diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index d08ae9051..088707b6e 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -1,3 +1,5 @@ +### Please do **not** open pull requests for *new features* now, as we are planning to rewrite large chunks of the code. Only bugfix PRs will be accepted. More details will be announced soon! + NewPipe contribution guidelines =============================== @@ -22,6 +24,7 @@ You'll see *exactly* what is sent, be able to add **your comments**, and then se * NewPipe is translated via [Weblate](https://hosted.weblate.org/projects/newpipe/strings/). Log in there with your GitHub account, or register. * Add the language you want to translate if it is not there already: see [How to add a new language](https://github.com/TeamNewPipe/NewPipe/wiki/How-to-add-a-new-language-to-NewPipe) in the wiki. +* NewPipe uses the [PrettyTime](https://github.com/ocpsoft/prettytime) library to display localized versions of dates and times. It needs to be translated, too. Read [these instructions to add a new language](https://www.ocpsoft.org/prettytime/#section-14) and [this issue](https://github.com/TeamNewPipe/NewPipe/issues/9134) for more info. ## Code contribution diff --git a/.github/ISSUE_TEMPLATE/question.yml b/.github/DISCUSSION_TEMPLATE/questions.yml similarity index 72% rename from .github/ISSUE_TEMPLATE/question.yml rename to .github/DISCUSSION_TEMPLATE/questions.yml index 4c42ab26a..2d467d5e5 100644 --- a/.github/ISSUE_TEMPLATE/question.yml +++ b/.github/DISCUSSION_TEMPLATE/questions.yml @@ -1,11 +1,8 @@ -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: + Thanks for taking the time to fill out this form! :hugs: Note that you can also ask questions on our [IRC channel](https://web.libera.chat/#newpipe). @@ -14,7 +11,9 @@ body: 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." + - label: "I made sure that there are *no existing issues or discussions* - [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 read the [FAQ](https://newpipe.net/FAQ/) and my question isn't listed." 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 @@ -27,7 +26,7 @@ body: label: What is/are your question(s)? validations: required: true - + - type: textarea id: additional-information attributes: diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index a0a9f9ef5..52897f1ac 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -1,6 +1,6 @@ name: Bug report description: Create a bug report to help us improve -labels: [bug] +labels: [bug, needs triage] body: - type: markdown attributes: @@ -14,10 +14,12 @@ body: attributes: label: "Checklist" options: - - label: "I am able to reproduce the bug with the [latest version](https://github.com/TeamNewPipe/NewPipe/releases/latest)." + - label: "I am able to reproduce the bug with the latest version given here: [CLICK THIS LINK](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 read the [FAQ](https://newpipe.net/FAQ/) and my problem isn't listed." + 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." @@ -40,7 +42,7 @@ body: 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 '...' @@ -69,11 +71,11 @@ body: 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. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index b0fdb56db..4721637bf 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: + - name: ❓ Question + url: https://github.com/TeamNewPipe/NewPipe/discussions/new?category=questions + about: Ask about anything NewPipe-related - name: 💬 IRC url: https://web.libera.chat/#newpipe about: Chat with us via IRC for quick Q/A diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 83d6f0299..31ef92c44 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,6 +1,6 @@ name: Feature request description: Suggest an idea for this project -labels: [enhancement] +labels: [feature request, needs triage] body: - type: markdown attributes: @@ -8,7 +8,6 @@ body: 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: @@ -16,6 +15,8 @@ body: 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 read the [FAQ](https://newpipe.net/FAQ/) and my problem isn't listed." + 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." @@ -43,7 +44,7 @@ body: 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: diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 10e40af2a..407c00a39 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -25,10 +25,10 @@ - -#### APK testing +#### APK testing -The APK can be found by going to the "Checks" tab below the title. On the left pane, click on "CI", scroll down to "artifacts" and click "app" to download the zip file which contains the debug APK of this PR. +The APK can be found by going to the "Checks" tab below the title. On the left pane, click on "CI", scroll down to "artifacts" and click "app" to download the zip file which contains the debug APK of this PR. You can find more info and a video demonstration [on this wiki page](https://github.com/TeamNewPipe/NewPipe/wiki/Download-APK-for-PR). #### Due diligence - [ ] I read the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md). diff --git a/.github/changed-lines-count-labeler.yml b/.github/changed-lines-count-labeler.yml new file mode 100644 index 000000000..902f376c0 --- /dev/null +++ b/.github/changed-lines-count-labeler.yml @@ -0,0 +1,17 @@ +# Add 'size/small' label to any changes with less than 50 lines +size/small: + max: 49 + +# Add 'size/medium' label to any changes between 50 and 249 lines +size/medium: + min: 50 + max: 249 + +# Add 'size/large' label to any changes between 250 and 749 lines +size/large: + min: 250 + max: 749 + +# Add 'size/giant' label to any changes for more than 749 lines +size/giant: + min: 750 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7dbfadc0b..0d76e1645 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,7 @@ on: branches: - dev - master + - release** paths-ignore: - 'README.md' - 'doc/**' @@ -30,19 +31,25 @@ on: jobs: build-and-test-jvm: runs-on: ubuntu-latest + + permissions: + contents: read + steps: - - uses: actions/checkout@v2 - - uses: gradle/wrapper-validation-action@v1 + - uses: actions/checkout@v4 + - uses: gradle/wrapper-validation-action@v2 - name: create and checkout branch # push events already checked out the branch if: github.event_name == 'pull_request' - run: git checkout -B ${{ github.head_ref }} + env: + BRANCH: ${{ github.head_ref }} + run: git checkout -B "$BRANCH" - - name: set up JDK 11 - uses: actions/setup-java@v2 + - name: set up JDK 17 + uses: actions/setup-java@v4 with: - java-version: 11 + java-version: 17 distribution: "temurin" cache: 'gradle' @@ -50,7 +57,7 @@ jobs: run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint - name: Upload APK - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v4 with: name: app path: app/build/outputs/apk/debug/*.apk @@ -61,15 +68,24 @@ jobs: timeout-minutes: 20 strategy: matrix: - # api-level 19 is min sdk, but throws errors related to desugaring - api-level: [ 21, 29 ] - steps: - - uses: actions/checkout@v2 + include: + - api-level: 21 + target: default + arch: x86 + - api-level: 33 + target: google_apis # emulator API 33 only exists with Google APIs + arch: x86_64 - - name: set up JDK 11 - uses: actions/setup-java@v2 + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: set up JDK 17 + uses: actions/setup-java@v4 with: - java-version: 11 + java-version: 17 distribution: "temurin" cache: 'gradle' @@ -77,12 +93,12 @@ jobs: uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} - # workaround to emulator bug: https://github.com/ReactiveCircus/android-emulator-runner/issues/160 - emulator-build: 7425822 + target: ${{ matrix.target }} + arch: ${{ matrix.arch }} 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 + uses: actions/upload-artifact@v4 if: failure() with: name: android-test-report-api${{ matrix.api-level }} @@ -90,20 +106,24 @@ jobs: sonar: runs-on: ubuntu-latest + + permissions: + contents: read + steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 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 + - name: Set up JDK 17 + uses: actions/setup-java@v4 with: - java-version: 11 # Sonar requires JDK 11 + java-version: 17 distribution: "temurin" cache: 'gradle' - name: Cache SonarCloud packages - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ~/.sonar/cache key: ${{ runner.os }}-sonar @@ -113,4 +133,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./gradlew build sonarqube --info + run: ./gradlew build sonar --info diff --git a/.github/workflows/image-minimizer.js b/.github/workflows/image-minimizer.js index 80cc5294c..d099068ba 100644 --- a/.github/workflows/image-minimizer.js +++ b/.github/workflows/image-minimizer.js @@ -17,6 +17,8 @@ module.exports = async ({github, context}) => { initialBody = context.payload.comment.body; } else if (context.eventName == 'issues') { initialBody = context.payload.issue.body; + } else if (context.eventName == 'pull_request') { + initialBody = context.payload.pull_request.body; } else { console.log('Aborting: No body found'); return; @@ -30,10 +32,12 @@ module.exports = async ({github, context}) => { } // Regex for finding images (simple variant) ![ALT_TEXT](https://*.githubusercontent.com//.) - const REGEX_IMAGE_LOOKUP = /\!\[(.*)\]\((https:\/\/[-a-z0-9]+\.githubusercontent\.com\/\d+\/[-0-9a-f]{32,512}\.(jpg|gif|png))\)/gm; + const REGEX_USER_CONTENT_IMAGE_LOOKUP = /\!\[(.*)\]\((https:\/\/[-a-z0-9]+\.githubusercontent\.com\/\d+\/[-0-9a-f]{32,512}\.(jpg|gif|png))\)/gm; + const REGEX_ASSETS_IMAGE_LOCKUP = /\!\[(.*)\]\((https:\/\/github\.com\/[-\w\d]+\/[-\w\d]+\/assets\/\d+\/[\-0-9a-f]{32,512})\)/gm; // Check if we found something - let foundSimpleImages = REGEX_IMAGE_LOOKUP.test(initialBody); + let foundSimpleImages = REGEX_USER_CONTENT_IMAGE_LOOKUP.test(initialBody) + || REGEX_ASSETS_IMAGE_LOCKUP.test(initialBody); if (!foundSimpleImages) { console.log('Found no simple images to process'); return; @@ -47,51 +51,8 @@ module.exports = async ({github, context}) => { var wasMatchModified = false; // 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 shouldModify = 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})`; - } - if (probeResult.height <= 0) { - throw `Unexpected probeResult.height (height is invalid: ${probeResult.height})`; - } - if (probeResult.wUnits != 'px') { - throw `Unexpected probeResult.wUnits (expected px but got ${probeResult.wUnits})`; - } - if (probeResult.width <= 0) { - throw `Unexpected probeResult.width (width is invalid: ${probeResult.width})`; - } - console.log(`Probing resulted in ${probeResult.width}x${probeResult.height}px`); - - shouldModify = probeResult.height > IMG_MAX_HEIGHT_PX && (probeResult.width / probeResult.height) < MIN_ASPECT_RATIO; - } catch(e) { - console.log('Probing failed:', e); - // Immediately abort - return match; - } - - if (shouldModify) { - wasMatchModified = true; - console.log(`Modifying match '${match}'`); - return `${g1}`; - } - - console.log(`Match '${match}' is ok/will not be modified`); - return match; - }); + let newBody = await replaceAsync(initialBody, REGEX_USER_CONTENT_IMAGE_LOOKUP, minimizeAsync); + newBody = await replaceAsync(newBody, REGEX_ASSETS_IMAGE_LOCKUP, minimizeAsync); if (!wasMatchModified) { console.log('Nothing was modified. Skipping update'); @@ -115,9 +76,17 @@ module.exports = async ({github, context}) => { repo: context.repo.repo, body: newBody }); + } else if (context.eventName == 'pull_request') { + console.log('Updating pull request', context.payload.pull_request.number); + await github.rest.pulls.update({ + pull_number: context.payload.pull_request.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: newBody + }); } - // Asnyc replace function from https://stackoverflow.com/a/48032528 + // Async replace function from https://stackoverflow.com/a/48032528 async function replaceAsync(str, regex, asyncFn) { const promises = []; str.replace(regex, (match, ...args) => { @@ -127,4 +96,52 @@ module.exports = async ({github, context}) => { const data = await Promise.all(promises); return str.replace(regex, () => data.shift()); } + + async function minimizeAsync(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 probeAspectRatio = 0; + let shouldModify = 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})`; + } + if (probeResult.height <= 0) { + throw `Unexpected probeResult.height (height is invalid: ${probeResult.height})`; + } + if (probeResult.wUnits != 'px') { + throw `Unexpected probeResult.wUnits (expected px but got ${probeResult.wUnits})`; + } + if (probeResult.width <= 0) { + throw `Unexpected probeResult.width (width is invalid: ${probeResult.width})`; + } + console.log(`Probing resulted in ${probeResult.width}x${probeResult.height}px`); + + probeAspectRatio = probeResult.width / probeResult.height; + shouldModify = probeResult.height > IMG_MAX_HEIGHT_PX && probeAspectRatio < MIN_ASPECT_RATIO; + } catch(e) { + console.log('Probing failed:', e); + // Immediately abort + return match; + } + + if (shouldModify) { + wasMatchModified = true; + console.log(`Modifying match '${match}'`); + return `${g1}`; + } + + console.log(`Match '${match}' is ok/will not be modified`); + return match; + } } diff --git a/.github/workflows/image-minimizer.yml b/.github/workflows/image-minimizer.yml index 77b1faecf..d9241c33b 100644 --- a/.github/workflows/image-minimizer.yml +++ b/.github/workflows/image-minimizer.yml @@ -5,15 +5,21 @@ on: types: [created, edited] issues: types: [opened, edited] + pull_request: + types: [opened, edited] + +permissions: + issues: write + pull-requests: write jobs: try-minimize: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - uses: actions/setup-node@v2 + - uses: actions/setup-node@v4 with: node-version: 16 @@ -21,7 +27,7 @@ jobs: run: npm i probe-image-size@7.2.3 --ignore-scripts - name: Minimize simple images - uses: actions/github-script@v5 + uses: actions/github-script@v7 timeout-minutes: 3 with: script: | diff --git a/.github/workflows/no-response.yml b/.github/workflows/no-response.yml index 54e749dc0..b3495135f 100644 --- a/.github/workflows/no-response.yml +++ b/.github/workflows/no-response.yml @@ -9,6 +9,10 @@ on: # Run daily at midnight. - cron: '0 0 * * *' +permissions: + issues: write + pull-requests: write + jobs: noResponse: runs-on: ubuntu-latest @@ -17,4 +21,4 @@ jobs: with: token: ${{ github.token }} daysUntilClose: 14 - responseRequiredLabel: waiting-for-author + responseRequiredLabel: waiting for author diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 000000000..a18daca3a --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,18 @@ +name: "PR size labeler" +on: [pull_request_target] +permissions: + contents: read + pull-requests: write + +jobs: + changed-lines-count-labeler: + runs-on: ubuntu-latest + name: Automatically labelling pull requests based on the changed lines count + permissions: + pull-requests: write + steps: + - name: Set a label + uses: TeamNewPipe/changed-lines-count-labeler@main + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + configuration-path: .github/changed-lines-count-labeler.yml diff --git a/README.md b/README.md index 28be82192..4e22f9260 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ +

We are planning to rewrite large chunks of the codebase, to bring about a new, modern and stable NewPipe!

+

Please do not open pull requests for new features now, only bugfix PRs will be accepted.

+

NewPipe

-

A libre lightweight streaming frontend for Android.

+

A libre lightweight streaming front-end for Android.

Get it on F-Droid

@@ -10,90 +13,95 @@ - +


-

ScreenshotsDescriptionFeaturesInstallation and updatesContributionDonateLicense

+

ScreenshotsSupported ServicesDescriptionFeaturesInstallation and updatesContributionDonateLicense

WebsiteBlogFAQPress


-*Read this in other languages: [English](README.md), [Español](doc/README.es.md), [한국어](doc/README.ko.md), [Soomaali](doc/README.so.md), [Português Brasil](doc/README.pt_BR.md), [Polski](doc/README.pl.md), [日本語](doc/README.ja.md), [Română](doc/README.ro.md), [Türkçe](doc/README.tr.md), [正體中文](doc/README.zh_TW.md).* +*Read this document in other languages: [Deutsch](doc/README.de.md), [English](README.md), [Español](doc/README.es.md), [Français](doc/README.fr.md), [हिन्दी](doc/README.hi.md), [Italiano](doc/README.it.md), [한국어](doc/README.ko.md), [Português Brasil](doc/README.pt_BR.md), [Polski](doc/README.pl.md), [ਪੰਜਾਬੀ ](doc/README.pa.md), [日本語](doc/README.ja.md), [Română](doc/README.ro.md), [Soomaali](doc/README.so.md), [Türkçe](doc/README.tr.md), [正體中文](doc/README.zh_TW.md), [অসমীয়া](doc/README.asm.md), [Српски](doc/README.sr.md)* -WARNING: THIS IS A BETA VERSION, THEREFORE YOU MAY ENCOUNTER BUGS. IF YOU DO, OPEN AN ISSUE VIA OUR GITHUB REPOSITORY. - -PUTTING NEWPIPE OR ANY FORK OF IT INTO THE GOOGLE PLAY STORE VIOLATES THEIR TERMS AND CONDITIONS. +> [!warning] +> THIS APP IS IN BETA, SO YOU MAY ENCOUNTER BUGS. IF YOU DO, OPEN AN ISSUE IN OUR GITHUB REPOSITORY BY FILLING OUT THE ISSUE TEMPLATE. +> +> PUTTING NEWPIPE, OR ANY FORK OF IT, INTO THE GOOGLE PLAY STORE VIOLATES THEIR TERMS AND CONDITIONS. ## Screenshots -[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_01.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_02.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_03.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_04.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_05.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_06.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_07.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_08.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_09.png) -[](fastlane/metadata/android/en-US/images/phoneScreenshots/shot_10.png) -[](fastlane/metadata/android/en-US/images/tenInchScreenshots/shot_11.png) -[](fastlane/metadata/android/en-US/images/tenInchScreenshots/shot_12.png) - -## Description - -NewPipe does not use any Google framework libraries, nor the YouTube API. Websites are only parsed to fetch required info, so this app can be used on devices without Google services installed. Also, you don't need a YouTube account to use NewPipe, which is copylefted libre software. - -### Features - -* Search videos -* No Login Required -* Display general info about videos -* Watch YouTube videos -* Listen to YouTube videos -* Popup mode (floating player) -* Select streaming player to watch video with -* Download videos -* Download audio only -* Open a video in Kodi -* Show next/related videos -* Search YouTube in a specific language -* Watch/Block age restricted material -* Display general info about channels -* Search channels -* Watch videos from a channel -* Orbot/Tor support (not yet directly) -* 1080p/2K/4K support -* View history -* Subscribe to channels -* Search history -* Search/watch playlists -* Watch as enqueued playlists -* Enqueue videos -* Local playlists -* Subtitles -* Livestream support -* Show comments +[](fastlane/metadata/android/en-US/images/phoneScreenshots/00.png) +[](fastlane/metadata/android/en-US/images/phoneScreenshots/01.png) +[](fastlane/metadata/android/en-US/images/phoneScreenshots/02.png) +[](fastlane/metadata/android/en-US/images/phoneScreenshots/03.png) +[](fastlane/metadata/android/en-US/images/phoneScreenshots/04.png) +[](fastlane/metadata/android/en-US/images/phoneScreenshots/05.png) +[](fastlane/metadata/android/en-US/images/phoneScreenshots/06.png) +[](fastlane/metadata/android/en-US/images/phoneScreenshots/07.png) +[](fastlane/metadata/android/en-US/images/phoneScreenshots/08.png) +

+[](fastlane/metadata/android/en-US/images/tenInchScreenshots/09.png) +[](fastlane/metadata/android/en-US/images/tenInchScreenshots/10.png) ### Supported Services -NewPipe supports multiple services. Our [docs](https://teamnewpipe.github.io/documentation/) provide more info on how a new service can be added to the app and the extractor. Please get in touch with us if you intend to add a new one. Currently supported services are: +NewPipe currently supports these services: -* YouTube -* SoundCloud \[beta\] -* media.ccc.de \[beta\] -* PeerTube instances \[beta\] -* Bandcamp \[beta\] + +* YouTube ([website](https://www.youtube.com/)) and YouTube Music ([website](https://music.youtube.com/)) ([wiki](https://en.wikipedia.org/wiki/YouTube)) +* PeerTube ([website](https://joinpeertube.org/)) and all its instances (open the website to know what that means!) ([wiki](https://en.wikipedia.org/wiki/PeerTube)) +* Bandcamp ([website](https://bandcamp.com/)) ([wiki](https://en.wikipedia.org/wiki/Bandcamp)) +* SoundCloud ([website](https://soundcloud.com/)) ([wiki](https://en.wikipedia.org/wiki/SoundCloud)) +* media.ccc.de ([website](https://media.ccc.de/)) ([wiki](https://en.wikipedia.org/wiki/Chaos_Computer_Club)) - +As you can see, NewPipe supports multiple video and audio services. Though it started off with YouTube, other people have added more services over the years, making NewPipe more and more versatile! + +Partially due to circumstance, and partially due to its popularity, YouTube is the best supported out of these services. If you use or are familiar with any of these other services, please help us improve support for them! We're looking for maintainers for SoundCloud and PeerTube. + +If you intend to add a new service, please get in touch with us first! Our [docs](https://teamnewpipe.github.io/documentation/) provide more information on how a new service can be added to the app and to the [NewPipe Extractor](https://github.com/TeamNewPipe/NewPipeExtractor). + +## Description + +NewPipe works by fetching the required data from the official API (e.g. PeerTube) of the service you're using. If the official API is restricted (e.g. YouTube) for our purposes, or is proprietary, the app parses the website or uses an internal API instead. This means that you don't need an account on any service to use NewPipe. + +Also, since they are free and open source software, neither the app nor the Extractor use any proprietary libraries or frameworks, such as Google Play Services. This means you can use NewPipe on devices or custom ROMs that do not have Google apps installed. + +### Features + +* Watch videos at resolutions up to 4K +* Listen to audio in the background, only loading the audio stream to save data +* Popup mode (floating player, aka Picture-in-Picture) +* Watch live streams +* Show/hide subtitles/closed captions +* Search videos and audios (on YouTube, you can specify the content language as well) +* Enqueue videos (and optionally save them as local playlists) +* Show/hide general information about videos (such as description and tags) +* Show/hide next/related videos +* Show/hide comments +* Search videos, audios, channels, playlists and albums +* Browse videos and audios within a channel +* Subscribe to channels (yes, without logging into any account!) +* Get notifications about new videos from channels you're subscribed to +* Create and edit channel groups (for easier browsing and management) +* Browse video feeds generated from your channel groups +* View and search your watch history +* Search and watch playlists (these are remote playlists, which means they're fetched from the service you're browsing) +* Create and edit local playlists (these are created and saved within the app, and have nothing to do with any service) +* Download videos/audios/subtitles (closed captions) +* Open in Kodi +* Watch/Block age-restricted material + + ## Installation and updates You can install NewPipe using one of the following methods: 1. Add our custom repo to F-Droid and install it from there. The instructions are here: https://newpipe.net/FAQ/tutorials/install-add-fdroid-repo/ 2. Download the APK from [GitHub Releases](https://github.com/TeamNewPipe/NewPipe/releases) and install it. - 3. Update via F-Droid. This is the slowest method of getting updates, as F-Droid must recognize changes, build the APK itself, sign it, then push the update to users. + 3. Update via F-Droid. This is the slowest method of getting updates, as F-Droid must recognize changes, build the APK itself, sign it, and then push the update to users. 4. Build a debug APK yourself. This is the fastest way to get new features on your device, but is much more complicated, so we recommend using one of the other methods. + 5. If you're interested in a specific feature or bugfix provided in a Pull Request in this repo, you can also download its APK from within the PR. Read the PR description for instructions. The great thing about PR-specific APKs is that they're installed side-by-side the official app, so you don't have to worry about losing your data or messing anything up. -We recommend method 1 for most users. APKs installed using method 1 or 2 are compatible with each other, but not with those installed using method 3. This is due to the same signing key (ours) being used for 1 and 2, but a different signing key (F-Droid's) being used for 3. Building a debug APK using method 4 excludes a key entirely. Signing keys help ensure that a user isn't tricked into installing a malicious update to an app. +We recommend method 1 for most users. APKs installed using method 1 or 2 are compatible with each other (meaning that if you installed NewPipe using either method 1 or 2, you can also update NewPipe using the other), but not with those installed using method 3. This is due to the same signing key (ours) being used for 1 and 2, but a different signing key (F-Droid's) being used for 3. Building a debug APK using method 4 excludes a key entirely. Signing keys help ensure that a user isn't tricked into installing a malicious update to an app. When using method 5, each APK is signed with a different random key supplied by GitHub Actions, so you cannot even update it. You will have to backup and restore the app data each time you wish to use a new APK. In the meanwhile, if you want to switch sources for some reason (e.g. NewPipe's core functionality breaks and F-Droid doesn't have the latest update yet), we recommend following this procedure: 1. Back up your data via Settings > Content > Export Database so you keep your history, subscriptions, and playlists @@ -101,47 +109,31 @@ In the meanwhile, if you want to switch sources for some reason (e.g. NewPipe's 3. Download the APK from the new source and install it 4. Import the data from step 1 via Settings > Content > Import Database -## Contribution -Whether you have ideas, translations, design changes, code cleaning, or real heavy code changes, help is always welcome. -The more is done the better it gets! +Note: when you're importing a database into the official app, always make sure that it is the one you exported _from_ the official app. If you import a database exported from an APK other than the official app, it may break things. Such an action is unsupported, and you should only do so when you're absolutely certain you know what you're doing. -If you'd like to get involved, check our [contribution notes](.github/CONTRIBUTING.md). +## Contribution +Whether you have ideas, translations, design changes, code cleaning, or even major code changes, help is always welcome. The app gets better and better with each contribution, no matter how big or small! If you'd like to get involved, check our [contribution notes](.github/CONTRIBUTING.md). Translation status ## Donate -If you like NewPipe we'd be happy about a donation. You can either send bitcoin or donate via Bountysource or Liberapay. For further info on donating to NewPipe, please visit our [website](https://newpipe.net/donate). +If you like NewPipe, you're welcome to send a donation. We prefer Liberapay, as it is both open-source and non-profit. For further info on donating to NewPipe, please visit our [website](https://newpipe.net/donate). - - - - - - - - - -
BitcoinBitcoin QR code16A9J59ahMRqkLSZjhYj33n9j3fMztFxnh
Liberapay Visit NewPipe at liberapay.com Donate via Liberapay
BountysourceVisit NewPipe at bountysource.comCheck out how many bounties you can earn.
## Privacy Policy -The NewPipe project aims to provide a private, anonymous experience for using media web services. -Therefore, the app does not collect any data without your consent. NewPipe's privacy policy explains in detail what data is sent and stored when you send a crash report, or comment in our blog. You can find the document [here](https://newpipe.net/legal/privacy/). +The NewPipe project aims to provide a private, anonymous experience for using web-based media services. Therefore, the app does not collect any data without your consent. NewPipe's privacy policy explains in detail what data is sent and stored when you send a crash report, or leave a comment in our blog. You can find the document [here](https://newpipe.net/legal/privacy/). ## License [![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](https://www.gnu.org/licenses/gpl-3.0.en.html) -NewPipe is Free Software: You can use, study, share, and improve it at -will. Specifically you can redistribute and/or modify it under the terms of the -[GNU General Public License](https://www.gnu.org/licenses/gpl.html) as -published by the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. +NewPipe is Free Software: You can use, study, share, and improve it at will. Specifically you can redistribute and/or modify it under the terms of the [GNU General Public License](https://www.gnu.org/licenses/gpl.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. diff --git a/app/build.gradle b/app/build.gradle index d3f6c5d06..7a3b28661 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,28 +1,29 @@ +import com.android.tools.profgen.ArtProfileKt +import com.android.tools.profgen.ArtProfileSerializer +import com.android.tools.profgen.DexFile + plugins { id "com.android.application" id "kotlin-android" id "kotlin-kapt" id "kotlin-parcelize" id "checkstyle" - id "org.sonarqube" version "3.3" + id "org.sonarqube" version "4.0.0.2929" } android { - compileSdk 31 - buildToolsVersion '31.0.0' + compileSdk 34 + namespace 'org.schabi.newpipe' defaultConfig { applicationId "org.schabi.newpipe" resValue "string", "app_name", "NewPipe" - minSdk 19 - targetSdk 29 - versionCode 984 - versionName "0.22.1" - - multiDexEnabled true + minSdk 21 + targetSdk 33 + versionCode 996 + versionName "0.26.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - vectorDrawables.useSupportLibrary = true javaCompileOptions { annotationProcessorOptions { @@ -49,9 +50,6 @@ android { } } - // Keep the release build type at the end of the list to override 'archivesBaseName' of - // debug build. This seems to be a Gradle bug, therefore - // TODO: update Gradle version release { if (System.properties.containsKey('packageSuffix')) { applicationIdSuffix System.getProperty('packageSuffix') @@ -79,13 +77,13 @@ android { // Flag to enable support for the new language APIs coreLibraryDesugaringEnabled true - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 encoding 'utf-8' } kotlinOptions { - jvmTarget = JavaVersion.VERSION_11 + jvmTarget = JavaVersion.VERSION_17 } sourceSets { @@ -95,25 +93,33 @@ android { buildFeatures { viewBinding true } + + packagingOptions { + resources { + // remove two files which belong to jsoup + // no idea how they ended up in the META-INF dir... + excludes += ['META-INF/README.md', 'META-INF/CHANGES', + // 'COPYRIGHT' belongs to RxJava... + 'META-INF/COPYRIGHT'] + } + } } ext { - checkstyleVersion = '10.0' + checkstyleVersion = '10.12.1' - androidxLifecycleVersion = '2.3.1' - androidxRoomVersion = '2.4.2' - androidxWorkVersion = '2.7.1' + androidxLifecycleVersion = '2.6.2' + androidxRoomVersion = '2.6.1' + androidxWorkVersion = '2.8.1' icepickVersion = '3.2.0' - exoPlayerVersion = '2.17.1' - googleAutoServiceVersion = '1.0.1' - groupieVersion = '2.10.0' + exoPlayerVersion = '2.18.7' + googleAutoServiceVersion = '1.1.1' + groupieVersion = '2.10.1' markwonVersion = '4.6.2' - leakCanaryVersion = '2.5' + leakCanaryVersion = '2.12' stethoVersion = '1.6.0' - mockitoVersion = '4.0.0' - assertJVersion = '3.22.0' } configurations { @@ -128,7 +134,7 @@ checkstyle { toolVersion = checkstyleVersion } -task runCheckstyle(type: Checkstyle) { +tasks.register('runCheckstyle', Checkstyle) { source 'src' include '**/*.java' exclude '**/gen/**' @@ -149,20 +155,22 @@ task runCheckstyle(type: Checkstyle) { def outputDir = "${project.buildDir}/reports/ktlint/" def inputFiles = project.fileTree(dir: "src", include: "**/*.kt") -task runKtlint(type: JavaExec) { +tasks.register('runKtlint', JavaExec) { inputs.files(inputFiles) outputs.dir(outputDir) getMainClass().set("com.pinterest.ktlint.Main") classpath = configurations.ktlint args "src/**/*.kt" + jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") } -task formatKtlint(type: JavaExec) { +tasks.register('formatKtlint', JavaExec) { inputs.files(inputFiles) outputs.dir(outputDir) getMainClass().set("com.pinterest.ktlint.Main") classpath = configurations.ktlint args "-F", "src/**/*.kt" + jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") } afterEvaluate { @@ -172,7 +180,7 @@ afterEvaluate { preDebugBuild.dependsOn runCheckstyle, runKtlint } -sonarqube { +sonar { properties { property "sonar.projectKey", "TeamNewPipe_NewPipe" property "sonar.organization", "teamnewpipe" @@ -182,7 +190,7 @@ sonarqube { dependencies { /** Desugaring **/ - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.0.4' /** NewPipe libraries **/ // You can use a local version by uncommenting a few lines in settings.gradle @@ -190,41 +198,39 @@ 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:5a1873084' + implementation 'com.github.Stypox:NewPipeExtractor:aaf3231fc75d7b4177549fec4aa7e672bfe84015' + implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0' /** Checkstyle **/ checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}" - ktlint 'com.pinterest:ktlint:0.44.0' + ktlint 'com.pinterest:ktlint:0.45.2' /** Kotlin **/ - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin_version}" + implementation "org.jetbrains.kotlin:kotlin-stdlib:${kotlin_version}" /** AndroidX **/ - implementation 'androidx.appcompat:appcompat:1.3.1' + implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.cardview:cardview:1.0.0' - implementation 'androidx.constraintlayout:constraintlayout:2.1.3' - implementation 'androidx.core:core-ktx:1.6.0' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.documentfile:documentfile:1.0.1' - implementation 'androidx.fragment:fragment-ktx:1.3.6' - implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}" - implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}" + implementation 'androidx.fragment:fragment-ktx:1.6.2' + implementation "androidx.lifecycle:lifecycle-livedata-ktx:${androidxLifecycleVersion}" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${androidxLifecycleVersion}" implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' - implementation 'androidx.media:media:1.5.0' - implementation 'androidx.multidex:multidex:2.0.1' - implementation 'androidx.preference:preference:1.2.0' - implementation 'androidx.recyclerview:recyclerview:1.2.1' + implementation 'androidx.media:media:1.7.0' + implementation 'androidx.preference:preference:1.2.1' + implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation "androidx.room:room-runtime:${androidxRoomVersion}" implementation "androidx.room:room-rxjava3:${androidxRoomVersion}" kapt "androidx.room:room-compiler:${androidxRoomVersion}" implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' // Newer version specified to prevent accessibility regressions with RecyclerView, see: // 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.5.0' - implementation "androidx.work:work-runtime:${androidxWorkVersion}" + implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02' implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}" implementation "androidx.work:work-rxjava3:${androidxWorkVersion}" + implementation 'com.google.android.material:material:1.11.0' /** Third-party libraries **/ // Instance state boilerplate elimination @@ -232,14 +238,19 @@ dependencies { kapt "frankiesardo:icepick-processor:${icepickVersion}" // HTML parser - implementation "org.jsoup:jsoup:1.14.3" + implementation "org.jsoup:jsoup:1.17.2" // HTTP client - //noinspection GradleDependency --> do not update okhttp to keep supporting Android 4.4 users - implementation "com.squareup.okhttp3:okhttp:3.12.13" + implementation "com.squareup.okhttp3:okhttp:4.12.0" // Media player - implementation "com.google.android.exoplayer:exoplayer:${exoPlayerVersion}" + implementation "com.google.android.exoplayer:exoplayer-core:${exoPlayerVersion}" + implementation "com.google.android.exoplayer:exoplayer-dash:${exoPlayerVersion}" + implementation "com.google.android.exoplayer:exoplayer-database:${exoPlayerVersion}" + implementation "com.google.android.exoplayer:exoplayer-datasource:${exoPlayerVersion}" + implementation "com.google.android.exoplayer:exoplayer-hls:${exoPlayerVersion}" + implementation "com.google.android.exoplayer:exoplayer-smoothstreaming:${exoPlayerVersion}" + implementation "com.google.android.exoplayer:exoplayer-ui:${exoPlayerVersion}" implementation "com.google.android.exoplayer:extension-mediasession:${exoPlayerVersion}" // Metadata generator for service descriptors @@ -258,42 +269,38 @@ dependencies { implementation "io.noties.markwon:core:${markwonVersion}" implementation "io.noties.markwon:linkify:${markwonVersion}" - // File picker - implementation "com.nononsenseapps:filepicker:4.2.1" - // Crash reporting - implementation "ch.acra:acra-core:5.8.4" + implementation "ch.acra:acra-core:5.11.3" // Properly restarting implementation 'com.jakewharton:process-phoenix:2.1.2' // Reactive extensions for Java VM - implementation "io.reactivex.rxjava3:rxjava:3.0.13" - implementation "io.reactivex.rxjava3:rxandroid:3.0.0" + implementation "io.reactivex.rxjava3:rxjava:3.1.8" + implementation "io.reactivex.rxjava3:rxandroid:3.0.2" // 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.2.Final" + implementation "org.ocpsoft.prettytime:prettytime:5.0.7.Final" /** Debugging **/ // Memory leak detection - implementation "com.squareup.leakcanary:leakcanary-object-watcher-android:${leakCanaryVersion}" - implementation "com.squareup.leakcanary:plumber-android:${leakCanaryVersion}" - debugImplementation "com.squareup.leakcanary:leakcanary-android:${leakCanaryVersion}" + debugImplementation "com.squareup.leakcanary:leakcanary-object-watcher-android:${leakCanaryVersion}" + debugImplementation "com.squareup.leakcanary:plumber-android:${leakCanaryVersion}" + debugImplementation "com.squareup.leakcanary:leakcanary-android-core:${leakCanaryVersion}" // Debug bridge for Android debugImplementation "com.facebook.stetho:stetho:${stethoVersion}" debugImplementation "com.facebook.stetho:stetho-okhttp3:${stethoVersion}" /** Testing **/ testImplementation 'junit:junit:4.13.2' - testImplementation "org.mockito:mockito-core:${mockitoVersion}" - testImplementation "org.mockito:mockito-inline:${mockitoVersion}" + testImplementation 'org.mockito:mockito-core:5.6.0' - androidTestImplementation "androidx.test.ext:junit:1.1.3" - androidTestImplementation "androidx.test:runner:1.4.0" + androidTestImplementation "androidx.test.ext:junit:1.1.5" + androidTestImplementation "androidx.test:runner:1.5.2" androidTestImplementation "androidx.room:room-testing:${androidxRoomVersion}" - androidTestImplementation "org.assertj:assertj-core:${assertJVersion}" + androidTestImplementation "org.assertj:assertj-core:3.24.2" } static String getGitWorkingBranch() { @@ -311,3 +318,25 @@ static String getGitWorkingBranch() { return "" } } + +// fix reproducible builds +project.afterEvaluate { + tasks.compileReleaseArtProfile.doLast { + outputs.files.each { file -> + if (file.toString().endsWith(".profm")) { + println("Sorting ${file} ...") + def version = ArtProfileSerializer.valueOf("METADATA_0_0_2") + def profile = ArtProfileKt.ArtProfile(file) + def keys = new ArrayList(profile.profileData.keySet()) + def sortedData = new LinkedHashMap() + Collections.sort keys, new DexFile.Companion() + keys.each { key -> sortedData[key] = profile.profileData[key] } + new FileOutputStream(file).with { + write(version.magicBytes$profgen) + write(version.versionBytes$profgen) + version.write$profgen(it, sortedData, "") + } + } + } + } +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 53a9ecd5a..d21f33e1f 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,36 +1,18 @@ -# Add project specific ProGuard rules here. -# By default, the flags in this file are appended to flags specified -# in /home/the-scrabi/bin/Android/Sdk/tools/proguard/proguard-android.txt -# You can edit the include path and order by changing the proguardFiles -# directive in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# Add any project specific keep options here: - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} +# https://developer.android.com/build/shrink-code +## Helps debug release versions -dontobfuscate + +## Rules for NewPipeExtractor -keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; } --keep class org.ocpsoft.prettytime.i18n.** { *; } - -keep class org.mozilla.javascript.** { *; } - -keep class org.mozilla.classfile.ClassFileWriter +-dontwarn org.mozilla.javascript.tools.** + +## Rules for ExoPlayer -keep class com.google.android.exoplayer2.** { *; } --dontwarn org.mozilla.javascript.tools.** --dontwarn android.arch.util.paging.CountedDataSource --dontwarn android.arch.persistence.room.paging.LimitOffsetDataSource - - -# Rules for icepick. Copy paste from https://github.com/frankiesardo/icepick +## Rules for Icepick. Copy pasted from https://github.com/frankiesardo/icepick -dontwarn icepick.** -keep class icepick.** { *; } -keep class **$$Icepick { *; } @@ -39,15 +21,17 @@ } -keepnames class * { @icepick.State *;} -# Rules for OkHttp. Copy paste from https://github.com/square/okhttp +## Rules for OkHttp. Copy pasted from https://github.com/square/okhttp -dontwarn okhttp3.** -dontwarn okio.** --dontwarn javax.annotation.** -# A resource is loaded with a relative path so the package of this class must be preserved. --keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase + +## See https://github.com/TeamNewPipe/NewPipe/pull/1441 -keepclassmembers class * implements java.io.Serializable { static final long serialVersionUID; !static !transient ; private void writeObject(java.io.ObjectOutputStream); private void readObject(java.io.ObjectInputStream); } + +## For some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml) +-keep class org.schabi.newpipe.settings.notifications.** { *; } diff --git a/app/sampledata/channels.json b/app/sampledata/channels.json new file mode 100644 index 000000000..207d78597 --- /dev/null +++ b/app/sampledata/channels.json @@ -0,0 +1,19 @@ +{ + "data": [ + { + "name": "BBC", + "additional": "12K subscribers•233 videos", + "description": "The BBC is the world’s leading public service broadcaster. We’re impartial and independent, and every day we create distinctive, world-class programmes and content which inform, educate and entertain millions of people in the UK and around the world. SUBSCRIBE to our YouTube channel to get the best of BBC entertainment and comedy programmes, stories from science and nature documentaries, and much more! https://bit.ly/2IXqEIn Get ALL your fresh TV, and sofa-hugging box sets on iPlayer https://bbc.in/2J18jYJ" + }, + { + "name": "Linus Tech Tips", + "additional": "1M subscribers•233 videos", + "description": "Looking for a Tech YouTuber?\n\nLinus Tech Tips is a passionate team of \"professionally curious\" experts in consumer technology and video production which aims to inform and educate people of all ages through our entertaining videos. We create product reviews, step-by-step computer build guides, and a variety of other tech-focused content.\n\nSchedule:\nNew videos every Saturday to Thursday @ 10:00am Pacific\nLive WAN Show podcasts every Friday @ ~5:00pm Pacific" + }, + { + "name": "Marques Brownlee", + "additional": "13 subscribers•12K videos", + "description": "MKBHD: Quality Tech Videos | YouTuber | Geek | Consumer Electronics | Tech Head | Internet Personality!\n\nbusiness@MKBHD.com\n\nNYC" + } + ] +} \ No newline at end of file diff --git a/app/schemas/org.schabi.newpipe.database.AppDatabase/6.json b/app/schemas/org.schabi.newpipe.database.AppDatabase/6.json index 3ef363e30..ee69363cc 100644 --- a/app/schemas/org.schabi.newpipe.database.AppDatabase/6.json +++ b/app/schemas/org.schabi.newpipe.database.AppDatabase/6.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 6, - "identityHash": "9ffc14521c566beed378d77430de3f0c", + "identityHash": "4084aa342aef315dc7b558770a7755a9", "entities": [ { "tableName": "subscriptions", @@ -323,7 +323,7 @@ }, { "tableName": "playlists", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT, `display_index` INTEGER NOT NULL)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL)", "fields": [ { "fieldPath": "uid", @@ -344,8 +344,8 @@ "notNull": false }, { - "fieldPath": "displayIndex", - "columnName": "display_index", + "fieldPath": "isThumbnailPermanent", + "columnName": "is_thumbnail_permanent", "affinity": "INTEGER", "notNull": true } @@ -447,7 +447,7 @@ }, { "tableName": "remote_playlists", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `display_index` INTEGER NOT NULL, `stream_count` INTEGER)", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)", "fields": [ { "fieldPath": "uid", @@ -485,12 +485,6 @@ "affinity": "TEXT", "notNull": false }, - { - "fieldPath": "displayIndex", - "columnName": "display_index", - "affinity": "INTEGER", - "notNull": true - }, { "fieldPath": "streamCount", "columnName": "stream_count", @@ -737,7 +731,7 @@ "views": [], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '9ffc14521c566beed378d77430de3f0c')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4084aa342aef315dc7b558770a7755a9')" ] } } \ No newline at end of file diff --git a/app/schemas/org.schabi.newpipe.database.AppDatabase/7.json b/app/schemas/org.schabi.newpipe.database.AppDatabase/7.json new file mode 100644 index 000000000..a14f8b9a8 --- /dev/null +++ b/app/schemas/org.schabi.newpipe.database.AppDatabase/7.json @@ -0,0 +1,737 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "012fc8e7ad3333f1597347f34e76a513", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatar_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subscriberCount", + "columnName": "subscriber_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "notificationMode", + "columnName": "notification_mode", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_subscriptions_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "creationDate", + "columnName": "creation_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "search", + "columnName": "search", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_search_history_search", + "unique": false, + "columnNames": [ + "search" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "streams", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "streamType", + "columnName": "stream_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploader_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "viewCount", + "columnName": "view_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "textualUploadDate", + "columnName": "textual_upload_date", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadDate", + "columnName": "upload_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isUploadDateApproximation", + "columnName": "is_upload_date_approximation", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_streams_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "stream_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessDate", + "columnName": "access_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repeatCount", + "columnName": "repeat_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "stream_id", + "access_date" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_stream_history_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "stream_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progressMillis", + "columnName": "progress_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "stream_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, `thumbnail_stream_id` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isThumbnailPermanent", + "columnName": "is_thumbnail_permanent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailStreamId", + "columnName": "thumbnail_stream_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_playlists_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "playlist_stream_join", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "playlistUid", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "join_index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "playlist_id", + "join_index" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_playlist_stream_join_playlist_id_join_index", + "unique": true, + "columnNames": [ + "playlist_id", + "join_index" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)" + }, + { + "name": "index_playlist_stream_join_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "playlists", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "remote_playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "streamCount", + "columnName": "stream_count", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_remote_playlists_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_remote_playlists_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "feed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "streamId", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "stream_id", + "subscription_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_feed_subscription_id", + "unique": false, + "columnNames": [ + "subscription_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "feed_group", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_feed_group_sort_order", + "unique": false, + "columnNames": [ + "sort_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "feed_group_subscription_join", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "feedGroupId", + "columnName": "group_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "group_id", + "subscription_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_feed_group_subscription_join_subscription_id", + "unique": false, + "columnNames": [ + "subscription_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" + } + ], + "foreignKeys": [ + { + "table": "feed_group", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "group_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "feed_last_updated", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "subscription_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '012fc8e7ad3333f1597347f34e76a513')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.schabi.newpipe.database.AppDatabase/8.json b/app/schemas/org.schabi.newpipe.database.AppDatabase/8.json new file mode 100644 index 000000000..d4a89567b --- /dev/null +++ b/app/schemas/org.schabi.newpipe.database.AppDatabase/8.json @@ -0,0 +1,737 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "012fc8e7ad3333f1597347f34e76a513", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatar_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subscriberCount", + "columnName": "subscriber_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "notificationMode", + "columnName": "notification_mode", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_subscriptions_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "creationDate", + "columnName": "creation_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "search", + "columnName": "search", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_search_history_search", + "unique": false, + "columnNames": [ + "search" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "streams", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "streamType", + "columnName": "stream_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploader_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "viewCount", + "columnName": "view_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "textualUploadDate", + "columnName": "textual_upload_date", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadDate", + "columnName": "upload_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isUploadDateApproximation", + "columnName": "is_upload_date_approximation", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_streams_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "stream_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessDate", + "columnName": "access_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repeatCount", + "columnName": "repeat_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "stream_id", + "access_date" + ] + }, + "indices": [ + { + "name": "index_stream_history_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "stream_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progressMillis", + "columnName": "progress_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "stream_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, `thumbnail_stream_id` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isThumbnailPermanent", + "columnName": "is_thumbnail_permanent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailStreamId", + "columnName": "thumbnail_stream_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_playlists_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "playlist_stream_join", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "playlistUid", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "join_index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id", + "join_index" + ] + }, + "indices": [ + { + "name": "index_playlist_stream_join_playlist_id_join_index", + "unique": true, + "columnNames": [ + "playlist_id", + "join_index" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)" + }, + { + "name": "index_playlist_stream_join_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "playlists", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "remote_playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "streamCount", + "columnName": "stream_count", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_remote_playlists_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_remote_playlists_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "feed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "streamId", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "stream_id", + "subscription_id" + ] + }, + "indices": [ + { + "name": "index_feed_subscription_id", + "unique": false, + "columnNames": [ + "subscription_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "feed_group", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_feed_group_sort_order", + "unique": false, + "columnNames": [ + "sort_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "feed_group_subscription_join", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "feedGroupId", + "columnName": "group_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "group_id", + "subscription_id" + ] + }, + "indices": [ + { + "name": "index_feed_group_subscription_join_subscription_id", + "unique": false, + "columnNames": [ + "subscription_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" + } + ], + "foreignKeys": [ + { + "table": "feed_group", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "group_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "feed_last_updated", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscription_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '012fc8e7ad3333f1597347f34e76a513')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/org.schabi.newpipe.database.AppDatabase/9.json b/app/schemas/org.schabi.newpipe.database.AppDatabase/9.json new file mode 100644 index 000000000..0fcd383af --- /dev/null +++ b/app/schemas/org.schabi.newpipe.database.AppDatabase/9.json @@ -0,0 +1,749 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "94596ea2227c63dd78b472ea4a83f1c4", + "entities": [ + { + "tableName": "subscriptions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatar_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "subscriberCount", + "columnName": "subscriber_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "notificationMode", + "columnName": "notification_mode", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_subscriptions_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "creationDate", + "columnName": "creation_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "search", + "columnName": "search", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_search_history_search", + "unique": false, + "columnNames": [ + "search" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "streams", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "streamType", + "columnName": "stream_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uploaderUrl", + "columnName": "uploader_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "viewCount", + "columnName": "view_count", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "textualUploadDate", + "columnName": "textual_upload_date", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploadDate", + "columnName": "upload_date", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isUploadDateApproximation", + "columnName": "is_upload_date_approximation", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_streams_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "stream_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accessDate", + "columnName": "access_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "repeatCount", + "columnName": "repeat_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "stream_id", + "access_date" + ] + }, + "indices": [ + { + "name": "index_stream_history_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "stream_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "progressMillis", + "columnName": "progress_time", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "stream_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, `thumbnail_stream_id` INTEGER NOT NULL, `display_index` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isThumbnailPermanent", + "columnName": "is_thumbnail_permanent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailStreamId", + "columnName": "thumbnail_stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayIndex", + "columnName": "display_index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_playlists_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "playlist_stream_join", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "playlistUid", + "columnName": "playlist_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamUid", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "join_index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "playlist_id", + "join_index" + ] + }, + "indices": [ + { + "name": "index_playlist_stream_join_playlist_id_join_index", + "unique": true, + "columnNames": [ + "playlist_id", + "join_index" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)" + }, + { + "name": "index_playlist_stream_join_stream_id", + "unique": false, + "columnNames": [ + "stream_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)" + } + ], + "foreignKeys": [ + { + "table": "playlists", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "playlist_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "remote_playlists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `display_index` INTEGER NOT NULL, `stream_count` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serviceId", + "columnName": "service_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uploader", + "columnName": "uploader", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayIndex", + "columnName": "display_index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "streamCount", + "columnName": "stream_count", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_remote_playlists_name", + "unique": false, + "columnNames": [ + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)" + }, + { + "name": "index_remote_playlists_service_id_url", + "unique": true, + "columnNames": [ + "service_id", + "url" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "feed", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "streamId", + "columnName": "stream_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "stream_id", + "subscription_id" + ] + }, + "indices": [ + { + "name": "index_feed_subscription_id", + "unique": false, + "columnNames": [ + "subscription_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" + } + ], + "foreignKeys": [ + { + "table": "streams", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "stream_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "feed_group", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "icon_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sortOrder", + "columnName": "sort_order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uid" + ] + }, + "indices": [ + { + "name": "index_feed_group_sort_order", + "unique": false, + "columnNames": [ + "sort_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "feed_group_subscription_join", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "feedGroupId", + "columnName": "group_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "group_id", + "subscription_id" + ] + }, + "indices": [ + { + "name": "index_feed_group_subscription_join_subscription_id", + "unique": false, + "columnNames": [ + "subscription_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)" + } + ], + "foreignKeys": [ + { + "table": "feed_group", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "group_id" + ], + "referencedColumns": [ + "uid" + ] + }, + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + }, + { + "tableName": "feed_last_updated", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)", + "fields": [ + { + "fieldPath": "subscriptionId", + "columnName": "subscription_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "subscription_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "subscriptions", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "subscription_id" + ], + "referencedColumns": [ + "uid" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '94596ea2227c63dd78b472ea4a83f1c4')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt b/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt index 73b6313db..f71880366 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt @@ -4,17 +4,18 @@ import android.content.ContentValues import android.database.sqlite.SQLiteDatabase import androidx.room.Room import androidx.room.testing.MigrationTestHelper -import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNull import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.schabi.newpipe.database.playlist.model.PlaylistEntity import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity +import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.stream.StreamType @RunWith(AndroidJUnit4::class) @@ -39,7 +40,7 @@ class DatabaseMigrationTest { @get:Rule val testHelper = MigrationTestHelper( InstrumentationRegistry.getInstrumentation(), - AppDatabase::class.java.canonicalName, FrameworkSQLiteOpenHelperFactory() + AppDatabase::class.java ) @Test @@ -48,7 +49,8 @@ class DatabaseMigrationTest { databaseInV2.run { insert( - "streams", SQLiteDatabase.CONFLICT_FAIL, + "streams", + SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply { put("service_id", DEFAULT_SERVICE_ID) put("url", DEFAULT_URL) @@ -60,14 +62,16 @@ class DatabaseMigrationTest { } ) insert( - "streams", SQLiteDatabase.CONFLICT_FAIL, + "streams", + SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply { put("service_id", DEFAULT_SECOND_SERVICE_ID) put("url", DEFAULT_SECOND_URL) } ) insert( - "streams", SQLiteDatabase.CONFLICT_FAIL, + "streams", + SQLiteDatabase.CONFLICT_FAIL, ContentValues().apply { put("service_id", DEFAULT_SERVICE_ID) } @@ -76,18 +80,45 @@ class DatabaseMigrationTest { } testHelper.runMigrationsAndValidate( - AppDatabase.DATABASE_NAME, Migrations.DB_VER_3, - true, Migrations.MIGRATION_2_3 + AppDatabase.DATABASE_NAME, + Migrations.DB_VER_3, + true, + Migrations.MIGRATION_2_3 ) testHelper.runMigrationsAndValidate( - AppDatabase.DATABASE_NAME, Migrations.DB_VER_4, - true, Migrations.MIGRATION_3_4 + AppDatabase.DATABASE_NAME, + Migrations.DB_VER_4, + true, + Migrations.MIGRATION_3_4 ) testHelper.runMigrationsAndValidate( - AppDatabase.DATABASE_NAME, Migrations.DB_VER_5, - true, Migrations.MIGRATION_4_5 + AppDatabase.DATABASE_NAME, + Migrations.DB_VER_5, + true, + Migrations.MIGRATION_4_5 + ) + + testHelper.runMigrationsAndValidate( + AppDatabase.DATABASE_NAME, + Migrations.DB_VER_6, + true, + Migrations.MIGRATION_5_6 + ) + + testHelper.runMigrationsAndValidate( + AppDatabase.DATABASE_NAME, + Migrations.DB_VER_7, + true, + Migrations.MIGRATION_6_7 + ) + + testHelper.runMigrationsAndValidate( + AppDatabase.DATABASE_NAME, + Migrations.DB_VER_8, + true, + Migrations.MIGRATION_7_8 ) testHelper.runMigrationsAndValidate( @@ -130,7 +161,65 @@ class DatabaseMigrationTest { } @Test - fun migrateDatabaseFrom5to6() { + fun migrateDatabaseFrom7to8() { + val databaseInV7 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_7) + + val defaultSearch1 = " abc " + val defaultSearch2 = " abc" + + val serviceId = DEFAULT_SERVICE_ID // YouTube + // Use id different to YouTube because two searches with the same query + // but different service are considered not equal. + val otherServiceId = ServiceList.SoundCloud.serviceId + + databaseInV7.run { + insert( + "search_history", SQLiteDatabase.CONFLICT_FAIL, + ContentValues().apply { + put("service_id", serviceId) + put("search", defaultSearch1) + } + ) + insert( + "search_history", SQLiteDatabase.CONFLICT_FAIL, + ContentValues().apply { + put("service_id", serviceId) + put("search", defaultSearch2) + } + ) + insert( + "search_history", SQLiteDatabase.CONFLICT_FAIL, + ContentValues().apply { + put("service_id", otherServiceId) + put("search", defaultSearch1) + } + ) + insert( + "search_history", SQLiteDatabase.CONFLICT_FAIL, + ContentValues().apply { + put("service_id", otherServiceId) + put("search", defaultSearch2) + } + ) + close() + } + + testHelper.runMigrationsAndValidate( + AppDatabase.DATABASE_NAME, Migrations.DB_VER_8, + true, Migrations.MIGRATION_7_8 + ) + + val migratedDatabaseV8 = getMigratedDatabase() + val listFromDB = migratedDatabaseV8.searchHistoryDAO().all.blockingFirst() + + assertEquals(2, listFromDB.size) + assertEquals("abc", listFromDB[0].search) + assertEquals("abc", listFromDB[1].search) + assertNotEquals(listFromDB[0].serviceId, listFromDB[1].serviceId) + } + + @Test + fun migrateDatabaseFrom8to9() { val databaseInV5 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_5) val localUid1: Long @@ -216,7 +305,8 @@ class DatabaseMigrationTest { private fun getMigratedDatabase(): AppDatabase { val database: AppDatabase = Room.databaseBuilder( ApplicationProvider.getApplicationContext(), - AppDatabase::class.java, AppDatabase.DATABASE_NAME + AppDatabase::class.java, + AppDatabase.DATABASE_NAME ) .build() testHelper.closeWhenFinished(database) diff --git a/app/src/androidTest/java/org/schabi/newpipe/database/FeedDAOTest.kt b/app/src/androidTest/java/org/schabi/newpipe/database/FeedDAOTest.kt new file mode 100644 index 000000000..893ae82b7 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/database/FeedDAOTest.kt @@ -0,0 +1,130 @@ +package org.schabi.newpipe.database + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import io.reactivex.rxjava3.core.Single +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.schabi.newpipe.database.feed.dao.FeedDAO +import org.schabi.newpipe.database.feed.model.FeedEntity +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.database.stream.StreamWithState +import org.schabi.newpipe.database.stream.dao.StreamDAO +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.subscription.SubscriptionDAO +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import org.schabi.newpipe.extractor.ServiceList +import org.schabi.newpipe.extractor.channel.ChannelInfo +import org.schabi.newpipe.extractor.stream.StreamType +import java.io.IOException +import java.time.OffsetDateTime +import kotlin.streams.toList + +class FeedDAOTest { + private lateinit var db: AppDatabase + private lateinit var feedDAO: FeedDAO + private lateinit var streamDAO: StreamDAO + private lateinit var subscriptionDAO: SubscriptionDAO + + private val serviceId = ServiceList.YouTube.serviceId + + private val stream1 = StreamEntity(1, serviceId, "https://youtube.com/watch?v=1", "stream 1", StreamType.VIDEO_STREAM, 1000, "channel-1", "https://youtube.com/channel/1", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-01-01", OffsetDateTime.parse("2023-01-01T00:00:00Z")) + private val stream2 = StreamEntity(2, serviceId, "https://youtube.com/watch?v=2", "stream 2", StreamType.VIDEO_STREAM, 1000, "channel-1", "https://youtube.com/channel/1", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-01-02", OffsetDateTime.parse("2023-01-02T00:00:00Z")) + private val stream3 = StreamEntity(3, serviceId, "https://youtube.com/watch?v=3", "stream 3", StreamType.LIVE_STREAM, 1000, "channel-1", "https://youtube.com/channel/1", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-01-03", OffsetDateTime.parse("2023-01-03T00:00:00Z")) + private val stream4 = StreamEntity(4, serviceId, "https://youtube.com/watch?v=4", "stream 4", StreamType.VIDEO_STREAM, 1000, "channel-2", "https://youtube.com/channel/2", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-08-10", OffsetDateTime.parse("2023-08-10T00:00:00Z")) + private val stream5 = StreamEntity(5, serviceId, "https://youtube.com/watch?v=5", "stream 5", StreamType.VIDEO_STREAM, 1000, "channel-2", "https://youtube.com/channel/2", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-08-20", OffsetDateTime.parse("2023-08-20T00:00:00Z")) + private val stream6 = StreamEntity(6, serviceId, "https://youtube.com/watch?v=6", "stream 6", StreamType.VIDEO_STREAM, 1000, "channel-3", "https://youtube.com/channel/3", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-09-01", OffsetDateTime.parse("2023-09-01T00:00:00Z")) + private val stream7 = StreamEntity(7, serviceId, "https://youtube.com/watch?v=7", "stream 7", StreamType.VIDEO_STREAM, 1000, "channel-4", "https://youtube.com/channel/4", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-08-10", OffsetDateTime.parse("2023-08-10T00:00:00Z")) + + private val allStreams = listOf( + stream1, stream2, stream3, stream4, stream5, stream6, stream7 + ) + + @Before + fun createDb() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder( + context, AppDatabase::class.java + ).build() + feedDAO = db.feedDAO() + streamDAO = db.streamDAO() + subscriptionDAO = db.subscriptionDAO() + } + + @After + @Throws(IOException::class) + fun closeDb() { + db.close() + } + + @Test + fun testUnlinkStreamsOlderThan_KeepOne() { + setupUnlinkDelete("2023-08-15T00:00:00Z") + val streams = feedDAO.getStreams( + FeedGroupEntity.GROUP_ALL_ID, includePlayed = true, includePartiallyPlayed = true, null + ) + .blockingGet() + val allowedStreams = listOf(stream3, stream5, stream6, stream7) + assertEqual(streams, allowedStreams) + } + + @Test + fun testUnlinkStreamsOlderThan_KeepMultiple() { + setupUnlinkDelete("2023-08-01T00:00:00Z") + val streams = feedDAO.getStreams( + FeedGroupEntity.GROUP_ALL_ID, includePlayed = true, includePartiallyPlayed = true, null + ) + .blockingGet() + val allowedStreams = listOf(stream3, stream4, stream5, stream6, stream7) + assertEqual(streams, allowedStreams) + } + + private fun assertEqual(streams: List?, allowedStreams: List) { + assertNotNull(streams) + assertEquals( + allowedStreams, + streams!! + .map { it.stream } + .sortedBy { it.uid } + .toList() + ) + } + + private fun setupUnlinkDelete(time: String) { + clearAndFillTables() + Single.fromCallable { + feedDAO.unlinkStreamsOlderThan(OffsetDateTime.parse(time)) + }.blockingSubscribe() + Single.fromCallable { + streamDAO.deleteOrphans() + }.blockingSubscribe() + } + + private fun clearAndFillTables() { + db.clearAllTables() + streamDAO.insertAll(allStreams) + subscriptionDAO.insertAll( + listOf( + SubscriptionEntity.from(ChannelInfo(serviceId, "1", "https://youtube.com/channel/1", "https://youtube.com/channel/1", "channel-1")), + SubscriptionEntity.from(ChannelInfo(serviceId, "2", "https://youtube.com/channel/2", "https://youtube.com/channel/2", "channel-2")), + SubscriptionEntity.from(ChannelInfo(serviceId, "3", "https://youtube.com/channel/3", "https://youtube.com/channel/3", "channel-3")), + SubscriptionEntity.from(ChannelInfo(serviceId, "4", "https://youtube.com/channel/4", "https://youtube.com/channel/4", "channel-4")), + ) + ) + feedDAO.insertAll( + listOf( + FeedEntity(1, 1), + FeedEntity(2, 1), + FeedEntity(3, 1), + FeedEntity(4, 2), + FeedEntity(5, 2), + FeedEntity(6, 3), + FeedEntity(7, 4), + ) + ) + } +} diff --git a/app/src/androidTest/java/org/schabi/newpipe/local/subscription/SubscriptionManagerTest.java b/app/src/androidTest/java/org/schabi/newpipe/local/subscription/SubscriptionManagerTest.java new file mode 100644 index 000000000..213b679f0 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/local/subscription/SubscriptionManagerTest.java @@ -0,0 +1,82 @@ +package org.schabi.newpipe.local.subscription; + +import static org.junit.Assert.assertEquals; + +import androidx.test.core.app.ApplicationProvider; + +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.schabi.newpipe.database.AppDatabase; +import org.schabi.newpipe.database.feed.model.FeedGroupEntity; +import org.schabi.newpipe.database.subscription.SubscriptionEntity; +import org.schabi.newpipe.extractor.channel.ChannelInfo; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.testUtil.TestDatabase; +import org.schabi.newpipe.testUtil.TrampolineSchedulerRule; + +import java.io.IOException; +import java.util.List; + +public class SubscriptionManagerTest { + private AppDatabase database; + private SubscriptionManager manager; + + @Rule + public TrampolineSchedulerRule trampolineScheduler = new TrampolineSchedulerRule(); + + + private SubscriptionEntity getAssertOneSubscriptionEntity() { + final List entities = manager + .getSubscriptions(FeedGroupEntity.GROUP_ALL_ID, "", false) + .blockingFirst(); + assertEquals(1, entities.size()); + return entities.get(0); + } + + + @Before + public void setup() { + database = TestDatabase.Companion.createReplacingNewPipeDatabase(); + manager = new SubscriptionManager(ApplicationProvider.getApplicationContext()); + } + + @After + public void cleanUp() { + database.close(); + } + + @Test + public void testInsert() throws ExtractionException, IOException { + final ChannelInfo info = ChannelInfo.getInfo("https://www.youtube.com/c/3blue1brown"); + final SubscriptionEntity subscription = SubscriptionEntity.from(info); + + manager.insertSubscription(subscription); + final SubscriptionEntity readSubscription = getAssertOneSubscriptionEntity(); + + // the uid has changed, since the uid is chosen upon inserting, but the rest should match + assertEquals(subscription.getServiceId(), readSubscription.getServiceId()); + assertEquals(subscription.getUrl(), readSubscription.getUrl()); + assertEquals(subscription.getName(), readSubscription.getName()); + assertEquals(subscription.getAvatarUrl(), readSubscription.getAvatarUrl()); + assertEquals(subscription.getSubscriberCount(), readSubscription.getSubscriberCount()); + assertEquals(subscription.getDescription(), readSubscription.getDescription()); + } + + @Test + public void testUpdateNotificationMode() throws ExtractionException, IOException { + final ChannelInfo info = ChannelInfo.getInfo("https://www.youtube.com/c/veritasium"); + final SubscriptionEntity subscription = SubscriptionEntity.from(info); + subscription.setNotificationMode(0); + + manager.insertSubscription(subscription); + manager.updateNotificationMode(subscription.getServiceId(), subscription.getUrl(), 1) + .blockingAwait(); + final SubscriptionEntity anotherSubscription = getAssertOneSubscriptionEntity(); + + assertEquals(0, subscription.getNotificationMode()); + assertEquals(subscription.getUrl(), anotherSubscription.getUrl()); + assertEquals(1, anotherSubscription.getNotificationMode()); + } +} diff --git a/app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt b/app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt index a9aa40d82..9b8ee211e 100644 --- a/app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt +++ b/app/src/androidTest/java/org/schabi/newpipe/util/StreamItemAdapterTest.kt @@ -1,26 +1,32 @@ package org.schabi.newpipe.util import android.content.Context -import android.util.SparseArray import android.view.View import android.view.View.GONE import android.view.View.INVISIBLE import android.view.View.VISIBLE import android.widget.Spinner +import androidx.collection.SparseArrayCompat import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import androidx.test.internal.runner.junit4.statement.UiThreadStatement import org.junit.Assert +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.schabi.newpipe.R import org.schabi.newpipe.extractor.MediaFormat +import org.schabi.newpipe.extractor.downloader.Response import org.schabi.newpipe.extractor.stream.AudioStream import org.schabi.newpipe.extractor.stream.Stream import org.schabi.newpipe.extractor.stream.SubtitlesStream import org.schabi.newpipe.extractor.stream.VideoStream +import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper @MediumTest @RunWith(AndroidJUnit4::class) @@ -39,9 +45,7 @@ class StreamItemAdapterTest { @Test fun videoStreams_noSecondaryStream() { val adapter = StreamItemAdapter( - context, - getVideoStreams(true, true, true, true), - null + getVideoStreams(true, true, true, true) ) spinner.adapter = adapter @@ -54,7 +58,6 @@ class StreamItemAdapterTest { @Test fun videoStreams_hasSecondaryStream() { val adapter = StreamItemAdapter( - context, getVideoStreams(false, true, false, true), getAudioStreams(false, true, false, true) ) @@ -69,7 +72,6 @@ class StreamItemAdapterTest { @Test fun videoStreams_Mixed() { val adapter = StreamItemAdapter( - context, getVideoStreams(true, true, true, true, true, false, true, true), getAudioStreams(false, true, false, false, false, true, true, true) ) @@ -88,14 +90,17 @@ class StreamItemAdapterTest { @Test fun subtitleStreams_noIcon() { val adapter = StreamItemAdapter( - context, - StreamItemAdapter.StreamSizeWrapper( + StreamItemAdapter.StreamInfoWrapper( (0 until 5).map { - SubtitlesStream(MediaFormat.SRT, "pt-BR", "https://example.com", false) + SubtitlesStream.Builder() + .setContent("https://example.com", true) + .setMediaFormat(MediaFormat.SRT) + .setLanguageCode("pt-BR") + .setAutoGenerated(false) + .build() }, context - ), - null + ) ) spinner.adapter = adapter for (i in 0 until spinner.count) { @@ -106,12 +111,17 @@ class StreamItemAdapterTest { @Test fun audioStreams_noIcon() { val adapter = StreamItemAdapter( - context, - StreamItemAdapter.StreamSizeWrapper( - (0 until 5).map { AudioStream("https://example.com/$it", MediaFormat.OPUS, 192) }, + StreamItemAdapter.StreamInfoWrapper( + (0 until 5).map { + AudioStream.Builder() + .setId(Stream.ID_UNKNOWN) + .setContent("https://example.com/$it", true) + .setMediaFormat(MediaFormat.OPUS) + .setAverageBitrate(192) + .build() + }, context - ), - null + ) ) spinner.adapter = adapter for (i in 0 until spinner.count) { @@ -119,14 +129,117 @@ class StreamItemAdapterTest { } } + @Test + fun retrieveMediaFormatFromFileTypeHeaders() { + val streams = getIncompleteAudioStreams(5) + val wrapper = StreamInfoWrapper(streams, context) + val retrieveMediaFormat = { stream: AudioStream, response: Response -> + StreamInfoWrapper.retrieveMediaFormatFromFileTypeHeaders(stream, wrapper, response) + } + val helper = AssertionHelper(streams, wrapper, retrieveMediaFormat) + + helper.assertInvalidResponse(getResponse(mapOf(Pair("content-length", "mp3"))), 0) + helper.assertInvalidResponse(getResponse(mapOf(Pair("file-type", "mp0"))), 1) + + helper.assertValidResponse(getResponse(mapOf(Pair("x-amz-meta-file-type", "aiff"))), 2, MediaFormat.AIFF) + helper.assertValidResponse(getResponse(mapOf(Pair("file-type", "mp3"))), 3, MediaFormat.MP3) + } + + @Test + fun retrieveMediaFormatFromContentDispositionHeader() { + val streams = getIncompleteAudioStreams(11) + val wrapper = StreamInfoWrapper(streams, context) + val retrieveMediaFormat = { stream: AudioStream, response: Response -> + StreamInfoWrapper.retrieveMediaFormatFromContentDispositionHeader(stream, wrapper, response) + } + val helper = AssertionHelper(streams, wrapper, retrieveMediaFormat) + + helper.assertInvalidResponse(getResponse(mapOf(Pair("content-length", "mp3"))), 0) + helper.assertInvalidResponse( + getResponse(mapOf(Pair("Content-Disposition", "filename=\"train.png\""))), 1 + ) + helper.assertInvalidResponse( + getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"data.csv\""))), 2 + ) + helper.assertInvalidResponse( + getResponse(mapOf(Pair("Content-Disposition", "form-data; filename=\"data.csv\""))), 3 + ) + helper.assertInvalidResponse( + getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"fieldName\"; filename*=\"filename.jpg\""))), 4 + ) + + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Disposition", "filename=\"train.ogg\""))), + 5, MediaFormat.OGG + ) + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Disposition", "some-form-data; filename=\"audio.flac\""))), + 6, MediaFormat.FLAC + ) + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.aiff\"; filename=\"audio.aiff\""))), + 7, MediaFormat.AIFF + ) + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"alien?\"; filename*=UTF-8''%CE%B1%CE%BB%CE%B9%CF%B5%CE%BD.m4a"))), + 8, MediaFormat.M4A + ) + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.mp3\"; filename=\"audio.opus\"; filename*=UTF-8''alien.opus"))), + 9, MediaFormat.OPUS + ) + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.mp3\"; filename=\"audio.opus\"; filename*=\"UTF-8''alien.opus\""))), + 10, MediaFormat.OPUS + ) + } + + @Test + fun retrieveMediaFormatFromContentTypeHeader() { + val streams = getIncompleteAudioStreams(12) + val wrapper = StreamInfoWrapper(streams, context) + val retrieveMediaFormat = { stream: AudioStream, response: Response -> + StreamInfoWrapper.retrieveMediaFormatFromContentTypeHeader(stream, wrapper, response) + } + val helper = AssertionHelper(streams, wrapper, retrieveMediaFormat) + + helper.assertInvalidResponse(getResponse(mapOf(Pair("content-length", "984501"))), 0) + helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "audio/xyz"))), 1) + helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "mp3"))), 2) + helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "mp3"))), 3) + helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "audio/mpeg"))), 4) + helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "audio/aif"))), 5) + helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "whatever"))), 6) + helper.assertInvalidResponse(getResponse(mapOf()), 7) + + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Type", "audio/flac"))), 8, MediaFormat.FLAC + ) + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Type", "audio/wav"))), 9, MediaFormat.WAV + ) + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Type", "audio/opus"))), 10, MediaFormat.OPUS + ) + helper.assertValidResponse( + getResponse(mapOf(Pair("Content-Type", "audio/aiff"))), 11, MediaFormat.AIFF + ) + } + /** * @return a list of video streams, in which their video only property mirrors the provided * [videoOnly] vararg. */ private fun getVideoStreams(vararg videoOnly: Boolean) = - StreamItemAdapter.StreamSizeWrapper( + StreamItemAdapter.StreamInfoWrapper( videoOnly.map { - VideoStream("https://example.com", MediaFormat.MPEG_4, "720p", it) + VideoStream.Builder() + .setId(Stream.ID_UNKNOWN) + .setContent("https://example.com", true) + .setMediaFormat(MediaFormat.MPEG_4) + .setResolution("720p") + .setIsVideoOnly(it) + .build() }, context ) @@ -138,11 +251,32 @@ class StreamItemAdapterTest { private fun getAudioStreams(vararg shouldBeValid: Boolean) = getSecondaryStreamsFromList( shouldBeValid.map { - if (it) AudioStream("https://example.com", MediaFormat.OPUS, 192) - else null + if (it) { + AudioStream.Builder() + .setId(Stream.ID_UNKNOWN) + .setContent("https://example.com", true) + .setMediaFormat(MediaFormat.OPUS) + .setAverageBitrate(192) + .build() + } else { + null + } } ) + private fun getIncompleteAudioStreams(size: Int): List { + val list = ArrayList(size) + for (i in 1..size) { + list.add( + AudioStream.Builder() + .setId(Stream.ID_UNKNOWN) + .setContent("https://example.com/$i", true) + .build() + ) + } + return list + } + /** * Checks whether the item at [position] in the [spinner] has the correct icon visibility when * it is shown in normal mode (selected) and in dropdown mode (user is choosing one of a list). @@ -174,15 +308,60 @@ class StreamItemAdapterTest { * Helper function that builds a secondary stream list. */ private fun getSecondaryStreamsFromList(streams: List) = - SparseArray?>(streams.size).apply { + SparseArrayCompat?>(streams.size).apply { streams.forEachIndexed { index, stream -> val secondaryStreamHelper: SecondaryStreamHelper? = stream?.let { SecondaryStreamHelper( - StreamItemAdapter.StreamSizeWrapper(streams, context), + StreamItemAdapter.StreamInfoWrapper(streams, context), it ) } put(index, secondaryStreamHelper) } } + + private fun getResponse(headers: Map): Response { + val listHeaders = HashMap>() + headers.forEach { entry -> + listHeaders[entry.key] = listOf(entry.value) + } + return Response(200, null, listHeaders, "", "") + } + + /** + * Helper class for assertion related to extractions of [MediaFormat]s. + */ + class AssertionHelper( + private val streams: List, + private val wrapper: StreamInfoWrapper, + private val retrieveMediaFormat: (stream: T, response: Response) -> Boolean + ) { + + /** + * Assert that an invalid response does not result in wrongly extracted [MediaFormat]. + */ + fun assertInvalidResponse( + response: Response, + index: Int + ) { + assertFalse( + "invalid header returns valid value", retrieveMediaFormat(streams[index], response) + ) + assertNull("Media format extracted although stated otherwise", wrapper.getFormat(index)) + } + + /** + * Assert that a valid response results in correctly extracted and handled [MediaFormat]. + */ + fun assertValidResponse( + response: Response, + index: Int, + format: MediaFormat + ) { + assertTrue( + "header was not recognized", retrieveMediaFormat(streams[index], response) + ) + assertEquals("Wrong media format extracted", format, wrapper.getFormat(index)) + } + } } diff --git a/app/src/debug/AndroidManifest.xml b/app/src/debug/AndroidManifest.xml index 5cc2fa66a..02a0f6c74 100644 --- a/app/src/debug/AndroidManifest.xml +++ b/app/src/debug/AndroidManifest.xml @@ -1,8 +1,6 @@ - + @@ -10,10 +9,22 @@ + + + + + + + + + + @@ -37,15 +49,17 @@ - + @@ -54,15 +68,18 @@ @@ -71,6 +88,7 @@ @@ -83,13 +101,18 @@ - + + @@ -97,6 +120,7 @@ @@ -107,6 +131,7 @@ @@ -147,10 +173,12 @@ + + @@ -329,16 +357,16 @@ + - - - - - + + - + + + @@ -351,30 +379,30 @@ - - - + + + - - + + - - - + + + - - - + + + - - + + - - - + + + @@ -383,11 +411,17 @@ android:exported="false" /> - + - + - + diff --git a/app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.java b/app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.java index 639443377..8d03a1486 100644 --- a/app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.java +++ b/app/src/main/java/androidx/fragment/app/FragmentStatePagerAdapterMenuWorkaround.java @@ -25,6 +25,7 @@ import android.view.ViewGroup; import androidx.annotation.IntDef; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.os.BundleCompat; import androidx.lifecycle.Lifecycle; import androidx.viewpager.widget.PagerAdapter; @@ -282,11 +283,9 @@ public abstract class FragmentStatePagerAdapterMenuWorkaround extends PagerAdapt @Nullable public Parcelable saveState() { Bundle state = null; - if (mSavedState.size() > 0) { + if (!mSavedState.isEmpty()) { state = new Bundle(); - final Fragment.SavedState[] fss = new Fragment.SavedState[mSavedState.size()]; - mSavedState.toArray(fss); - state.putParcelableArray("states", fss); + state.putParcelableArrayList("states", mSavedState); } for (int i = 0; i < mFragments.size(); i++) { final Fragment f = mFragments.get(i); @@ -313,13 +312,12 @@ public abstract class FragmentStatePagerAdapterMenuWorkaround extends PagerAdapt if (state != null) { final Bundle bundle = (Bundle) state; bundle.setClassLoader(loader); - final Parcelable[] fss = bundle.getParcelableArray("states"); + final var states = BundleCompat.getParcelableArrayList(bundle, "states", + Fragment.SavedState.class); mSavedState.clear(); mFragments.clear(); - if (fss != null) { - for (final Parcelable parcelable : fss) { - mSavedState.add((Fragment.SavedState) parcelable); - } + if (states != null) { + mSavedState.addAll(states); } final Iterable keys = bundle.keySet(); for (final String key : keys) { diff --git a/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java b/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java index 3e5f408f7..52754e8fa 100644 --- a/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java +++ b/app/src/main/java/com/google/android/material/appbar/FlingBehavior.java @@ -14,7 +14,6 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout; import org.schabi.newpipe.R; import java.lang.reflect.Field; -import java.util.Arrays; import java.util.List; // See https://stackoverflow.com/questions/56849221#57997489 @@ -27,7 +26,7 @@ public final class FlingBehavior extends AppBarLayout.Behavior { private boolean allowScroll = true; private final Rect globalRect = new Rect(); - private final List skipInterceptionOfElements = Arrays.asList( + private final List skipInterceptionOfElements = List.of( R.id.itemsListPanel, R.id.playbackSeekBar, R.id.playPauseButton, R.id.playPreviousButton, R.id.playNextButton); @@ -67,7 +66,7 @@ public final class FlingBehavior extends AppBarLayout.Behavior { public boolean onInterceptTouchEvent(@NonNull final CoordinatorLayout parent, @NonNull final AppBarLayout child, @NonNull final MotionEvent ev) { - for (final Integer element : skipInterceptionOfElements) { + for (final int element : skipInterceptionOfElements) { final View view = child.findViewById(element); if (view != null) { final boolean visible = view.getGlobalVisibleRect(globalRect); @@ -132,8 +131,8 @@ public final class FlingBehavior extends AppBarLayout.Behavior { try { final Class headerBehaviorType = this.getClass().getSuperclass().getSuperclass(); if (headerBehaviorType != null) { - final Field field - = headerBehaviorType.getDeclaredField("lastNestedScrollingChildRef"); + final Field field = + headerBehaviorType.getDeclaredField("lastNestedScrollingChildRef"); field.setAccessible(true); return field; } diff --git a/app/src/main/java/org/schabi/newpipe/App.java b/app/src/main/java/org/schabi/newpipe/App.java index 6b02e21ca..d92425d20 100644 --- a/app/src/main/java/org/schabi/newpipe/App.java +++ b/app/src/main/java/org/schabi/newpipe/App.java @@ -1,5 +1,6 @@ package org.schabi.newpipe; +import android.app.Application; import android.content.Context; import android.content.SharedPreferences; import android.util.Log; @@ -7,7 +8,6 @@ import android.util.Log; import androidx.annotation.NonNull; import androidx.core.app.NotificationChannelCompat; import androidx.core.app.NotificationManagerCompat; -import androidx.multidex.MultiDexApplication; import androidx.preference.PreferenceManager; import com.jakewharton.processphoenix.ProcessPhoenix; @@ -20,16 +20,17 @@ import org.schabi.newpipe.extractor.downloader.Downloader; import org.schabi.newpipe.ktx.ExceptionUtils; import org.schabi.newpipe.settings.NewPipeSettings; import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.image.ImageStrategy; +import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.StateSaver; +import org.schabi.newpipe.util.image.PreferredImageQuality; import java.io.IOException; import java.io.InterruptedIOException; import java.net.SocketException; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; +import java.util.Objects; import io.reactivex.rxjava3.exceptions.CompositeException; import io.reactivex.rxjava3.exceptions.MissingBackpressureException; @@ -56,9 +57,11 @@ import io.reactivex.rxjava3.plugins.RxJavaPlugins; * along with NewPipe. If not, see . */ -public class App extends MultiDexApplication { +public class App extends Application { public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID; private static final String TAG = App.class.toString(); + + private boolean isFirstRun = false; private static App app; @NonNull @@ -84,7 +87,13 @@ public class App extends MultiDexApplication { return; } - // Initialize settings first because others inits can use its values + // check if the last used preference version is set + // to determine whether this is the first app run + final int lastUsedPrefVersion = PreferenceManager.getDefaultSharedPreferences(this) + .getInt(getString(R.string.last_used_preferences_version), -1); + isFirstRun = lastUsedPrefVersion == -1; + + // Initialize settings first because other initializations can use its values NewPipeSettings.initSettings(this); NewPipe.init(getDownloader(), @@ -100,8 +109,9 @@ public class App extends MultiDexApplication { // Initialize image loader final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); PicassoHelper.init(this); - PicassoHelper.setShouldLoadImages( - prefs.getBoolean(getString(R.string.download_thumbnail_key), true)); + ImageStrategy.setPreferredImageQuality(PreferredImageQuality.fromPreferenceKey(this, + prefs.getString(getString(R.string.image_quality_key), + getString(R.string.image_quality_default)))); PicassoHelper.setIndicatorsEnabled(MainActivity.DEBUG && prefs.getBoolean(getString(R.string.show_image_indicators_key), false)); @@ -140,7 +150,7 @@ public class App extends MultiDexApplication { if (throwable instanceof UndeliverableException) { // As UndeliverableException is a wrapper, // get the cause of it to get the "real" exception - actualThrowable = throwable.getCause(); + actualThrowable = Objects.requireNonNull(throwable.getCause()); } else { actualThrowable = throwable; } @@ -149,7 +159,7 @@ public class App extends MultiDexApplication { if (actualThrowable instanceof CompositeException) { errors = ((CompositeException) actualThrowable).getExceptions(); } else { - errors = Collections.singletonList(actualThrowable); + errors = List.of(actualThrowable); } for (final Throwable error : errors) { @@ -205,7 +215,7 @@ public class App extends MultiDexApplication { return; } - final CoreConfigurationBuilder acraConfig = new CoreConfigurationBuilder(this) + final CoreConfigurationBuilder acraConfig = new CoreConfigurationBuilder() .withBuildConfigClass(BuildConfig.class); ACRA.init(this, acraConfig); } @@ -213,41 +223,37 @@ public class App extends MultiDexApplication { private void initNotificationChannels() { // Keep the importance below DEFAULT to avoid making noise on every notification update for // the main and update channels - final List notificationChannelCompats = new ArrayList<>(); - notificationChannelCompats.add(new NotificationChannelCompat - .Builder(getString(R.string.notification_channel_id), + final List notificationChannelCompats = List.of( + new NotificationChannelCompat.Builder(getString(R.string.notification_channel_id), NotificationManagerCompat.IMPORTANCE_LOW) - .setName(getString(R.string.notification_channel_name)) - .setDescription(getString(R.string.notification_channel_description)) - .build()); - - notificationChannelCompats.add(new NotificationChannelCompat - .Builder(getString(R.string.app_update_notification_channel_id), + .setName(getString(R.string.notification_channel_name)) + .setDescription(getString(R.string.notification_channel_description)) + .build(), + new NotificationChannelCompat + .Builder(getString(R.string.app_update_notification_channel_id), NotificationManagerCompat.IMPORTANCE_LOW) - .setName(getString(R.string.app_update_notification_channel_name)) - .setDescription(getString(R.string.app_update_notification_channel_description)) - .build()); - - notificationChannelCompats.add(new NotificationChannelCompat - .Builder(getString(R.string.hash_channel_id), + .setName(getString(R.string.app_update_notification_channel_name)) + .setDescription( + getString(R.string.app_update_notification_channel_description)) + .build(), + new NotificationChannelCompat.Builder(getString(R.string.hash_channel_id), NotificationManagerCompat.IMPORTANCE_HIGH) - .setName(getString(R.string.hash_channel_name)) - .setDescription(getString(R.string.hash_channel_description)) - .build()); - - notificationChannelCompats.add(new NotificationChannelCompat - .Builder(getString(R.string.error_report_channel_id), + .setName(getString(R.string.hash_channel_name)) + .setDescription(getString(R.string.hash_channel_description)) + .build(), + 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()); - - notificationChannelCompats.add(new NotificationChannelCompat - .Builder(getString(R.string.streams_notification_channel_id), - NotificationManagerCompat.IMPORTANCE_DEFAULT) - .setName(getString(R.string.streams_notification_channel_name)) - .setDescription(getString(R.string.streams_notification_channel_description)) - .build()); + .setName(getString(R.string.error_report_channel_name)) + .setDescription(getString(R.string.error_report_channel_description)) + .build(), + new NotificationChannelCompat + .Builder(getString(R.string.streams_notification_channel_id), + NotificationManagerCompat.IMPORTANCE_DEFAULT) + .setName(getString(R.string.streams_notification_channel_name)) + .setDescription( + getString(R.string.streams_notification_channel_description)) + .build() + ); final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this); notificationManager.createNotificationChannelsCompat(notificationChannelCompats); @@ -257,4 +263,7 @@ public class App extends MultiDexApplication { return false; } + public boolean isFirstRun() { + return isFirstRun; + } } diff --git a/app/src/main/java/org/schabi/newpipe/BaseFragment.java b/app/src/main/java/org/schabi/newpipe/BaseFragment.java index 16ddb8376..7a06771dd 100644 --- a/app/src/main/java/org/schabi/newpipe/BaseFragment.java +++ b/app/src/main/java/org/schabi/newpipe/BaseFragment.java @@ -12,7 +12,6 @@ import androidx.fragment.app.FragmentManager; import icepick.Icepick; import icepick.State; -import leakcanary.AppWatcher; public abstract class BaseFragment extends Fragment { protected final String TAG = getClass().getSimpleName() + "@" + Integer.toHexString(hashCode()); @@ -77,20 +76,33 @@ public abstract class BaseFragment extends Fragment { protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) { } - @Override - public void onDestroy() { - super.onDestroy(); - - AppWatcher.INSTANCE.getObjectWatcher().watch(this); - } - /*////////////////////////////////////////////////////////////////////////// // Init //////////////////////////////////////////////////////////////////////////*/ + /** + * This method is called in {@link #onViewCreated(View, Bundle)} to initialize the views. + * + *

+ * {@link #initListeners()} is called after this method to initialize the corresponding + * listeners. + *

+ * @param rootView The inflated view for this fragment + * (provided by {@link #onViewCreated(View, Bundle)}) + * @param savedInstanceState The saved state of this fragment + * (provided by {@link #onViewCreated(View, Bundle)}) + */ protected void initViews(final View rootView, final Bundle savedInstanceState) { } + /** + * Initialize the listeners for this fragment. + * + *

+ * This method is called after {@link #initViews(View, Bundle)} + * in {@link #onViewCreated(View, Bundle)}. + *

+ */ protected void initListeners() { } @@ -108,9 +120,20 @@ public abstract class BaseFragment extends Fragment { } } + /** + * Finds the root fragment by looping through all of the parent fragments. The root fragment + * is supposed to be {@link org.schabi.newpipe.fragments.MainFragment}, and is the fragment that + * handles keeping the backstack of opened fragments in NewPipe, and also the player bottom + * sheet. This function therefore returns the fragment manager of said fragment. + * + * @return the fragment manager of the root fragment, i.e. + * {@link org.schabi.newpipe.fragments.MainFragment} + */ protected FragmentManager getFM() { - return getParentFragment() == null - ? getFragmentManager() - : getParentFragment().getFragmentManager(); + Fragment current = this; + while (current.getParentFragment() != null) { + current = current.getParentFragment(); + } + return current.getFragmentManager(); } } diff --git a/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java b/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java index fde991655..9ddbe96df 100644 --- a/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java +++ b/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java @@ -1,7 +1,6 @@ package org.schabi.newpipe; import android.content.Context; -import android.os.Build; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -12,40 +11,27 @@ import org.schabi.newpipe.extractor.downloader.Downloader; import org.schabi.newpipe.extractor.downloader.Request; import org.schabi.newpipe.extractor.downloader.Response; import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; -import org.schabi.newpipe.util.CookieUtils; import org.schabi.newpipe.util.InfoCache; -import org.schabi.newpipe.util.TLSSocketFactoryCompat; import java.io.IOException; -import java.security.KeyManagementException; -import java.security.KeyStore; -import java.security.KeyStoreException; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; +import java.util.stream.Stream; -import javax.net.ssl.SSLSocketFactory; -import javax.net.ssl.TrustManager; -import javax.net.ssl.TrustManagerFactory; -import javax.net.ssl.X509TrustManager; - -import okhttp3.CipherSuite; -import okhttp3.ConnectionSpec; import okhttp3.OkHttpClient; import okhttp3.RequestBody; import okhttp3.ResponseBody; -import static org.schabi.newpipe.MainActivity.DEBUG; - public final class DownloaderImpl extends Downloader { - public static final String USER_AGENT - = "Mozilla/5.0 (Windows NT 10.0; rv:78.0) Gecko/20100101 Firefox/78.0"; - public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY - = "youtube_restricted_mode_key"; + public static final String USER_AGENT = + "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0"; + public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY = + "youtube_restricted_mode_key"; public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000"; public static final String YOUTUBE_DOMAIN = "youtube.com"; @@ -54,9 +40,6 @@ public final class DownloaderImpl extends Downloader { private final OkHttpClient client; private DownloaderImpl(final OkHttpClient.Builder builder) { - if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) { - enableModernTLS(builder); - } this.client = builder .readTimeout(30, TimeUnit.SECONDS) // .cache(new Cache(new File(context.getExternalCacheDir(), "okhttp"), @@ -81,69 +64,16 @@ public final class DownloaderImpl extends Downloader { return instance; } - /** - * Enable TLS 1.2 and 1.1 on Android Kitkat. This function is mostly taken - * from the documentation of OkHttpClient.Builder.sslSocketFactory(_,_). - *

- * If there is an error, the function will safely fall back to doing nothing - * and printing the error to the console. - *

- * - * @param builder The HTTPClient Builder on which TLS is enabled on (will be modified in-place) - */ - private static void enableModernTLS(final OkHttpClient.Builder builder) { - try { - // get the default TrustManager - final TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance( - TrustManagerFactory.getDefaultAlgorithm()); - trustManagerFactory.init((KeyStore) null); - final TrustManager[] trustManagers = trustManagerFactory.getTrustManagers(); - if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) { - throw new IllegalStateException("Unexpected default trust managers:" - + Arrays.toString(trustManagers)); - } - final X509TrustManager trustManager = (X509TrustManager) trustManagers[0]; - - // insert our own TLSSocketFactory - final SSLSocketFactory sslSocketFactory = TLSSocketFactoryCompat.getInstance(); - - builder.sslSocketFactory(sslSocketFactory, trustManager); - - // This will try to enable all modern CipherSuites(+2 more) - // that are supported on the device. - // Necessary because some servers (e.g. Framatube.org) - // don't support the old cipher suites. - // https://github.com/square/okhttp/issues/4053#issuecomment-402579554 - final List cipherSuites = - new ArrayList<>(ConnectionSpec.MODERN_TLS.cipherSuites()); - cipherSuites.add(CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA); - cipherSuites.add(CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA); - final ConnectionSpec legacyTLS = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS) - .cipherSuites(cipherSuites.toArray(new CipherSuite[0])) - .build(); - - builder.connectionSpecs(Arrays.asList(legacyTLS, ConnectionSpec.CLEARTEXT)); - } catch (final KeyManagementException | NoSuchAlgorithmException | KeyStoreException e) { - if (DEBUG) { - e.printStackTrace(); - } - } - } - public String getCookies(final String url) { - final List resultCookies = new ArrayList<>(); - if (url.contains(YOUTUBE_DOMAIN)) { - final String youtubeCookie = getCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY); - if (youtubeCookie != null) { - resultCookies.add(youtubeCookie); - } - } + final String youtubeCookie = url.contains(YOUTUBE_DOMAIN) + ? getCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY) : null; + // Recaptcha cookie is always added TODO: not sure if this is necessary - final String recaptchaCookie = getCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY); - if (recaptchaCookie != null) { - resultCookies.add(recaptchaCookie); - } - return CookieUtils.concatCookies(resultCookies); + return Stream.of(youtubeCookie, getCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY)) + .filter(Objects::nonNull) + .flatMap(cookies -> Arrays.stream(cookies.split("; *"))) + .distinct() + .collect(Collectors.joining("; ")); } public String getCookie(final String key) { @@ -203,7 +133,7 @@ public final class DownloaderImpl extends Downloader { RequestBody requestBody = null; if (dataToSend != null) { - requestBody = RequestBody.create(null, dataToSend); + requestBody = RequestBody.create(dataToSend); } final okhttp3.Request.Builder requestBuilder = new okhttp3.Request.Builder() diff --git a/app/src/main/java/org/schabi/newpipe/ExitActivity.java b/app/src/main/java/org/schabi/newpipe/ExitActivity.java index 8da22db2d..bd1351f0c 100644 --- a/app/src/main/java/org/schabi/newpipe/ExitActivity.java +++ b/app/src/main/java/org/schabi/newpipe/ExitActivity.java @@ -3,7 +3,6 @@ package org.schabi.newpipe; import android.annotation.SuppressLint; import android.app.Activity; import android.content.Intent; -import android.os.Build; import android.os.Bundle; import org.schabi.newpipe.util.NavigationHelper; @@ -44,11 +43,7 @@ public class ExitActivity extends Activity { protected void onCreate(final Bundle savedInstanceState) { super.onCreate(savedInstanceState); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - finishAndRemoveTask(); - } else { - finish(); - } + finishAndRemoveTask(); NavigationHelper.restartApp(this); } diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java index 6ae5cf936..175694125 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -28,7 +28,6 @@ import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.content.pm.PackageManager; -import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.os.Looper; @@ -45,6 +44,7 @@ import android.widget.FrameLayout; import android.widget.Spinner; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBarDrawerToggle; import androidx.appcompat.app.AppCompatActivity; @@ -52,6 +52,7 @@ import androidx.core.app.ActivityCompat; import androidx.core.view.GravityCompat; import androidx.drawerlayout.widget.DrawerLayout; import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentContainerView; import androidx.fragment.app.FragmentManager; import androidx.preference.PreferenceManager; @@ -65,17 +66,20 @@ import org.schabi.newpipe.databinding.ToolbarLayoutBinding; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.MainFragment; import org.schabi.newpipe.fragments.detail.VideoDetailFragment; +import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment; import org.schabi.newpipe.fragments.list.search.SearchFragment; import org.schabi.newpipe.local.feed.notifications.NotificationWorker; import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.event.OnKeyDownListener; import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.playqueue.PlayQueue; +import org.schabi.newpipe.settings.UpdateSettingsFragment; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.KioskTranslator; @@ -83,10 +87,10 @@ import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PeertubeHelper; import org.schabi.newpipe.util.PermissionHelper; +import org.schabi.newpipe.util.ReleaseVersionUtil; import org.schabi.newpipe.util.SerializedCache; import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.StateSaver; -import org.schabi.newpipe.util.TLSSocketFactoryCompat; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.views.FocusOverlayView; @@ -131,11 +135,6 @@ public class MainActivity extends AppCompatActivity { + "savedInstanceState = [" + savedInstanceState + "]"); } - // enable TLS1.1/1.2 for kitkat devices, to fix download and play for media.ccc.de sources - if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) { - TLSSocketFactoryCompat.setAsDefault(); - } - ThemeHelper.setDayNightMode(this); ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this)); @@ -164,9 +163,17 @@ public class MainActivity extends AppCompatActivity { } openMiniPlayerUponPlayerStarted(); - // Schedule worker for checking for new streams and creating corresponding notifications - // if this is enabled by the user. - NotificationWorker.initialize(this); + if (PermissionHelper.checkPostNotificationsPermission(this, + PermissionHelper.POST_NOTIFICATIONS_REQUEST_CODE)) { + // Schedule worker for checking for new streams and creating corresponding notifications + // if this is enabled by the user. + NotificationWorker.initialize(this); + } + if (!UpdateSettingsFragment.wasUserAskedForConsent(this) + && !App.getApp().isFirstRun() + && ReleaseVersionUtil.INSTANCE.isReleaseApk()) { + UpdateSettingsFragment.askForConsentToUpdateChecks(this); + } } @Override @@ -176,10 +183,11 @@ public class MainActivity extends AppCompatActivity { final App app = App.getApp(); final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app); - if (prefs.getBoolean(app.getString(R.string.update_app_key), true)) { + if (prefs.getBoolean(app.getString(R.string.update_app_key), false) + && prefs.getBoolean(app.getString(R.string.update_check_consent_key), false)) { // Start the worker which is checking all conditions // and eventually searching for a new version. - NewVersionWorker.enqueueNewVersionCheckingWork(app); + NewVersionWorker.enqueueNewVersionCheckingWork(app, false); } } @@ -223,14 +231,14 @@ public class MainActivity extends AppCompatActivity { final int currentServiceId = ServiceHelper.getSelectedServiceId(this); final StreamingService service = NewPipe.getService(currentServiceId); - int kioskId = 0; + int kioskMenuItemId = 0; for (final String ks : service.getKioskList().getAvailableKiosks()) { drawerLayoutBinding.navigation.getMenu() - .add(R.id.menu_tabs_group, kioskId, 0, KioskTranslator + .add(R.id.menu_tabs_group, kioskMenuItemId, 0, KioskTranslator .getTranslatedKioskName(ks, this)) .setIcon(KioskTranslator.getKioskIcon(ks)); - kioskId++; + kioskMenuItemId++; } drawerLayoutBinding.navigation.getMenu() @@ -239,7 +247,7 @@ public class MainActivity extends AppCompatActivity { .setIcon(R.drawable.ic_tv); drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title) - .setIcon(R.drawable.ic_rss_feed); + .setIcon(R.drawable.ic_subscriptions); drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks) .setIcon(R.drawable.ic_bookmark); @@ -310,20 +318,16 @@ public class MainActivity extends AppCompatActivity { NavigationHelper.openStatisticFragment(getSupportFragmentManager()); break; default: - final int currentServiceId = ServiceHelper.getSelectedServiceId(this); - final StreamingService service = NewPipe.getService(currentServiceId); - String serviceName = ""; - - int kioskId = 0; - for (final String ks : service.getKioskList().getAvailableKiosks()) { - if (kioskId == item.getItemId()) { - serviceName = ks; + final StreamingService currentService = ServiceHelper.getSelectedService(this); + int kioskMenuItemId = 0; + for (final String kioskId : currentService.getKioskList().getAvailableKiosks()) { + if (kioskMenuItemId == item.getItemId()) { + NavigationHelper.openKioskFragment(getSupportFragmentManager(), + currentService.getServiceId(), kioskId); + break; } - kioskId++; + kioskMenuItemId++; } - - NavigationHelper.openKioskFragment(getSupportFragmentManager(), currentServiceId, - serviceName); break; } } @@ -381,8 +385,7 @@ public class MainActivity extends AppCompatActivity { private void showServices() { for (final StreamingService s : NewPipe.getServices()) { - final String title = s.getServiceInfo().getName() - + (ServiceHelper.isBeta(s) ? " (beta)" : ""); + final String title = s.getServiceInfo().getName(); final MenuItem menuItem = drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_services_group, s.getServiceId(), ORDER, title) @@ -390,7 +393,7 @@ public class MainActivity extends AppCompatActivity { // peertube specifics if (s.getServiceId() == 3) { - enhancePeertubeMenu(s, menuItem); + enhancePeertubeMenu(menuItem); } } drawerLayoutBinding.navigation.getMenu() @@ -398,9 +401,9 @@ public class MainActivity extends AppCompatActivity { .setChecked(true); } - private void enhancePeertubeMenu(final StreamingService s, final MenuItem menuItem) { + private void enhancePeertubeMenu(final MenuItem menuItem) { final PeertubeInstance currentInstance = PeertubeHelper.getCurrentInstance(); - menuItem.setTitle(currentInstance.getName() + (ServiceHelper.isBeta(s) ? " (beta)" : "")); + menuItem.setTitle(currentInstance.getName()); final Spinner spinner = InstanceSpinnerLayoutBinding.inflate(LayoutInflater.from(this)) .getRoot(); final List instances = PeertubeHelper.getInstanceList(this); @@ -480,8 +483,8 @@ public class MainActivity extends AppCompatActivity { ErrorUtil.showUiErrorSnackbar(this, "Setting up service toggle", e); } - final SharedPreferences sharedPreferences - = PreferenceManager.getDefaultSharedPreferences(this); + final SharedPreferences sharedPreferences = + PreferenceManager.getDefaultSharedPreferences(this); if (sharedPreferences.getBoolean(Constants.KEY_THEME_CHANGE, false)) { if (DEBUG) { Log.d(TAG, "Theme has changed, recreating activity..."); @@ -555,14 +558,21 @@ public class MainActivity extends AppCompatActivity { // interacts with a fragment inside fragment_holder so all back presses should be // handled by it if (bottomSheetHiddenOrCollapsed()) { - final Fragment fragment = getSupportFragmentManager() - .findFragmentById(R.id.fragment_holder); + final FragmentManager fm = getSupportFragmentManager(); + final Fragment fragment = fm.findFragmentById(R.id.fragment_holder); // If current fragment implements BackPressable (i.e. can/wanna handle back press) // delegate the back press to it if (fragment instanceof BackPressable) { if (((BackPressable) fragment).onBackPressed()) { return; } + } else if (fragment instanceof CommentRepliesFragment) { + // expand DetailsFragment if CommentRepliesFragment was opened + // to show the top level comments again + // Expand DetailsFragment if CommentRepliesFragment was opened + // and no other CommentRepliesFragments are on top of the back stack + // to show the top level comments again. + openDetailFragmentFromCommentReplies(fm, false); } } else { @@ -607,6 +617,9 @@ public class MainActivity extends AppCompatActivity { ((VideoDetailFragment) fragment).openDownloadDialog(); } break; + case PermissionHelper.POST_NOTIFICATIONS_REQUEST_CODE: + NotificationWorker.initialize(this); + break; } } @@ -635,10 +648,17 @@ public class MainActivity extends AppCompatActivity { * */ private void onHomeButtonPressed() { - // If search fragment wasn't found in the backstack... - if (!NavigationHelper.tryGotoSearchFragment(getSupportFragmentManager())) { - // ...go to the main fragment - NavigationHelper.gotoMainFragment(getSupportFragmentManager()); + final FragmentManager fm = getSupportFragmentManager(); + final Fragment fragment = fm.findFragmentById(R.id.fragment_holder); + + if (fragment instanceof CommentRepliesFragment) { + // Expand DetailsFragment if CommentRepliesFragment was opened + // and no other CommentRepliesFragments are on top of the back stack + // to show the top level comments again. + openDetailFragmentFromCommentReplies(fm, true); + } else if (!NavigationHelper.tryGotoSearchFragment(fm)) { + // If search fragment wasn't found in the backstack go to the main fragment + NavigationHelper.gotoMainFragment(fm); } } @@ -653,8 +673,8 @@ public class MainActivity extends AppCompatActivity { } super.onCreateOptionsMenu(menu); - final Fragment fragment - = getSupportFragmentManager().findFragmentById(R.id.fragment_holder); + final Fragment fragment = + getSupportFragmentManager().findFragmentById(R.id.fragment_holder); if (!(fragment instanceof SearchFragment)) { toolbarLayoutBinding.toolbarSearchContainer.getRoot().setVisibility(View.GONE); } @@ -721,7 +741,7 @@ public class MainActivity extends AppCompatActivity { if (toggle != null) { toggle.syncState(); toolbarLayoutBinding.toolbar.setNavigationOnClickListener(v -> mainBinding.getRoot() - .openDrawer(GravityCompat.START)); + .open()); mainBinding.getRoot().setDrawerLockMode(DrawerLayout.LOCK_MODE_UNDEFINED); } } else { @@ -834,6 +854,68 @@ public class MainActivity extends AppCompatActivity { } } + private void openDetailFragmentFromCommentReplies( + @NonNull final FragmentManager fm, + final boolean popBackStack + ) { + // obtain the name of the fragment under the replies fragment that's going to be popped + @Nullable final String fragmentUnderEntryName; + if (fm.getBackStackEntryCount() < 2) { + fragmentUnderEntryName = null; + } else { + fragmentUnderEntryName = fm.getBackStackEntryAt(fm.getBackStackEntryCount() - 2) + .getName(); + } + + // the root comment is the comment for which the user opened the replies page + @Nullable final CommentRepliesFragment repliesFragment = + (CommentRepliesFragment) fm.findFragmentByTag(CommentRepliesFragment.TAG); + @Nullable final CommentsInfoItem rootComment = + repliesFragment == null ? null : repliesFragment.getCommentsInfoItem(); + + // sometimes this function pops the backstack, other times it's handled by the system + if (popBackStack) { + fm.popBackStackImmediate(); + } + + // only expand the bottom sheet back if there are no more nested comment replies fragments + // stacked under the one that is currently being popped + if (CommentRepliesFragment.TAG.equals(fragmentUnderEntryName)) { + return; + } + + final BottomSheetBehavior behavior = BottomSheetBehavior + .from(mainBinding.fragmentPlayerHolder); + // do not return to the comment if the details fragment was closed + if (behavior.getState() == BottomSheetBehavior.STATE_HIDDEN) { + return; + } + + // scroll to the root comment once the bottom sheet expansion animation is finished + behavior.addBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() { + @Override + public void onStateChanged(@NonNull final View bottomSheet, + final int newState) { + if (newState == BottomSheetBehavior.STATE_EXPANDED) { + final Fragment detailFragment = fm.findFragmentById( + R.id.fragment_player_holder); + if (detailFragment instanceof VideoDetailFragment && rootComment != null) { + // should always be the case + ((VideoDetailFragment) detailFragment).scrollToComment(rootComment); + } + behavior.removeBottomSheetCallback(this); + } + } + + @Override + public void onSlide(@NonNull final View bottomSheet, final float slideOffset) { + // not needed, listener is removed once the sheet is expanded + } + }); + + behavior.setState(BottomSheetBehavior.STATE_EXPANDED); + } + private boolean bottomSheetHiddenOrCollapsed() { final BottomSheetBehavior bottomSheetBehavior = BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder); diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java index fc3423994..21c5354f4 100644 --- a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java +++ b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java @@ -6,6 +6,9 @@ import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3; import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4; import static org.schabi.newpipe.database.Migrations.MIGRATION_4_5; import static org.schabi.newpipe.database.Migrations.MIGRATION_5_6; +import static org.schabi.newpipe.database.Migrations.MIGRATION_6_7; +import static org.schabi.newpipe.database.Migrations.MIGRATION_7_8; +import static org.schabi.newpipe.database.Migrations.MIGRATION_8_9; import android.content.Context; import android.database.Cursor; @@ -26,7 +29,7 @@ public final class NewPipeDatabase { return Room .databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME) .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, - MIGRATION_5_6) + MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9) .build(); } diff --git a/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt b/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt index 060114974..000b83953 100644 --- a/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt +++ b/app/src/main/java/org/schabi/newpipe/NewVersionWorker.kt @@ -1,26 +1,26 @@ package org.schabi.newpipe -import android.app.PendingIntent import android.content.Context import android.content.Intent import android.util.Log +import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat +import androidx.core.content.ContextCompat import androidx.core.content.edit import androidx.core.net.toUri import androidx.preference.PreferenceManager -import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkManager -import androidx.work.WorkRequest import androidx.work.Worker import androidx.work.WorkerParameters +import androidx.work.workDataOf import com.grack.nanojson.JsonParser import com.grack.nanojson.JsonParserException import org.schabi.newpipe.extractor.downloader.Response import org.schabi.newpipe.extractor.exceptions.ReCaptchaException -import org.schabi.newpipe.util.ReleaseVersionUtil.coerceUpdateCheckExpiry -import org.schabi.newpipe.util.ReleaseVersionUtil.isLastUpdateCheckExpired -import org.schabi.newpipe.util.ReleaseVersionUtil.isReleaseApk +import org.schabi.newpipe.util.ReleaseVersionUtil import java.io.IOException class NewVersionWorker( @@ -42,42 +42,58 @@ class NewVersionWorker( versionCode: Int ) { if (BuildConfig.VERSION_CODE >= versionCode) { + if (inputData.getBoolean(IS_MANUAL, false)) { + // Show toast stating that the app is up-to-date if the update check was manual. + ContextCompat.getMainExecutor(applicationContext).execute { + Toast.makeText( + applicationContext, R.string.app_update_unavailable_toast, + Toast.LENGTH_SHORT + ).show() + } + } return } - val app = App.getApp() // A pending intent to open the apk location url in the browser. val intent = Intent(Intent.ACTION_VIEW, apkLocationUrl?.toUri()) intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - val pendingIntent = PendingIntent.getActivity(app, 0, intent, 0) - val channelId = app.getString(R.string.app_update_notification_channel_id) - val notificationBuilder = NotificationCompat.Builder(app, channelId) + val pendingIntent = PendingIntentCompat.getActivity( + applicationContext, 0, intent, 0, false + ) + val channelId = applicationContext.getString(R.string.app_update_notification_channel_id) + val notificationBuilder = NotificationCompat.Builder(applicationContext, channelId) .setSmallIcon(R.drawable.ic_newpipe_update) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setContentIntent(pendingIntent) .setAutoCancel(true) - .setContentTitle(app.getString(R.string.app_update_notification_content_title)) - .setContentText( - app.getString(R.string.app_update_notification_content_text) + - " " + versionName + .setContentIntent(pendingIntent) + .setContentTitle( + applicationContext.getString(R.string.app_update_available_notification_title) ) - val notificationManager = NotificationManagerCompat.from(app) + .setContentText( + applicationContext.getString( + R.string.app_update_available_notification_text, versionName + ) + ) + + val notificationManager = NotificationManagerCompat.from(applicationContext) notificationManager.notify(2000, notificationBuilder.build()) } @Throws(IOException::class, ReCaptchaException::class) private fun checkNewVersion() { // Check if the current apk is a github one or not. - if (!isReleaseApk()) { + if (!ReleaseVersionUtil.isReleaseApk) { return } - val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) - // Check if the last request has happened a certain time ago - // to reduce the number of API requests. - val expiry = prefs.getLong(applicationContext.getString(R.string.update_expiry_key), 0) - if (!isLastUpdateCheckExpired(expiry)) { - return + if (!inputData.getBoolean(IS_MANUAL, false)) { + val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext) + // Check if the last request has happened a certain time ago + // to reduce the number of API requests. + val expiry = prefs.getLong(applicationContext.getString(R.string.update_expiry_key), 0) + if (!ReleaseVersionUtil.isLastUpdateCheckExpired(expiry)) { + return + } } // Make a network request to get latest NewPipe data. @@ -90,7 +106,7 @@ class NewVersionWorker( try { // Store a timestamp which needs to be exceeded, // before a new request to the API is made. - val newExpiry = coerceUpdateCheckExpiry(response.getHeader("expires")) + val newExpiry = ReleaseVersionUtil.coerceUpdateCheckExpiry(response.getHeader("expires")) prefs.edit { putLong(applicationContext.getString(R.string.update_expiry_key), newExpiry) } @@ -102,13 +118,13 @@ class NewVersionWorker( // Parse the json from the response. try { - val githubStableObject = JsonParser.`object`() + val newpipeVersionInfo = JsonParser.`object`() .from(response.responseBody()).getObject("flavors") - .getObject("github").getObject("stable") + .getObject("newpipe") - val versionName = githubStableObject.getString("version") - val versionCode = githubStableObject.getInt("version_code") - val apkLocationUrl = githubStableObject.getString("apk") + val versionName = newpipeVersionInfo.getString("version") + val versionCode = newpipeVersionInfo.getInt("version_code") + val apkLocationUrl = newpipeVersionInfo.getString("apk") compareAppVersionAndShowNotification(versionName, apkLocationUrl, versionCode) } catch (e: JsonParserException) { // Most likely something is wrong in data received from NEWPIPE_API_URL. @@ -120,43 +136,42 @@ class NewVersionWorker( } override fun doWork(): Result { - try { + return try { checkNewVersion() + Result.success() } catch (e: IOException) { Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e) - return Result.failure() + Result.failure() } catch (e: ReCaptchaException) { Log.e(TAG, "ReCaptchaException should never happen here.", e) - return Result.failure() + Result.failure() } - return Result.success() } companion object { private val DEBUG = MainActivity.DEBUG private val TAG = NewVersionWorker::class.java.simpleName private const val NEWPIPE_API_URL = "https://newpipe.net/api/data.json" + private const val IS_MANUAL = "isManual" /** - * Start a new worker which - * checks if all conditions for performing a version check are met, - * fetches the API endpoint [.NEWPIPE_API_URL] containing info - * about the latest NewPipe version - * and displays a notification about ana available update. + * Start a new worker which checks if all conditions for performing a version check are met, + * fetches the API endpoint [.NEWPIPE_API_URL] containing info about the latest NewPipe + * version and displays a notification about an available update if one is available. *

- * Following conditions need to be met, before data is request from the server: + * Following conditions need to be met, before data is requested from the server: * * * The app is signed with the correct signing key (by TeamNewPipe / schabi). * If the signing key differs from the one used upstream, the update cannot be installed. * * The user enabled searching for and notifying about updates in the settings. * * The app did not recently check for updates. * We do not want to make unnecessary connections and DOS our servers. - * */ @JvmStatic - fun enqueueNewVersionCheckingWork(context: Context) { - val workRequest: WorkRequest = - OneTimeWorkRequest.Builder(NewVersionWorker::class.java).build() + fun enqueueNewVersionCheckingWork(context: Context, isManual: Boolean) { + val workRequest = OneTimeWorkRequestBuilder() + .setInputData(workDataOf(IS_MANUAL to isManual)) + .build() WorkManager.getInstance(context).enqueue(workRequest) } } diff --git a/app/src/main/java/org/schabi/newpipe/PanicResponderActivity.java b/app/src/main/java/org/schabi/newpipe/PanicResponderActivity.java index b4fbdfb28..f0d1af81a 100644 --- a/app/src/main/java/org/schabi/newpipe/PanicResponderActivity.java +++ b/app/src/main/java/org/schabi/newpipe/PanicResponderActivity.java @@ -3,7 +3,6 @@ package org.schabi.newpipe; import android.annotation.SuppressLint; import android.app.Activity; import android.content.Intent; -import android.os.Build; import android.os.Bundle; /* @@ -40,10 +39,6 @@ public class PanicResponderActivity extends Activity { ExitActivity.exitAndRemoveFromRecentApps(this); } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - finishAndRemoveTask(); - } else { - finish(); - } + finishAndRemoveTask(); } } diff --git a/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java b/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java index c7604e512..e6177f6a3 100644 --- a/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java +++ b/app/src/main/java/org/schabi/newpipe/QueueItemMenuUtil.java @@ -1,5 +1,6 @@ package org.schabi.newpipe; +import static org.schabi.newpipe.util.SparseItemUtil.fetchStreamInfoAndSaveToDatabase; import static org.schabi.newpipe.util.external_communication.ShareUtils.shareText; import android.content.Context; @@ -10,13 +11,14 @@ import android.widget.PopupMenu; import androidx.fragment.app.FragmentManager; import org.schabi.newpipe.database.stream.model.StreamEntity; +import org.schabi.newpipe.download.DownloadDialog; 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.SparseItemUtil; -import java.util.Collections; +import java.util.List; public final class QueueItemMenuUtil { private QueueItemMenuUtil() { @@ -53,7 +55,7 @@ public final class QueueItemMenuUtil { case R.id.menu_item_append_playlist: PlaylistDialog.createCorrespondingDialog( context, - Collections.singletonList(new StreamEntity(item)), + List.of(new StreamEntity(item)), dialog -> dialog.show( fragmentManager, "QueueItemMenuUtil@append_playlist" @@ -73,7 +75,15 @@ public final class QueueItemMenuUtil { return true; case R.id.menu_item_share: shareText(context, item.getTitle(), item.getUrl(), - item.getThumbnailUrl()); + item.getThumbnails()); + return true; + case R.id.menu_item_download: + fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(), + info -> { + final DownloadDialog downloadDialog = new DownloadDialog(context, + info); + downloadDialog.show(fragmentManager, "downloadDialog"); + }); return true; } return false; diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java index adef3c0e4..c59dc7532 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -10,12 +10,14 @@ import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; import android.content.pm.PackageManager; +import android.os.Build; import android.os.Bundle; import android.text.TextUtils; import android.view.ContextThemeWrapper; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.view.WindowManager; import android.widget.Button; import android.widget.RadioButton; import android.widget.RadioGroup; @@ -24,19 +26,26 @@ import android.widget.Toast; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.content.res.AppCompatResources; import androidx.core.app.NotificationCompat; import androidx.core.app.ServiceCompat; -import androidx.core.widget.TextViewCompat; +import androidx.core.math.MathUtils; +import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.DefaultLifecycleObserver; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleOwner; import androidx.preference.PreferenceManager; 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.download.LoadingDialog; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.ReCaptchaActivity; @@ -56,22 +65,23 @@ import org.schabi.newpipe.extractor.exceptions.PrivateContentException; import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException; import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException; +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.ktx.ExceptionUtils; import org.schabi.newpipe.local.dialog.PlaylistDialog; -import org.schabi.newpipe.player.MainPlayer; +import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.helper.PlayerHolder; -import org.schabi.newpipe.player.playqueue.ChannelPlayQueue; +import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; +import org.schabi.newpipe.util.ChannelTabHelper; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.ExtractorHelper; -import org.schabi.newpipe.util.ListHelper; +import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.ThemeHelper; @@ -80,10 +90,13 @@ import org.schabi.newpipe.util.urlfinder.UrlFinder; import org.schabi.newpipe.views.FocusOverlayView; import java.io.Serializable; +import java.lang.ref.Reference; +import java.lang.ref.WeakReference; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collections; import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; import icepick.Icepick; import icepick.State; @@ -92,7 +105,6 @@ import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.functions.Consumer; import io.reactivex.rxjava3.schedulers.Schedulers; /** @@ -112,12 +124,57 @@ public class RouterActivity extends AppCompatActivity { private boolean selectionIsDownload = false; private boolean selectionIsAddToPlaylist = false; private AlertDialog alertDialogChoice = null; + private FragmentManager.FragmentLifecycleCallbacks dismissListener = null; @Override protected void onCreate(final Bundle savedInstanceState) { + ThemeHelper.setDayNightMode(this); + setTheme(ThemeHelper.isLightThemeSelected(this) + ? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark); + Localization.assureCorrectAppLanguage(this); + + // Pass-through touch events to background activities + // so that our transparent window won't lock UI in the mean time + // network request is underway before showing PlaylistDialog or DownloadDialog + // (ref: https://stackoverflow.com/a/10606141) + getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + | WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL + | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE); + + // Android never fails to impress us with a list of new restrictions per API. + // Starting with S (Android 12) one of the prerequisite conditions has to be met + // before the FLAG_NOT_TOUCHABLE flag is allowed to kick in: + // @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE + // For our present purpose it seems we can just set LayoutParams.alpha to 0 + // on the strength of "4. Fully transparent windows" without affecting the scrim of dialogs + final WindowManager.LayoutParams params = getWindow().getAttributes(); + params.alpha = 0f; + getWindow().setAttributes(params); + super.onCreate(savedInstanceState); Icepick.restoreInstanceState(this, savedInstanceState); + // FragmentManager will take care to recreate (Playlist|Download)Dialog when screen rotates + // We used to .setOnDismissListener(dialog -> finish()); when creating these DialogFragments + // but those callbacks won't survive a config change + // Try an alternate approach to hook into FragmentManager instead, to that effect + // (ref: https://stackoverflow.com/a/44028453) + final FragmentManager fm = getSupportFragmentManager(); + if (dismissListener == null) { + dismissListener = new FragmentManager.FragmentLifecycleCallbacks() { + @Override + public void onFragmentDestroyed(@NonNull final FragmentManager fm, + @NonNull final Fragment f) { + super.onFragmentDestroyed(fm, f); + if (f instanceof DialogFragment && fm.getFragments().isEmpty()) { + // No more DialogFragments, we're done + finish(); + } + } + }; + } + fm.registerFragmentLifecycleCallbacks(dismissListener, false); + if (TextUtils.isEmpty(currentUrl)) { currentUrl = getUrl(getIntent()); @@ -126,9 +183,6 @@ public class RouterActivity extends AppCompatActivity { finish(); } } - - setTheme(ThemeHelper.isLightThemeSelected(this) - ? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark); } @Override @@ -150,16 +204,34 @@ public class RouterActivity extends AppCompatActivity { protected void onStart() { super.onStart(); - handleUrl(currentUrl); + // Don't overlap the DialogFragment after rotating the screen + // If there's no DialogFragment, we're either starting afresh + // or we didn't make it to PlaylistDialog or DownloadDialog before the orientation change + if (getSupportFragmentManager().getFragments().isEmpty()) { + // Start over from scratch + handleUrl(currentUrl); + } } @Override protected void onDestroy() { super.onDestroy(); + if (dismissListener != null) { + getSupportFragmentManager().unregisterFragmentLifecycleCallbacks(dismissListener); + } + disposables.clear(); } + @Override + public void finish() { + // allow the activity to recreate in case orientation changes + if (!isChangingConfigurations()) { + super.finish(); + } + } + private void handleUrl(final String url) { disposables.add(Observable .fromCallable(() -> { @@ -239,7 +311,7 @@ public class RouterActivity extends AppCompatActivity { } } - private void showUnsupportedUrlDialog(final String url) { + protected void showUnsupportedUrlDialog(final String url) { final Context context = getThemeWrapperContext(); new AlertDialog.Builder(context) .setTitle(R.string.unsupported_url) @@ -257,80 +329,122 @@ public class RouterActivity extends AppCompatActivity { protected void onSuccess() { final SharedPreferences preferences = PreferenceManager .getDefaultSharedPreferences(this); - final String selectedChoiceKey = preferences - .getString(getString(R.string.preferred_open_action_key), - getString(R.string.preferred_open_action_default)); - final String showInfoKey = getString(R.string.show_info_key); - final String videoPlayerKey = getString(R.string.video_player_key); - final String backgroundPlayerKey = getString(R.string.background_player_key); - final String popupPlayerKey = getString(R.string.popup_player_key); - final String downloadKey = getString(R.string.download_key); - final String alwaysAskKey = getString(R.string.always_ask_open_action_key); + final ChoiceAvailabilityChecker choiceChecker = new ChoiceAvailabilityChecker( + getChoicesForService(currentService, currentLinkType), + preferences.getString(getString(R.string.preferred_open_action_key), + getString(R.string.preferred_open_action_default))); - if (selectedChoiceKey.equals(alwaysAskKey)) { - final List choices - = getChoicesForService(currentService, currentLinkType); + // Check for non-player related choices + if (choiceChecker.isAvailableAndSelected( + R.string.show_info_key, + R.string.download_key, + R.string.add_to_playlist_key)) { + handleChoice(choiceChecker.getSelectedChoiceKey()); + return; + } + // Check if the choice is player related + if (choiceChecker.isAvailableAndSelected( + R.string.video_player_key, + R.string.background_player_key, + R.string.popup_player_key)) { + + final String selectedChoice = choiceChecker.getSelectedChoiceKey(); - switch (choices.size()) { - case 1: - handleChoice(choices.get(0).key); - break; - case 0: - handleChoice(showInfoKey); - break; - default: - showDialog(choices); - break; - } - } else if (selectedChoiceKey.equals(showInfoKey)) { - handleChoice(showInfoKey); - } else if (selectedChoiceKey.equals(downloadKey)) { - handleChoice(downloadKey); - } else { final boolean isExtVideoEnabled = preferences.getBoolean( getString(R.string.use_external_video_player_key), false); final boolean isExtAudioEnabled = preferences.getBoolean( getString(R.string.use_external_audio_player_key), false); - final boolean isVideoPlayerSelected = selectedChoiceKey.equals(videoPlayerKey) - || selectedChoiceKey.equals(popupPlayerKey); - final boolean isAudioPlayerSelected = selectedChoiceKey.equals(backgroundPlayerKey); + final boolean isVideoPlayerSelected = + selectedChoice.equals(getString(R.string.video_player_key)) + || selectedChoice.equals(getString(R.string.popup_player_key)); + final boolean isAudioPlayerSelected = + selectedChoice.equals(getString(R.string.background_player_key)); - if (currentLinkType != LinkType.STREAM) { - if (isExtAudioEnabled && isAudioPlayerSelected - || isExtVideoEnabled && isVideoPlayerSelected) { - Toast.makeText(this, R.string.external_player_unsupported_link_type, - Toast.LENGTH_LONG).show(); - handleChoice(showInfoKey); - return; - } + if (currentLinkType != LinkType.STREAM + && ((isExtAudioEnabled && isAudioPlayerSelected) + || (isExtVideoEnabled && isVideoPlayerSelected)) + ) { + Toast.makeText(this, R.string.external_player_unsupported_link_type, + Toast.LENGTH_LONG).show(); + handleChoice(getString(R.string.show_info_key)); + return; } - final List capabilities - = currentService.getServiceInfo().getMediaCapabilities(); + final List capabilities = + currentService.getServiceInfo().getMediaCapabilities(); - boolean serviceSupportsChoice = false; - if (isVideoPlayerSelected) { - serviceSupportsChoice = capabilities.contains(VIDEO); - } else if (selectedChoiceKey.equals(backgroundPlayerKey)) { - serviceSupportsChoice = capabilities.contains(AUDIO); - } - - if (serviceSupportsChoice) { - handleChoice(selectedChoiceKey); + // Check if the service supports the choice + if ((isVideoPlayerSelected && capabilities.contains(VIDEO)) + || (isAudioPlayerSelected && capabilities.contains(AUDIO))) { + handleChoice(selectedChoice); } else { - handleChoice(showInfoKey); + handleChoice(getString(R.string.show_info_key)); } + return; + } + + // Default / Ask always + final List availableChoices = choiceChecker.getAvailableChoices(); + switch (availableChoices.size()) { + case 1: + handleChoice(availableChoices.get(0).key); + break; + case 0: + handleChoice(getString(R.string.show_info_key)); + break; + default: + showDialog(availableChoices); + break; + } + } + + /** + * This is a helper class for checking if the choices are available and/or selected. + */ + class ChoiceAvailabilityChecker { + private final List availableChoices; + private final String selectedChoiceKey; + + ChoiceAvailabilityChecker( + @NonNull final List availableChoices, + @NonNull final String selectedChoiceKey) { + this.availableChoices = availableChoices; + this.selectedChoiceKey = selectedChoiceKey; + } + + public List getAvailableChoices() { + return availableChoices; + } + + public String getSelectedChoiceKey() { + return selectedChoiceKey; + } + + public boolean isAvailableAndSelected(@StringRes final int... wantedKeys) { + return Arrays.stream(wantedKeys).anyMatch(this::isAvailableAndSelected); + } + + public boolean isAvailableAndSelected(@StringRes final int wantedKey) { + final String wanted = getString(wantedKey); + // Check if the wanted option is selected + if (!selectedChoiceKey.equals(wanted)) { + return false; + } + // Check if it's available + return availableChoices.stream().anyMatch(item -> wanted.equals(item.key)); } } private void showDialog(final List choices) { final SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(this); - final Context themeWrapperContext = getThemeWrapperContext(); - final LayoutInflater inflater = LayoutInflater.from(themeWrapperContext); - final RadioGroup radioGroup = SingleChoiceDialogViewBinding.inflate(getLayoutInflater()) - .list; + final Context themeWrapperContext = getThemeWrapperContext(); + final LayoutInflater layoutInflater = LayoutInflater.from(themeWrapperContext); + + final SingleChoiceDialogViewBinding binding = + SingleChoiceDialogViewBinding.inflate(layoutInflater); + final RadioGroup radioGroup = binding.list; final DialogInterface.OnClickListener dialogButtonsClickListener = (dialog, which) -> { final int indexOfChild = radioGroup.indexOfChild( @@ -349,21 +463,19 @@ public class RouterActivity extends AppCompatActivity { alertDialogChoice = new AlertDialog.Builder(themeWrapperContext) .setTitle(R.string.preferred_open_action_share_menu_title) - .setView(radioGroup) + .setView(binding.getRoot()) .setCancelable(true) .setNegativeButton(R.string.just_once, dialogButtonsClickListener) .setPositiveButton(R.string.always, dialogButtonsClickListener) - .setOnDismissListener((dialog) -> { + .setOnDismissListener(dialog -> { if (!selectionIsDownload && !selectionIsAddToPlaylist) { finish(); } }) .create(); - //noinspection CodeBlock2Expr - alertDialogChoice.setOnShowListener(dialog -> { - setDialogButtonsState(alertDialogChoice, radioGroup.getCheckedRadioButtonId() != -1); - }); + alertDialogChoice.setOnShowListener(dialog -> setDialogButtonsState( + alertDialogChoice, radioGroup.getCheckedRadioButtonId() != -1)); radioGroup.setOnCheckedChangeListener((group, checkedId) -> setDialogButtonsState(alertDialogChoice, true)); @@ -383,9 +495,10 @@ public class RouterActivity extends AppCompatActivity { int id = 12345; for (final AdapterChoiceItem item : choices) { - final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater).getRoot(); + final RadioButton radioButton = ListRadioIconItemBinding.inflate(layoutInflater) + .getRoot(); radioButton.setText(item.description); - TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(radioButton, + radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds( AppCompatResources.getDrawable(themeWrapperContext, item.icon), null, null, null); radioButton.setChecked(false); @@ -410,7 +523,7 @@ public class RouterActivity extends AppCompatActivity { } } - selectedRadioPosition = Math.min(Math.max(-1, selectedRadioPosition), choices.size() - 1); + selectedRadioPosition = MathUtils.clamp(selectedRadioPosition, -1, choices.size() - 1); if (selectedRadioPosition != -1) { ((RadioButton) radioGroup.getChildAt(selectedRadioPosition)).setChecked(true); } @@ -425,90 +538,67 @@ public class RouterActivity extends AppCompatActivity { private List getChoicesForService(final StreamingService service, final LinkType linkType) { - final Context context = getThemeWrapperContext(); - - final List returnList = new ArrayList<>(); - final List capabilities - = service.getServiceInfo().getMediaCapabilities(); - - final SharedPreferences preferences = PreferenceManager - .getDefaultSharedPreferences(this); - final boolean isExtVideoEnabled = preferences.getBoolean( - getString(R.string.use_external_video_player_key), false); - final boolean isExtAudioEnabled = preferences.getBoolean( - getString(R.string.use_external_audio_player_key), false); - - final AdapterChoiceItem videoPlayer = new AdapterChoiceItem( - getString(R.string.video_player_key), getString(R.string.video_player), - R.drawable.ic_play_arrow); final AdapterChoiceItem showInfo = new AdapterChoiceItem( getString(R.string.show_info_key), getString(R.string.show_info), R.drawable.ic_info_outline); - final AdapterChoiceItem popupPlayer = new AdapterChoiceItem( - getString(R.string.popup_player_key), getString(R.string.popup_player), - R.drawable.ic_picture_in_picture); + final AdapterChoiceItem videoPlayer = new AdapterChoiceItem( + getString(R.string.video_player_key), getString(R.string.video_player), + R.drawable.ic_play_arrow); final AdapterChoiceItem backgroundPlayer = new AdapterChoiceItem( getString(R.string.background_player_key), getString(R.string.background_player), R.drawable.ic_headset); - final AdapterChoiceItem addToPlaylist = new AdapterChoiceItem( - getString(R.string.add_to_playlist_key), getString(R.string.add_to_playlist), - R.drawable.ic_add); + final AdapterChoiceItem popupPlayer = new AdapterChoiceItem( + getString(R.string.popup_player_key), getString(R.string.popup_player), + R.drawable.ic_picture_in_picture); + final List returnedItems = new ArrayList<>(); + returnedItems.add(showInfo); // Always present + + final List capabilities = + service.getServiceInfo().getMediaCapabilities(); if (linkType == LinkType.STREAM) { - if (isExtVideoEnabled) { - // show both "show info" and "video player", they are two different activities - returnList.add(showInfo); - returnList.add(videoPlayer); - } else { - final MainPlayer.PlayerType playerType = PlayerHolder.getInstance().getType(); - if (capabilities.contains(VIDEO) - && PlayerHelper.isAutoplayAllowedByUser(context) - && playerType == null || playerType == MainPlayer.PlayerType.VIDEO) { - // show only "video player" since the details activity will be opened and the - // video will be auto played there. Since "show info" would do the exact same - // thing, use that as a key to let VideoDetailFragment load the stream instead - // of using FetcherService (see comment in handleChoice()) - returnList.add(new AdapterChoiceItem( - showInfo.key, videoPlayer.description, videoPlayer.icon)); - } else { - // show only "show info" if video player is not applicable, auto play is - // disabled or a video is playing in a player different than the main one - returnList.add(showInfo); - } - } - if (capabilities.contains(VIDEO)) { - returnList.add(popupPlayer); + returnedItems.add(videoPlayer); + returnedItems.add(popupPlayer); } if (capabilities.contains(AUDIO)) { - returnList.add(backgroundPlayer); + returnedItems.add(backgroundPlayer); } // download is redundant for linkType CHANNEL AND PLAYLIST (till playlist downloading is // not supported ) - returnList.add(new AdapterChoiceItem(getString(R.string.download_key), + returnedItems.add(new AdapterChoiceItem(getString(R.string.download_key), getString(R.string.download), R.drawable.ic_file_download)); // Add to playlist is not necessary for CHANNEL and PLAYLIST linkType since those can // not be added to a playlist - returnList.add(addToPlaylist); - + returnedItems.add(new AdapterChoiceItem(getString(R.string.add_to_playlist_key), + getString(R.string.add_to_playlist), + R.drawable.ic_add)); } else { - returnList.add(showInfo); + // LinkType.NONE is never present because it's filtered out before + // channels and playlist can be played as they contain a list of videos + final SharedPreferences preferences = PreferenceManager + .getDefaultSharedPreferences(this); + final boolean isExtVideoEnabled = preferences.getBoolean( + getString(R.string.use_external_video_player_key), false); + final boolean isExtAudioEnabled = preferences.getBoolean( + getString(R.string.use_external_audio_player_key), false); + if (capabilities.contains(VIDEO) && !isExtVideoEnabled) { - returnList.add(videoPlayer); - returnList.add(popupPlayer); + returnedItems.add(videoPlayer); + returnedItems.add(popupPlayer); } if (capabilities.contains(AUDIO) && !isExtAudioEnabled) { - returnList.add(backgroundPlayer); + returnedItems.add(backgroundPlayer); } } - return returnList; + return returnedItems; } - private Context getThemeWrapperContext() { + protected Context getThemeWrapperContext() { return new ContextThemeWrapper(this, ThemeHelper.isLightThemeSelected(this) ? R.style.LightTheme : R.style.DarkTheme); } @@ -544,8 +634,7 @@ public class RouterActivity extends AppCompatActivity { } if (selectedChoiceKey.equals(getString(R.string.popup_player_key)) - && !PermissionHelper.isPopupEnabled(this)) { - PermissionHelper.showPopupEnablementToast(this); + && !PermissionHelper.isPopupEnabledElseAsk(this)) { finish(); return; } @@ -567,7 +656,8 @@ public class RouterActivity extends AppCompatActivity { // stop and bypass FetcherService if InfoScreen was selected since // StreamDetailFragment can fetch data itself - if (selectedChoiceKey.equals(getString(R.string.show_info_key))) { + if (selectedChoiceKey.equals(getString(R.string.show_info_key)) + || canHandleChoiceLikeShowInfo(selectedChoiceKey)) { disposables.add(Observable .fromCallable(() -> NavigationHelper.getIntentByLink(this, currentUrl)) .subscribeOn(Schedulers.io()) @@ -590,63 +680,208 @@ public class RouterActivity extends AppCompatActivity { finish(); } - private void openAddToPlaylistDialog() { - // Getting the stream info usually takes a moment - // Notifying the user here to ensure that no confusion arises - Toast.makeText( - getApplicationContext(), - getString(R.string.processing_may_take_a_moment), - Toast.LENGTH_SHORT) - .show(); + private boolean canHandleChoiceLikeShowInfo(final String selectedChoiceKey) { + if (!selectedChoiceKey.equals(getString(R.string.video_player_key))) { + return false; + } + // "video player" can be handled like "show info" (because VideoDetailFragment can load + // the stream instead of FetcherService) when... - disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, false) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - info -> PlaylistDialog.createCorrespondingDialog( - getThemeWrapperContext(), - Collections.singletonList(new StreamEntity(info)), - playlistDialog -> { - playlistDialog.setOnDismissListener(dialog -> finish()); + // ...Autoplay is enabled + if (!PlayerHelper.isAutoplayAllowedByUser(getThemeWrapperContext())) { + return false; + } - playlistDialog.show( - this.getSupportFragmentManager(), - "addToPlaylistDialog" - ); - } - ), - throwable -> handleError(this, new ErrorInfo( - throwable, - UserAction.REQUESTED_STREAM, - "Tried to add " + currentUrl + " to a playlist", - currentService.getServiceId()) - ) - ) - ); + final boolean isExtVideoEnabled = PreferenceManager.getDefaultSharedPreferences(this) + .getBoolean(getString(R.string.use_external_video_player_key), false); + // ...it's not done via an external player + if (isExtVideoEnabled) { + return false; + } + + // ...the player is not running or in normal Video-mode/type + final PlayerType playerType = PlayerHolder.getInstance().getType(); + return playerType == null || playerType == PlayerType.MAIN; } - @SuppressLint("CheckResult") - private void openDownloadDialog() { - disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(result -> { - final List sortedVideoStreams = ListHelper - .getSortedStreamVideosList(this, result.getVideoStreams(), - result.getVideoOnlyStreams(), false, false); - final int selectedVideoStreamIndex = ListHelper - .getDefaultResolutionIndex(this, sortedVideoStreams); + public static class PersistentFragment extends Fragment { + private WeakReference weakContext; + private final CompositeDisposable disposables = new CompositeDisposable(); + private int running = 0; - final FragmentManager fm = getSupportFragmentManager(); - final DownloadDialog downloadDialog = DownloadDialog.newInstance(result); - downloadDialog.setVideoStreams(sortedVideoStreams); - downloadDialog.setAudioStreams(result.getAudioStreams()); - downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex); - downloadDialog.setOnDismissListener(dialog -> finish()); - downloadDialog.show(fm, "downloadDialog"); - fm.executePendingTransactions(); - }, throwable -> - showUnsupportedUrlDialog(currentUrl))); + private synchronized void inFlight(final boolean started) { + if (started) { + running++; + } else { + running--; + if (running <= 0) { + getActivityContext().ifPresent(context -> context.getSupportFragmentManager() + .beginTransaction().remove(this).commit()); + } + } + } + + @Override + public void onAttach(@NonNull final Context activityContext) { + super.onAttach(activityContext); + weakContext = new WeakReference<>((AppCompatActivity) activityContext); + } + + @Override + public void onDetach() { + super.onDetach(); + weakContext = null; + } + + @SuppressWarnings("deprecation") + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setRetainInstance(true); + } + + @Override + public void onDestroy() { + super.onDestroy(); + disposables.clear(); + } + + /** + * @return the activity context, if there is one and the activity is not finishing + */ + private Optional getActivityContext() { + return Optional.ofNullable(weakContext) + .map(Reference::get) + .filter(context -> !context.isFinishing()); + } + + // guard against IllegalStateException in calling DialogFragment.show() whilst in background + // (which could happen, say, when the user pressed the home button while waiting for + // the network request to return) when it internally calls FragmentTransaction.commit() + // after the FragmentManager has saved its states (isStateSaved() == true) + // (ref: https://stackoverflow.com/a/39813506) + private void runOnVisible(final Consumer runnable) { + getActivityContext().ifPresentOrElse(context -> { + if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) { + context.runOnUiThread(() -> { + runnable.accept(context); + inFlight(false); + }); + } else { + getLifecycle().addObserver(new DefaultLifecycleObserver() { + @Override + public void onResume(@NonNull final LifecycleOwner owner) { + getLifecycle().removeObserver(this); + getActivityContext().ifPresentOrElse(context -> + context.runOnUiThread(() -> { + runnable.accept(context); + inFlight(false); + }), + () -> inFlight(false) + ); + } + }); + // this trick doesn't seem to work on Android 10+ (API 29) + // which places restrictions on starting activities from the background + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q + && !context.isChangingConfigurations()) { + // try to bring the activity back to front if minimised + final Intent i = new Intent(context, RouterActivity.class); + i.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT); + startActivity(i); + } + } + + }, () -> + // this branch is executed if there is no activity context + inFlight(false) + ); + } + + Single pleaseWait(final Single single) { + // 'abuse' ambWith() here to cancel the toast for us when the wait is over + return single.ambWith(Single.create(emitter -> getActivityContext().ifPresent(context -> + context.runOnUiThread(() -> { + // Getting the stream info usually takes a moment + // Notifying the user here to ensure that no confusion arises + final Toast toast = Toast.makeText(context, + getString(R.string.processing_may_take_a_moment), + Toast.LENGTH_LONG); + toast.show(); + emitter.setCancellable(toast::cancel); + })))); + } + + @SuppressLint("CheckResult") + private void openDownloadDialog(final int currentServiceId, final String currentUrl) { + inFlight(true); + final LoadingDialog loadingDialog = new LoadingDialog(R.string.loading_metadata_title); + loadingDialog.show(getParentFragmentManager(), "loadingDialog"); + disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .compose(this::pleaseWait) + .subscribe(result -> + runOnVisible(ctx -> { + loadingDialog.dismiss(); + final FragmentManager fm = ctx.getSupportFragmentManager(); + final DownloadDialog downloadDialog = new DownloadDialog(ctx, result); + // dismiss listener to be handled by FragmentManager + downloadDialog.show(fm, "downloadDialog"); + } + ), throwable -> runOnVisible(ctx -> { + loadingDialog.dismiss(); + ((RouterActivity) ctx).showUnsupportedUrlDialog(currentUrl); + }))); + } + + private void openAddToPlaylistDialog(final int currentServiceId, final String currentUrl) { + inFlight(true); + disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, false) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .compose(this::pleaseWait) + .subscribe( + info -> getActivityContext().ifPresent(context -> + PlaylistDialog.createCorrespondingDialog(context, + List.of(new StreamEntity(info)), + playlistDialog -> runOnVisible(ctx -> { + // dismiss listener to be handled by FragmentManager + final FragmentManager fm = + ctx.getSupportFragmentManager(); + playlistDialog.show(fm, "addToPlaylistDialog"); + }) + )), + throwable -> runOnVisible(ctx -> handleError(ctx, new ErrorInfo( + throwable, + UserAction.REQUESTED_STREAM, + "Tried to add " + currentUrl + " to a playlist", + ((RouterActivity) ctx).currentService.getServiceId()) + )) + ) + ); + } + } + + private void openAddToPlaylistDialog() { + getPersistFragment().openAddToPlaylistDialog(currentServiceId, currentUrl); + } + + private void openDownloadDialog() { + getPersistFragment().openDownloadDialog(currentServiceId, currentUrl); + } + + private PersistentFragment getPersistFragment() { + final FragmentManager fm = getSupportFragmentManager(); + PersistentFragment persistFragment = + (PersistentFragment) fm.findFragmentByTag("PERSIST_FRAGMENT"); + if (persistFragment == null) { + persistFragment = new PersistentFragment(); + fm.beginTransaction() + .add(persistFragment, "PERSIST_FRAGMENT") + .commitNow(); + } + return persistFragment; } @Override @@ -672,8 +907,8 @@ public class RouterActivity extends AppCompatActivity { final int icon; AdapterChoiceItem(final String key, final String description, final int icon) { - this.description = description; this.key = key; + this.description = description; this.icon = icon; } } @@ -789,7 +1024,16 @@ public class RouterActivity extends AppCompatActivity { } playQueue = new SinglePlayQueue((StreamInfo) info); } else if (info instanceof ChannelInfo) { - playQueue = new ChannelPlayQueue((ChannelInfo) info); + final Optional playableTab = ((ChannelInfo) info).getTabs() + .stream() + .filter(ChannelTabHelper::isStreamsTab) + .findFirst(); + + if (playableTab.isPresent()) { + playQueue = new ChannelTabPlayQueue(info.getServiceId(), playableTab.get()); + } else { + return; // there is no playable tab + } } else if (info instanceof PlaylistInfo) { playQueue = new PlaylistPlayQueue((PlaylistInfo) info); } else { diff --git a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt index 50a3984e3..7f148e9b5 100644 --- a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt +++ b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt @@ -6,6 +6,7 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.Button +import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity @@ -57,13 +58,9 @@ class AboutActivity : AppCompatActivity() { * A placeholder fragment containing a simple view. */ class AboutFragment : Fragment() { - private fun Button.openLink(url: Int) { + private fun Button.openLink(@StringRes url: Int) { setOnClickListener { - ShareUtils.openUrlInBrowser( - context, - requireContext().getString(url), - false - ) + ShareUtils.openUrlInApp(context, requireContext().getString(url)) } } @@ -78,6 +75,7 @@ class AboutActivity : AppCompatActivity() { aboutDonationLink.openLink(R.string.donation_url) aboutWebsiteLink.openLink(R.string.website_url) aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url) + faqLink.openLink(R.string.faq_url) return root } } @@ -118,7 +116,7 @@ class AboutActivity : AppCompatActivity() { /** * List of all software components. */ - private val SOFTWARE_COMPONENTS = arrayOf( + private val SOFTWARE_COMPONENTS = arrayListOf( SoftwareComponent( "ACRA", "2013", "Kevin Gaudin", "https://github.com/ACRA/acra", StandardLicenses.APACHE2 diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt b/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt index c816d78be..9f5ad2a7a 100644 --- a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt @@ -1,31 +1,40 @@ package org.schabi.newpipe.about import android.os.Bundle +import android.util.Base64 import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.webkit.WebView +import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf import androidx.fragment.app.Fragment +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers +import io.reactivex.rxjava3.core.Observable import io.reactivex.rxjava3.disposables.CompositeDisposable +import io.reactivex.rxjava3.disposables.Disposable +import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.BuildConfig import org.schabi.newpipe.R -import org.schabi.newpipe.about.LicenseFragmentHelper.showLicense import org.schabi.newpipe.databinding.FragmentLicensesBinding import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding +import org.schabi.newpipe.ktx.parcelableArrayList +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.external_communication.ShareUtils /** * Fragment containing the software licenses. */ class LicenseFragment : Fragment() { - private lateinit var softwareComponents: Array - private var activeLicense: License? = null + private lateinit var softwareComponents: List + private var activeSoftwareComponent: SoftwareComponent? = null private val compositeDisposable = CompositeDisposable() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - softwareComponents = arguments?.getParcelableArray(ARG_COMPONENTS) as Array - activeLicense = savedInstanceState?.getSerializable(LICENSE_KEY) as? License - // Sort components by name - softwareComponents.sortBy { it.name } + softwareComponents = arguments?.parcelableArrayList(ARG_COMPONENTS)!! + .sortedBy { it.name } // Sort components by name + activeSoftwareComponent = savedInstanceState?.getSerializable(SOFTWARE_COMPONENT_KEY) as? SoftwareComponent } override fun onDestroy() { @@ -40,9 +49,8 @@ class LicenseFragment : Fragment() { ): View { val binding = FragmentLicensesBinding.inflate(inflater, container, false) binding.licensesAppReadLicense.setOnClickListener { - activeLicense = StandardLicenses.GPL3 compositeDisposable.add( - showLicense(activity, StandardLicenses.GPL3) + showLicense(NEWPIPE_SOFTWARE_COMPONENT) ) } for (component in softwareComponents) { @@ -58,27 +66,72 @@ class LicenseFragment : Fragment() { val root: View = componentBinding.root root.tag = component root.setOnClickListener { - activeLicense = component.license compositeDisposable.add( - showLicense(activity, component) + showLicense(component) ) } binding.licensesSoftwareComponents.addView(root) registerForContextMenu(root) } - activeLicense?.let { compositeDisposable.add(showLicense(activity, it)) } + activeSoftwareComponent?.let { compositeDisposable.add(showLicense(it)) } return binding.root } override fun onSaveInstanceState(savedInstanceState: Bundle) { super.onSaveInstanceState(savedInstanceState) - activeLicense?.let { savedInstanceState.putSerializable(LICENSE_KEY, it) } + activeSoftwareComponent?.let { savedInstanceState.putSerializable(SOFTWARE_COMPONENT_KEY, it) } + } + + private fun showLicense( + softwareComponent: SoftwareComponent + ): Disposable { + return if (context == null) { + Disposable.empty() + } else { + val context = requireContext() + activeSoftwareComponent = softwareComponent + Observable.fromCallable { getFormattedLicense(context, softwareComponent.license) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { formattedLicense -> + val webViewData = Base64.encodeToString( + formattedLicense.toByteArray(), Base64.NO_PADDING + ) + val webView = WebView(context) + webView.loadData(webViewData, "text/html; charset=UTF-8", "base64") + + Localization.assureCorrectAppLanguage(context) + val builder = AlertDialog.Builder(requireContext()) + .setTitle(softwareComponent.name) + .setView(webView) + .setOnCancelListener { activeSoftwareComponent = null } + .setOnDismissListener { activeSoftwareComponent = null } + .setPositiveButton(R.string.done) { dialog, _ -> dialog.dismiss() } + + if (softwareComponent != NEWPIPE_SOFTWARE_COMPONENT) { + builder.setNeutralButton(R.string.open_website_license) { _, _ -> + ShareUtils.openUrlInApp(requireContext(), softwareComponent.link) + } + } + + builder.show() + } + } } companion object { private const val ARG_COMPONENTS = "components" - private const val LICENSE_KEY = "ACTIVE_LICENSE" - fun newInstance(softwareComponents: Array): LicenseFragment { + private const val SOFTWARE_COMPONENT_KEY = "ACTIVE_SOFTWARE_COMPONENT" + private val NEWPIPE_SOFTWARE_COMPONENT = SoftwareComponent( + "NewPipe", + "2014-2023", + "Team NewPipe", + "https://newpipe.net/", + StandardLicenses.GPL3, + BuildConfig.VERSION_NAME + ) + + fun newInstance(softwareComponents: ArrayList): LicenseFragment { val fragment = LicenseFragment() fragment.arguments = bundleOf(ARG_COMPONENTS to softwareComponents) return fragment diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt index c1dd38389..56e21c88a 100644 --- a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt +++ b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt @@ -1,137 +1,52 @@ package org.schabi.newpipe.about import android.content.Context -import android.util.Base64 -import android.webkit.WebView -import androidx.appcompat.app.AlertDialog -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable -import io.reactivex.rxjava3.disposables.Disposable -import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.R -import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.ThemeHelper -import org.schabi.newpipe.util.external_communication.ShareUtils -import java.io.BufferedReader import java.io.IOException -import java.io.InputStreamReader -import java.nio.charset.StandardCharsets -object LicenseFragmentHelper { - /** - * @param context the context to use - * @param license the license - * @return String which contains a HTML formatted license page - * styled according to the context's theme - */ - private fun getFormattedLicense(context: Context, license: License): String { - val licenseContent = StringBuilder() - val webViewData: String - try { - BufferedReader( - InputStreamReader( - context.assets.open(license.filename), - StandardCharsets.UTF_8 - ) - ).use { `in` -> - var str: String? - while (`in`.readLine().also { str = it } != null) { - licenseContent.append(str) - } - - // split the HTML file and insert the stylesheet into the HEAD of the file - webViewData = "$licenseContent".replace( - "", - "" - ) - } - } catch (e: IOException) { - throw IllegalArgumentException( - "Could not get license file: " + license.filename, e - ) - } - return webViewData - } - - /** - * @param context the Android context - * @return String which is a CSS stylesheet according to the context's theme - */ - private fun getLicenseStylesheet(context: Context): String { - val isLightTheme = ThemeHelper.isLightThemeSelected(context) - return ( - "body{padding:12px 15px;margin:0;" + "background:#" + getHexRGBColor( - context, - if (isLightTheme) R.color.light_license_background_color - else R.color.dark_license_background_color - ) + ";" + "color:#" + getHexRGBColor( - context, - if (isLightTheme) R.color.light_license_text_color - else R.color.dark_license_text_color - ) + "}" + "a[href]{color:#" + getHexRGBColor( - context, - if (isLightTheme) R.color.light_youtube_primary_color - else R.color.dark_youtube_primary_color - ) + "}" + "pre{white-space:pre-wrap}" - ) - } - - /** - * Cast R.color to a hexadecimal color value. - * - * @param context the context to use - * @param color the color number from R.color - * @return a six characters long String with hexadecimal RGB values - */ - private fun getHexRGBColor(context: Context, color: Int): String { - return context.getString(color).substring(3) - } - - fun showLicense(context: Context?, license: License): Disposable { - return showLicense(context, license) { alertDialog -> - alertDialog.setPositiveButton(R.string.ok) { dialog, _ -> - dialog.dismiss() - } - } - } - - fun showLicense(context: Context?, component: SoftwareComponent): Disposable { - return showLicense(context, component.license) { alertDialog -> - alertDialog.setPositiveButton(R.string.dismiss) { dialog, _ -> - dialog.dismiss() - } - alertDialog.setNeutralButton(R.string.open_website_license) { _, _ -> - ShareUtils.openUrlInBrowser(context!!, component.link) - } - } - } - - private fun showLicense( - context: Context?, - license: License, - block: (AlertDialog.Builder) -> Unit - ): Disposable { - return if (context == null) { - Disposable.empty() - } else { - Observable.fromCallable { getFormattedLicense(context, license) } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { formattedLicense -> - val webViewData = Base64.encodeToString( - formattedLicense.toByteArray(StandardCharsets.UTF_8), Base64.NO_PADDING - ) - val webView = WebView(context) - webView.loadData(webViewData, "text/html; charset=UTF-8", "base64") - - AlertDialog.Builder(context).apply { - setTitle(license.name) - setView(webView) - Localization.assureCorrectAppLanguage(context) - block(this) - show() - } - } - } +/** + * @param context the context to use + * @param license the license + * @return String which contains a HTML formatted license page + * styled according to the context's theme + */ +fun getFormattedLicense(context: Context, license: License): String { + try { + return context.assets.open(license.filename).bufferedReader().use { it.readText() } + // split the HTML file and insert the stylesheet into the HEAD of the file + .replace("", "") + } catch (e: IOException) { + throw IllegalArgumentException("Could not get license file: ${license.filename}", e) } } + +/** + * @param context the Android context + * @return String which is a CSS stylesheet according to the context's theme + */ +fun getLicenseStylesheet(context: Context): String { + val isLightTheme = ThemeHelper.isLightThemeSelected(context) + val licenseBackgroundColor = getHexRGBColor( + context, if (isLightTheme) R.color.light_license_background_color else R.color.dark_license_background_color + ) + val licenseTextColor = getHexRGBColor( + context, if (isLightTheme) R.color.light_license_text_color else R.color.dark_license_text_color + ) + val youtubePrimaryColor = getHexRGBColor( + context, if (isLightTheme) R.color.light_youtube_primary_color else R.color.dark_youtube_primary_color + ) + return "body{padding:12px 15px;margin:0;background:#$licenseBackgroundColor;color:#$licenseTextColor}" + + "a[href]{color:#$youtubePrimaryColor}pre{white-space:pre-wrap}" +} + +/** + * Cast R.color to a hexadecimal color value. + * + * @param context the context to use + * @param color the color number from R.color + * @return a six characters long String with hexadecimal RGB values + */ +fun getHexRGBColor(context: Context, color: Int): String { + return context.getString(color).substring(3) +} diff --git a/app/src/main/java/org/schabi/newpipe/about/SoftwareComponent.kt b/app/src/main/java/org/schabi/newpipe/about/SoftwareComponent.kt index 354e8fef7..262641caa 100644 --- a/app/src/main/java/org/schabi/newpipe/about/SoftwareComponent.kt +++ b/app/src/main/java/org/schabi/newpipe/about/SoftwareComponent.kt @@ -2,6 +2,7 @@ package org.schabi.newpipe.about import android.os.Parcelable import kotlinx.parcelize.Parcelize +import java.io.Serializable @Parcelize class SoftwareComponent @@ -13,4 +14,4 @@ constructor( val link: String, val license: License, val version: String? = null -) : Parcelable +) : Parcelable, Serializable diff --git a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java index 563e80b17..04d93a238 100644 --- a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java +++ b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java @@ -1,6 +1,6 @@ package org.schabi.newpipe.database; -import static org.schabi.newpipe.database.Migrations.DB_VER_6; +import static org.schabi.newpipe.database.Migrations.DB_VER_9; import androidx.room.Database; import androidx.room.RoomDatabase; @@ -38,7 +38,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity; FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class, FeedLastUpdatedEntity.class }, - version = DB_VER_6 + version = DB_VER_9 ) public abstract class AppDatabase extends RoomDatabase { public static final String DATABASE_NAME = "newpipe.db"; diff --git a/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java b/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java index 1b8540808..255f5ba8d 100644 --- a/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java +++ b/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java @@ -3,7 +3,6 @@ package org.schabi.newpipe.database; import androidx.room.Dao; import androidx.room.Delete; import androidx.room.Insert; -import androidx.room.OnConflictStrategy; import androidx.room.Update; import java.util.Collection; @@ -14,13 +13,10 @@ import io.reactivex.rxjava3.core.Flowable; @Dao public interface BasicDAO { /* Inserts */ - @Insert(onConflict = OnConflictStrategy.ABORT) + @Insert long insert(Entity entity); - @Insert(onConflict = OnConflictStrategy.ABORT) - List insertAll(Entity... entities); - - @Insert(onConflict = OnConflictStrategy.ABORT) + @Insert List insertAll(Collection entities); /* Searches */ @@ -32,9 +28,6 @@ public interface BasicDAO { @Delete void delete(Entity entity); - @Delete - int delete(Collection entities); - int deleteAll(); /* Updates */ diff --git a/app/src/main/java/org/schabi/newpipe/database/Converters.kt b/app/src/main/java/org/schabi/newpipe/database/Converters.kt index 0eafcede1..ec097cc1b 100644 --- a/app/src/main/java/org/schabi/newpipe/database/Converters.kt +++ b/app/src/main/java/org/schabi/newpipe/database/Converters.kt @@ -7,7 +7,7 @@ import java.time.Instant import java.time.OffsetDateTime import java.time.ZoneOffset -object Converters { +class Converters { /** * Convert a long value to a [OffsetDateTime]. * @@ -47,6 +47,6 @@ object Converters { @TypeConverter fun feedGroupIconOf(id: Int): FeedGroupIcon { - return FeedGroupIcon.values().first { it.id == id } + return FeedGroupIcon.entries.first { it.id == id } } } diff --git a/app/src/main/java/org/schabi/newpipe/database/Migrations.java b/app/src/main/java/org/schabi/newpipe/database/Migrations.java index 1013899ca..fa470c2f2 100644 --- a/app/src/main/java/org/schabi/newpipe/database/Migrations.java +++ b/app/src/main/java/org/schabi/newpipe/database/Migrations.java @@ -24,6 +24,9 @@ public final class Migrations { public static final int DB_VER_4 = 4; public static final int DB_VER_5 = 5; public static final int DB_VER_6 = 6; + public static final int DB_VER_7 = 7; + public static final int DB_VER_8 = 8; + public static final int DB_VER_9 = 9; private static final String TAG = Migrations.class.getName(); public static final boolean DEBUG = MainActivity.DEBUG; @@ -190,6 +193,60 @@ public final class Migrations { }; public static final Migration MIGRATION_5_6 = new Migration(DB_VER_5, DB_VER_6) { + @Override + public void migrate(@NonNull final SupportSQLiteDatabase database) { + database.execSQL("ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` " + + "INTEGER NOT NULL DEFAULT 0"); + } + }; + + public static final Migration MIGRATION_6_7 = new Migration(DB_VER_6, DB_VER_7) { + @Override + public void migrate(@NonNull final SupportSQLiteDatabase database) { + // Create a new column thumbnail_stream_id + database.execSQL("ALTER TABLE `playlists` ADD COLUMN `thumbnail_stream_id` " + + "INTEGER NOT NULL DEFAULT -1"); + + // Migrate the thumbnail_url to the thumbnail_stream_id + database.execSQL("UPDATE playlists SET thumbnail_stream_id = (" + + " SELECT CASE WHEN COUNT(*) != 0 then stream_uid ELSE -1 END" + + " FROM (" + + " SELECT p.uid AS playlist_uid, s.uid AS stream_uid" + + " FROM playlists p" + + " LEFT JOIN playlist_stream_join ps ON p.uid = ps.playlist_id" + + " LEFT JOIN streams s ON s.uid = ps.stream_id" + + " WHERE s.thumbnail_url = p.thumbnail_url) AS temporary_table" + + " WHERE playlist_uid = playlists.uid)"); + + // Remove the thumbnail_url field in the playlist table + database.execSQL("CREATE TABLE IF NOT EXISTS `playlists_new`" + + "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " + + "name TEXT, " + + "is_thumbnail_permanent INTEGER NOT NULL, " + + "thumbnail_stream_id INTEGER NOT NULL)"); + + database.execSQL("INSERT INTO playlists_new" + + " SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id " + + " FROM playlists"); + + + database.execSQL("DROP TABLE playlists"); + database.execSQL("ALTER TABLE playlists_new RENAME TO playlists"); + database.execSQL("CREATE INDEX IF NOT EXISTS " + + "`index_playlists_name` ON `playlists` (`name`)"); + } + }; + + public static final Migration MIGRATION_7_8 = new Migration(DB_VER_7, DB_VER_8) { + @Override + public void migrate(@NonNull final SupportSQLiteDatabase database) { + database.execSQL("DELETE FROM search_history WHERE id NOT IN (SELECT id FROM (SELECT " + + "MIN(id) as id FROM search_history GROUP BY trim(search), service_id ) tmp)"); + database.execSQL("UPDATE search_history SET search = trim(search)"); + } + }; + + public static final Migration MIGRATION_8_9 = new Migration(DB_VER_8, DB_VER_9) { @Override public void migrate(@NonNull final SupportSQLiteDatabase database) { try { @@ -199,10 +256,13 @@ public final class Migrations { // Create a temp table to initialize display_index. database.execSQL("CREATE TABLE `playlists_tmp` " + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " - + "`name` TEXT, `thumbnail_url` TEXT," + + "`name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, " + + "`thumbnail_stream_id` INTEGER NOT NULL, " + "`display_index` INTEGER NOT NULL DEFAULT 0)"); - database.execSQL("INSERT INTO `playlists_tmp` (`uid`, `name`, `thumbnail_url`)" - + "SELECT `uid`, `name`, `thumbnail_url` FROM `playlists`"); + database.execSQL("INSERT INTO `playlists_tmp` " + + "(`uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`) " + + "SELECT `uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id` " + + "FROM `playlists`"); // Replace the old table. database.execSQL("DROP TABLE `playlists`"); diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt index d573788a6..e7ed93497 100644 --- a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt +++ b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt @@ -9,6 +9,7 @@ import androidx.room.Update import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Maybe import org.schabi.newpipe.database.feed.model.FeedEntity +import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity import org.schabi.newpipe.database.stream.StreamWithState import org.schabi.newpipe.database.stream.model.StreamStateEntity @@ -21,56 +22,17 @@ abstract class FeedDAO { @Query("DELETE FROM feed") abstract fun deleteAll(): Int - @Query( - """ - SELECT s.*, sst.progress_time - FROM streams s - - LEFT JOIN stream_state sst - ON s.uid = sst.stream_id - - LEFT JOIN stream_history sh - ON s.uid = sh.stream_id - - INNER JOIN feed f - ON s.uid = f.stream_id - - ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC - LIMIT 500 - """ - ) - abstract fun getAllStreams(): Maybe> - - @Query( - """ - SELECT s.*, sst.progress_time - FROM streams s - - LEFT JOIN stream_state sst - ON s.uid = sst.stream_id - - LEFT JOIN stream_history sh - ON s.uid = sh.stream_id - - INNER JOIN feed f - ON s.uid = f.stream_id - - INNER JOIN feed_group_subscription_join fgs - ON fgs.subscription_id = f.subscription_id - - WHERE fgs.group_id = :groupId - - ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC - LIMIT 500 - """ - ) - abstract fun getAllStreamsForGroup(groupId: Long): Maybe> - /** + * @param groupId the group id to get feed streams of; use + * [FeedGroupEntity.GROUP_ALL_ID] to not filter by group + * @param includePlayed if false, only return all of the live, never-played or non-finished + * feed streams (see `@see` items); if true no filter is applied + * @param uploadDateBefore get only streams uploaded before this date (useful to filter out + * future streams); use null to not filter by upload date + * @return the feed streams filtered according to the conditions provided in the parameters * @see StreamStateEntity.isFinished() * @see StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS - * @return all of the non-live, never-played and non-finished streams in the feed - * (all of the cited conditions must hold for a stream to be in the returned list) + * @see StreamStateEntity.PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS */ @Query( """ @@ -79,80 +41,82 @@ abstract class FeedDAO { LEFT JOIN stream_state sst ON s.uid = sst.stream_id - + LEFT JOIN stream_history sh - ON s.uid = sh.stream_id - + ON s.uid = sh.stream_id + INNER JOIN feed f ON s.uid = f.stream_id + LEFT JOIN feed_group_subscription_join fgs + ON ( + :groupId <> ${FeedGroupEntity.GROUP_ALL_ID} + AND fgs.subscription_id = f.subscription_id + ) + WHERE ( - sh.stream_id IS NULL + :groupId = ${FeedGroupEntity.GROUP_ALL_ID} + OR fgs.group_id = :groupId + ) + AND ( + :includePlayed + OR sh.stream_id IS NULL OR sst.stream_id IS NULL OR sst.progress_time < s.duration * 1000 - ${StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS} OR sst.progress_time < s.duration * 1000 * 3 / 4 OR s.stream_type = 'LIVE_STREAM' OR s.stream_type = 'AUDIO_LIVE_STREAM' ) + AND ( + :includePartiallyPlayed + OR sh.stream_id IS NULL + OR sst.stream_id IS NULL + OR (sst.progress_time <= ${StreamStateEntity.PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS} + AND sst.progress_time <= s.duration * 1000 / 4) + OR (sst.progress_time >= s.duration * 1000 - ${StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS} + AND sst.progress_time >= s.duration * 1000 * 3 / 4) + ) + AND ( + :uploadDateBefore IS NULL + OR s.upload_date IS NULL + OR s.upload_date < :uploadDateBefore + ) ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC LIMIT 500 """ ) - abstract fun getLiveOrNotPlayedStreams(): Maybe> + abstract fun getStreams( + groupId: Long, + includePlayed: Boolean, + includePartiallyPlayed: Boolean, + uploadDateBefore: OffsetDateTime? + ): Maybe> /** - * @see StreamStateEntity.isFinished() - * @see StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS - * @param groupId the group id to get streams of - * @return all of the non-live, never-played and non-finished streams for the given feed group - * (all of the cited conditions must hold for a stream to be in the returned list) + * Remove links to streams that are older than the given date + * **but keep at least one stream per uploader**. + * + * One stream per uploader is kept because it is needed as reference + * when fetching new streams to check if they are new or not. + * @param offsetDateTime the newest date to keep, older streams are removed */ @Query( """ - SELECT s.*, sst.progress_time - FROM streams s - - LEFT JOIN stream_state sst - ON s.uid = sst.stream_id + DELETE FROM feed + WHERE feed.stream_id IN (SELECT uid from ( + SELECT s.uid, + (SELECT MAX(upload_date) + FROM streams s1 + INNER JOIN feed f1 + ON s1.uid = f1.stream_id + WHERE f1.subscription_id = f.subscription_id) max_upload_date + FROM streams s + INNER JOIN feed f + ON s.uid = f.stream_id - LEFT JOIN stream_history sh - ON s.uid = sh.stream_id - - INNER JOIN feed f - ON s.uid = f.stream_id - - INNER JOIN feed_group_subscription_join fgs - ON fgs.subscription_id = f.subscription_id - - WHERE fgs.group_id = :groupId - AND ( - sh.stream_id IS NULL - OR sst.stream_id IS NULL - OR sst.progress_time < s.duration * 1000 - ${StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS} - OR sst.progress_time < s.duration * 1000 * 3 / 4 - OR s.stream_type = 'LIVE_STREAM' - OR s.stream_type = 'AUDIO_LIVE_STREAM' - ) - - ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC - LIMIT 500 - """ - ) - abstract fun getLiveOrNotPlayedStreamsForGroup(groupId: Long): Maybe> - - @Query( - """ - DELETE FROM feed WHERE - - feed.stream_id IN ( - SELECT s.uid FROM streams s - - INNER JOIN feed f - ON s.uid = f.stream_id - - WHERE s.upload_date < :offsetDateTime - ) + WHERE s.upload_date < :offsetDateTime + AND s.upload_date <> max_upload_date)) """ ) abstract fun unlinkStreamsOlderThan(offsetDateTime: OffsetDateTime) diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupSubscriptionEntity.kt b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupSubscriptionEntity.kt index 40f7d203b..b114a734c 100644 --- a/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupSubscriptionEntity.kt +++ b/app/src/main/java/org/schabi/newpipe/database/feed/model/FeedGroupSubscriptionEntity.kt @@ -3,7 +3,6 @@ package org.schabi.newpipe.database.feed.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey -import androidx.room.ForeignKey.CASCADE import androidx.room.Index import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.FEED_GROUP_SUBSCRIPTION_TABLE import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.GROUP_ID @@ -19,14 +18,14 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity entity = FeedGroupEntity::class, parentColumns = [FeedGroupEntity.ID], childColumns = [GROUP_ID], - onDelete = CASCADE, onUpdate = CASCADE, deferred = true + onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true ), ForeignKey( entity = SubscriptionEntity::class, parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID], childColumns = [SUBSCRIPTION_ID], - onDelete = CASCADE, onUpdate = CASCADE, deferred = true + onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true ) ] ) diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.java b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.java index ad1941adb..a9d69afe8 100644 --- a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.java +++ b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.java @@ -4,7 +4,6 @@ import androidx.annotation.NonNull; import androidx.room.ColumnInfo; import androidx.room.Entity; import androidx.room.ForeignKey; -import androidx.room.Ignore; import androidx.room.Index; import org.schabi.newpipe.database.stream.model.StreamEntity; @@ -42,18 +41,19 @@ public class StreamHistoryEntity { @ColumnInfo(name = STREAM_REPEAT_COUNT) private long repeatCount; - public StreamHistoryEntity(final long streamUid, @NonNull final OffsetDateTime accessDate, + /** + * @param streamUid the stream id this history item will refer to + * @param accessDate the last time the stream was accessed + * @param repeatCount the total number of views this stream received + */ + public StreamHistoryEntity(final long streamUid, + @NonNull final OffsetDateTime accessDate, final long repeatCount) { this.streamUid = streamUid; this.accessDate = accessDate; this.repeatCount = repeatCount; } - @Ignore - public StreamHistoryEntity(final long streamUid, @NonNull final OffsetDateTime accessDate) { - this(streamUid, accessDate, 1); - } - public long getStreamUid() { return streamUid; } diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.java new file mode 100644 index 000000000..dcd3b2b6c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.java @@ -0,0 +1,28 @@ +package org.schabi.newpipe.database.playlist; + +import androidx.room.ColumnInfo; + +/** + * This class adds a field to {@link PlaylistMetadataEntry} that contains an integer representing + * how many times a specific stream is already contained inside a local playlist. Used to be able + * to grey out playlists which already contain the current stream in the playlist append dialog. + * @see org.schabi.newpipe.local.playlist.LocalPlaylistManager#getPlaylistDuplicates(String) + */ +public class PlaylistDuplicatesEntry extends PlaylistMetadataEntry { + public static final String PLAYLIST_TIMES_STREAM_IS_CONTAINED = "timesStreamIsContained"; + @ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED) + public final long timesStreamIsContained; + + public PlaylistDuplicatesEntry(final long uid, + final String name, + final String thumbnailUrl, + final boolean isThumbnailPermanent, + final long thumbnailStreamId, + final long displayIndex, + final long streamCount, + final long timesStreamIsContained) { + super(uid, name, thumbnailUrl, isThumbnailPermanent, thumbnailStreamId, displayIndex, + streamCount); + this.timesStreamIsContained = timesStreamIsContained; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java index 4314b0f82..efd7120d3 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java @@ -28,7 +28,6 @@ public interface PlaylistLocalItem extends LocalItem { static List merge( final List localPlaylists, final List remotePlaylists) { - Collections.sort(localPlaylists, Comparator.comparingLong(PlaylistMetadataEntry::getDisplayIndex)); Collections.sort(remotePlaylists, diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java index f1ead0fa4..03a1e1e30 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java @@ -5,6 +5,8 @@ import androidx.room.ColumnInfo; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_DISPLAY_INDEX; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME; +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT; +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL; public class PlaylistMetadataEntry implements PlaylistLocalItem { @@ -14,6 +16,10 @@ public class PlaylistMetadataEntry implements PlaylistLocalItem { private final long uid; @ColumnInfo(name = PLAYLIST_NAME) public final String name; + @ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT) + private final boolean isThumbnailPermanent; + @ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID) + private final long thumbnailStreamId; @ColumnInfo(name = PLAYLIST_THUMBNAIL_URL) public final String thumbnailUrl; @ColumnInfo(name = PLAYLIST_DISPLAY_INDEX) @@ -22,10 +28,13 @@ public class PlaylistMetadataEntry implements PlaylistLocalItem { public final long streamCount; public PlaylistMetadataEntry(final long uid, final String name, final String thumbnailUrl, + final boolean isThumbnailPermanent, final long thumbnailStreamId, final long displayIndex, final long streamCount) { this.uid = uid; this.name = name; this.thumbnailUrl = thumbnailUrl; + this.isThumbnailPermanent = isThumbnailPermanent; + this.thumbnailStreamId = thumbnailStreamId; this.displayIndex = displayIndex; this.streamCount = streamCount; } @@ -40,6 +49,14 @@ public class PlaylistMetadataEntry implements PlaylistLocalItem { return name; } + public boolean isThumbnailPermanent() { + return isThumbnailPermanent; + } + + public long getThumbnailStreamId() { + return thumbnailStreamId; + } + @Override public long getDisplayIndex() { return displayIndex; diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt index d2543ae6d..1d74c6d31 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt @@ -7,6 +7,7 @@ import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.stream.model.StreamStateEntity import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.util.image.ImageStrategy data class PlaylistStreamEntry( @Embedded @@ -28,7 +29,7 @@ data class PlaylistStreamEntry( item.duration = streamEntity.duration item.uploaderName = streamEntity.uploader item.uploaderUrl = streamEntity.uploaderUrl - item.thumbnailUrl = streamEntity.thumbnailUrl + item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) return item } diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java index 0fce984f3..d795e6ea7 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java @@ -6,19 +6,25 @@ import androidx.room.RewriteQueriesToDropUnusedColumns; import androidx.room.Transaction; import org.schabi.newpipe.database.BasicDAO; +import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry; import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; +import org.schabi.newpipe.database.playlist.model.PlaylistEntity; import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity; import java.util.List; import io.reactivex.rxjava3.core.Flowable; +import static org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry.PLAYLIST_TIMES_STREAM_IS_CONTAINED; import static org.schabi.newpipe.database.playlist.PlaylistMetadataEntry.PLAYLIST_STREAM_COUNT; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_DISPLAY_INDEX; +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.DEFAULT_THUMBNAIL; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE; +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT; +import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL; import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_INDEX; import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_PLAYLIST_ID; @@ -26,6 +32,8 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JO import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE; import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID; import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE; +import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_THUMBNAIL_URL; +import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_URL; import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS; import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS; import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; @@ -54,6 +62,16 @@ public interface PlaylistStreamDAO extends BasicDAO { + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId") Flowable getMaximumIndexOf(long playlistId); + @Query("SELECT CASE WHEN COUNT(*) != 0 then " + STREAM_ID + + " ELSE " + PlaylistEntity.DEFAULT_THUMBNAIL_ID + " END" + + " FROM " + STREAM_TABLE + + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE + + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID + + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId " + + " LIMIT 1" + ) + Flowable getAutomaticThumbnailStreamId(long playlistId); + @RewriteQueriesToDropUnusedColumns @Transaction @Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN " @@ -75,26 +93,87 @@ public interface PlaylistStreamDAO extends BasicDAO { Flowable> getOrderedStreamsOf(long playlistId); @Transaction - @Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", " + PLAYLIST_THUMBNAIL_URL + ", " + @Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", " + + PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", " + PLAYLIST_DISPLAY_INDEX + ", " - + "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT + + " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = " + + PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'" + + " ELSE (SELECT " + STREAM_THUMBNAIL_URL + + " FROM " + STREAM_TABLE + + " WHERE " + STREAM_TABLE + "." + STREAM_ID + " = " + PLAYLIST_THUMBNAIL_STREAM_ID + + " ) END AS " + PLAYLIST_THUMBNAIL_URL + ", " + + + "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT + " FROM " + PLAYLIST_TABLE + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE - + " ON " + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID + + " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID + " GROUP BY " + PLAYLIST_ID + " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC") Flowable> getPlaylistMetadata(); @Transaction - @Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", " + PLAYLIST_THUMBNAIL_URL + ", " + @Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", " + + PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", " + PLAYLIST_DISPLAY_INDEX + ", " - + "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT + + " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = " + + PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'" + + " ELSE (SELECT " + STREAM_THUMBNAIL_URL + + " FROM " + STREAM_TABLE + + " WHERE " + STREAM_TABLE + "." + STREAM_ID + " = " + PLAYLIST_THUMBNAIL_STREAM_ID + + " ) END AS " + PLAYLIST_THUMBNAIL_URL + ", " + + + "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT + " FROM " + PLAYLIST_TABLE + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE - + " ON " + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID + + " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID + " GROUP BY " + PLAYLIST_ID + " ORDER BY " + PLAYLIST_DISPLAY_INDEX) Flowable> getDisplayIndexOrderedPlaylistMetadata(); + + @RewriteQueriesToDropUnusedColumns + @Transaction + @Query("SELECT *, MIN(" + JOIN_INDEX + ")" + + " FROM " + STREAM_TABLE + " INNER JOIN" + + " (SELECT " + JOIN_STREAM_ID + "," + JOIN_INDEX + + " FROM " + PLAYLIST_STREAM_JOIN_TABLE + + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId)" + + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID + + " LEFT JOIN " + + "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", " + + STREAM_PROGRESS_MILLIS + + " FROM " + STREAM_STATE_TABLE + " )" + + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS + + " GROUP BY " + STREAM_ID + + " ORDER BY MIN(" + JOIN_INDEX + ") ASC") + Flowable> getStreamsWithoutDuplicates(long playlistId); + + @Transaction + @Query("SELECT " + PLAYLIST_TABLE + "." + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", " + + PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", " + + PLAYLIST_DISPLAY_INDEX + ", " + + + " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = " + + PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'" + + " ELSE (SELECT " + STREAM_THUMBNAIL_URL + + " FROM " + STREAM_TABLE + + " WHERE " + STREAM_TABLE + "." + STREAM_ID + " = " + PLAYLIST_THUMBNAIL_STREAM_ID + + " ) END AS " + PLAYLIST_THUMBNAIL_URL + ", " + + + "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT + ", " + + "COALESCE(SUM(" + STREAM_URL + " = :streamUrl), 0) AS " + + PLAYLIST_TIMES_STREAM_IS_CONTAINED + + + " FROM " + PLAYLIST_TABLE + + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE + + " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID + + + " LEFT JOIN " + STREAM_TABLE + + " ON " + STREAM_TABLE + "." + STREAM_ID + " = " + JOIN_STREAM_ID + + " AND :streamUrl = :streamUrl" + + + " GROUP BY " + JOIN_PLAYLIST_ID + + " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC") + Flowable> getPlaylistDuplicatesMetadata(String streamUrl); } diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java index 508b55508..cb18027d0 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java @@ -9,16 +9,24 @@ import androidx.room.PrimaryKey; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE; +import org.schabi.newpipe.R; import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; @Entity(tableName = PLAYLIST_TABLE, indices = {@Index(value = {PLAYLIST_NAME})}) public class PlaylistEntity { + + public static final String DEFAULT_THUMBNAIL = "drawable://" + + R.drawable.placeholder_thumbnail_playlist; + public static final long DEFAULT_THUMBNAIL_ID = -1; + public static final String PLAYLIST_TABLE = "playlists"; public static final String PLAYLIST_ID = "uid"; public static final String PLAYLIST_NAME = "name"; public static final String PLAYLIST_THUMBNAIL_URL = "thumbnail_url"; public static final String PLAYLIST_DISPLAY_INDEX = "display_index"; + public static final String PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent"; + public static final String PLAYLIST_THUMBNAIL_STREAM_ID = "thumbnail_stream_id"; @PrimaryKey(autoGenerate = true) @ColumnInfo(name = PLAYLIST_ID) @@ -27,15 +35,20 @@ public class PlaylistEntity { @ColumnInfo(name = PLAYLIST_NAME) private String name; - @ColumnInfo(name = PLAYLIST_THUMBNAIL_URL) - private String thumbnailUrl; + @ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT) + private boolean isThumbnailPermanent; + + @ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID) + private long thumbnailStreamId; @ColumnInfo(name = PLAYLIST_DISPLAY_INDEX) private long displayIndex; - public PlaylistEntity(final String name, final String thumbnailUrl, final long displayIndex) { + public PlaylistEntity(final String name, final boolean isThumbnailPermanent, + final long thumbnailStreamId, final long displayIndex) { this.name = name; - this.thumbnailUrl = thumbnailUrl; + this.isThumbnailPermanent = isThumbnailPermanent; + this.thumbnailStreamId = thumbnailStreamId; this.displayIndex = displayIndex; } @@ -43,7 +56,8 @@ public class PlaylistEntity { public PlaylistEntity(final PlaylistMetadataEntry item) { this.uid = item.getUid(); this.name = item.name; - this.thumbnailUrl = item.thumbnailUrl; + this.isThumbnailPermanent = item.isThumbnailPermanent(); + this.thumbnailStreamId = item.getThumbnailStreamId(); this.displayIndex = item.getDisplayIndex(); } @@ -63,12 +77,20 @@ public class PlaylistEntity { this.name = name; } - public String getThumbnailUrl() { - return thumbnailUrl; + public long getThumbnailStreamId() { + return thumbnailStreamId; } - public void setThumbnailUrl(final String thumbnailUrl) { - this.thumbnailUrl = thumbnailUrl; + public void setThumbnailStreamId(final long thumbnailStreamId) { + this.thumbnailStreamId = thumbnailStreamId; + } + + public boolean getIsThumbnailPermanent() { + return isThumbnailPermanent; + } + + public void setIsThumbnailPermanent(final boolean isThumbnailSet) { + this.isThumbnailPermanent = isThumbnailSet; } public long getDisplayIndex() { diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java index 82baed82c..50c3899f1 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java @@ -11,6 +11,7 @@ import androidx.room.PrimaryKey; import org.schabi.newpipe.database.playlist.PlaylistLocalItem; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.util.Constants; +import org.schabi.newpipe.util.image.ImageStrategy; import static org.schabi.newpipe.database.LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM; import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_NAME; @@ -86,8 +87,9 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem { @Ignore public PlaylistRemoteEntity(final PlaylistInfo info) { this(info.getServiceId(), info.getName(), info.getUrl(), - info.getThumbnailUrl() == null - ? info.getUploaderAvatarUrl() : info.getThumbnailUrl(), + // use uploader avatar when no thumbnail is available + ImageStrategy.imageListToDbUrl(info.getThumbnails().isEmpty() + ? info.getUploaderAvatars() : info.getThumbnails()), info.getUploaderName(), info.getStreamCount()); } @@ -101,7 +103,10 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem { && getStreamCount() == info.getStreamCount() && TextUtils.equals(getName(), info.getName()) && TextUtils.equals(getUrl(), info.getUrl()) - && TextUtils.equals(getThumbnailUrl(), info.getThumbnailUrl()) + // we want to update the local playlist data even when either the remote thumbnail + // URL changes, or the preferred image quality setting is changed by the user + && TextUtils.equals(getThumbnailUrl(), + ImageStrategy.imageListToDbUrl(info.getThumbnails())) && TextUtils.equals(getUploader(), info.getUploaderName()); } diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt index dc0db59d8..1f3654e7a 100644 --- a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt +++ b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt @@ -7,6 +7,7 @@ import org.schabi.newpipe.database.history.model.StreamHistoryEntity import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.util.image.ImageStrategy import java.time.OffsetDateTime class StreamStatisticsEntry( @@ -30,7 +31,7 @@ class StreamStatisticsEntry( item.duration = streamEntity.duration item.uploaderName = streamEntity.uploader item.uploaderUrl = streamEntity.uploaderUrl - item.thumbnailUrl = streamEntity.thumbnailUrl + item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) return item } diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt index a22fd2bb9..d8c19c1e9 100644 --- a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt +++ b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt @@ -12,8 +12,7 @@ import org.schabi.newpipe.database.BasicDAO import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID import org.schabi.newpipe.extractor.stream.StreamType -import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM -import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM +import org.schabi.newpipe.util.StreamTypeUtil import java.time.OffsetDateTime @Dao @@ -91,8 +90,7 @@ abstract class StreamDAO : BasicDAO { ?: throw IllegalStateException("Stream cannot be null just after insertion.") newerStream.uid = existentMinimalStream.uid - val isNewerStreamLive = newerStream.streamType == AUDIO_LIVE_STREAM || newerStream.streamType == LIVE_STREAM - if (!isNewerStreamLive) { + if (!StreamTypeUtil.isLiveStream(newerStream.streamType)) { // Use the existent upload date if the newer stream does not have a better precision // (i.e. is an approximation). This is done to prevent unnecessary changes. diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.kt b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.kt index c56f91949..d9c160b89 100644 --- a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.kt +++ b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamEntity.kt @@ -13,6 +13,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfo import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.player.playqueue.PlayQueueItem +import org.schabi.newpipe.util.image.ImageStrategy import java.io.Serializable import java.time.OffsetDateTime @@ -67,7 +68,8 @@ data class StreamEntity( constructor(item: StreamInfoItem) : this( serviceId = item.serviceId, url = item.url, title = item.name, streamType = item.streamType, duration = item.duration, uploader = item.uploaderName, - uploaderUrl = item.uploaderUrl, thumbnailUrl = item.thumbnailUrl, viewCount = item.viewCount, + uploaderUrl = item.uploaderUrl, + thumbnailUrl = ImageStrategy.imageListToDbUrl(item.thumbnails), viewCount = item.viewCount, textualUploadDate = item.textualUploadDate, uploadDate = item.uploadDate?.offsetDateTime(), isUploadDateApproximation = item.uploadDate?.isApproximation ) @@ -76,7 +78,8 @@ data class StreamEntity( constructor(info: StreamInfo) : this( serviceId = info.serviceId, url = info.url, title = info.name, streamType = info.streamType, duration = info.duration, uploader = info.uploaderName, - uploaderUrl = info.uploaderUrl, thumbnailUrl = info.thumbnailUrl, viewCount = info.viewCount, + uploaderUrl = info.uploaderUrl, + thumbnailUrl = ImageStrategy.imageListToDbUrl(info.thumbnails), viewCount = info.viewCount, textualUploadDate = info.textualUploadDate, uploadDate = info.uploadDate?.offsetDateTime(), isUploadDateApproximation = info.uploadDate?.isApproximation ) @@ -85,7 +88,8 @@ data class StreamEntity( constructor(item: PlayQueueItem) : this( serviceId = item.serviceId, url = item.url, title = item.title, streamType = item.streamType, duration = item.duration, uploader = item.uploader, - uploaderUrl = item.uploaderUrl, thumbnailUrl = item.thumbnailUrl + uploaderUrl = item.uploaderUrl, + thumbnailUrl = ImageStrategy.imageListToDbUrl(item.thumbnails) ) fun toStreamInfoItem(): StreamInfoItem { @@ -93,7 +97,7 @@ data class StreamEntity( item.duration = duration item.uploaderName = uploader item.uploaderUrl = uploaderUrl - item.thumbnailUrl = thumbnailUrl + item.thumbnails = ImageStrategy.dbUrlToImageList(thumbnailUrl) if (viewCount != null) item.viewCount = viewCount as Long item.textualUploadDate = textualUploadDate diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java index 75766850f..627acea45 100644 --- a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java +++ b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java @@ -30,7 +30,7 @@ public class StreamStateEntity { /** * Playback state will not be saved, if playback time is less than this threshold (5000ms = 5s). */ - private static final long PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS = 5000; + public static final long PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS = 5000; /** * Stream will be considered finished if the playback time left exceeds this threshold diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java index 0e4bda490..a61a22a84 100644 --- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java +++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java @@ -10,6 +10,7 @@ import androidx.room.PrimaryKey; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.util.Constants; +import org.schabi.newpipe.util.image.ImageStrategy; import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID; import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE; @@ -57,8 +58,8 @@ public class SubscriptionEntity { final SubscriptionEntity result = new SubscriptionEntity(); result.setServiceId(info.getServiceId()); result.setUrl(info.getUrl()); - result.setData(info.getName(), info.getAvatarUrl(), info.getDescription(), - info.getSubscriberCount()); + result.setData(info.getName(), ImageStrategy.imageListToDbUrl(info.getAvatars()), + info.getDescription(), info.getSubscriberCount()); return result; } @@ -138,7 +139,7 @@ public class SubscriptionEntity { @Ignore public ChannelInfoItem toChannelInfoItem() { final ChannelInfoItem item = new ChannelInfoItem(getServiceId(), getUrl(), getName()); - item.setThumbnailUrl(getAvatarUrl()); + item.setThumbnails(ImageStrategy.dbUrlToImageList(getAvatarUrl())); item.setSubscriberCount(getSubscriberCount()); item.setDescription(getDescription()); return item; diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java index f5c226908..bbdb46292 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -1,10 +1,12 @@ package org.schabi.newpipe.download; +import static org.schabi.newpipe.extractor.stream.DeliveryMethod.PROGRESSIVE_HTTP; +import static org.schabi.newpipe.util.ListHelper.getStreamsOfSpecifiedDelivery; +import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; + import android.app.Activity; import android.content.ComponentName; import android.content.Context; -import android.content.DialogInterface; -import android.content.DialogInterface.OnDismissListener; import android.content.Intent; import android.content.ServiceConnection; import android.content.SharedPreferences; @@ -12,8 +14,8 @@ import android.net.Uri; import android.os.Bundle; import android.os.Environment; import android.os.IBinder; +import android.provider.Settings; import android.util.Log; -import android.util.SparseArray; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -32,6 +34,7 @@ import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.view.menu.ActionMenuItemView; import androidx.appcompat.widget.Toolbar; +import androidx.collection.SparseArrayCompat; import androidx.documentfile.provider.DocumentFile; import androidx.fragment.app.DialogFragment; import androidx.preference.PreferenceManager; @@ -63,7 +66,9 @@ import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.SecondaryStreamHelper; import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener; import org.schabi.newpipe.util.StreamItemAdapter; -import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper; +import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper; +import org.schabi.newpipe.util.AudioTrackAdapter; +import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper; import org.schabi.newpipe.util.ThemeHelper; import java.io.File; @@ -71,6 +76,8 @@ import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.Locale; +import java.util.Objects; +import java.util.Optional; import icepick.Icepick; import icepick.State; @@ -82,8 +89,6 @@ import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder; import us.shandian.giga.service.MissionState; -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheckedChangeListener, AdapterView.OnItemSelectedListener { private static final String TAG = "DialogFragment"; @@ -92,28 +97,28 @@ public class DownloadDialog extends DialogFragment @State StreamInfo currentInfo; @State - StreamSizeWrapper wrappedAudioStreams = StreamSizeWrapper.empty(); + StreamInfoWrapper wrappedVideoStreams; @State - StreamSizeWrapper wrappedVideoStreams = StreamSizeWrapper.empty(); + StreamInfoWrapper wrappedSubtitleStreams; @State - StreamSizeWrapper wrappedSubtitleStreams = StreamSizeWrapper.empty(); + AudioTracksWrapper wrappedAudioTracks; @State - int selectedVideoIndex = 0; + int selectedAudioTrackIndex; @State - int selectedAudioIndex = 0; + int selectedVideoIndex; // set in the constructor @State - int selectedSubtitleIndex = 0; - - @Nullable - private OnDismissListener onDismissListener = null; + int selectedAudioIndex = 0; // default to the first item + @State + int selectedSubtitleIndex = 0; // default to the first item private StoredDirectoryHelper mainStorageAudio = null; private StoredDirectoryHelper mainStorageVideo = null; private DownloadManager downloadManager = null; private ActionMenuItemView okButton = null; - private Context context; + private Context context = null; private boolean askForSavePath; + private AudioTrackAdapter audioTrackAdapter; private StreamItemAdapter audioStreamsAdapter; private StreamItemAdapter videoStreamsAdapter; private StreamItemAdapter subtitleStreamsAdapter; @@ -138,81 +143,53 @@ public class DownloadDialog extends DialogFragment registerForActivityResult( new StartActivityForResult(), this::requestDownloadPickVideoFolderResult); - /*////////////////////////////////////////////////////////////////////////// // Instance creation //////////////////////////////////////////////////////////////////////////*/ - public static DownloadDialog newInstance(final StreamInfo info) { - final DownloadDialog dialog = new DownloadDialog(); - dialog.setInfo(info); - return dialog; + public DownloadDialog() { + // Just an empty default no-arg ctor to keep Fragment.instantiate() happy + // otherwise InstantiationException will be thrown when fragment is recreated + // TODO: Maybe use a custom FragmentFactory instead? } - public static DownloadDialog newInstance(final Context context, final StreamInfo info) { - final ArrayList streamsList = new ArrayList<>(ListHelper - .getSortedStreamVideosList(context, info.getVideoStreams(), - info.getVideoOnlyStreams(), false, false)); - final int selectedStreamIndex = ListHelper.getDefaultResolutionIndex(context, streamsList); - - final DownloadDialog instance = newInstance(info); - instance.setVideoStreams(streamsList); - instance.setSelectedVideoStream(selectedStreamIndex); - instance.setAudioStreams(info.getAudioStreams()); - instance.setSubtitleStreams(info.getSubtitles()); - - return instance; - } - - - /*////////////////////////////////////////////////////////////////////////// - // Setters - //////////////////////////////////////////////////////////////////////////*/ - - private void setInfo(final StreamInfo info) { + /** + * Create a new download dialog with the video, audio and subtitle streams from the provided + * stream info. Video streams and video-only streams will be put into a single list menu, + * sorted according to their resolution and the default video resolution will be selected. + * + * @param context the context to use just to obtain preferences and strings (will not be stored) + * @param info the info from which to obtain downloadable streams and other info (e.g. title) + */ + public DownloadDialog(@NonNull final Context context, @NonNull final StreamInfo info) { this.currentInfo = info; + + final List audioStreams = + getStreamsOfSpecifiedDelivery(info.getAudioStreams(), PROGRESSIVE_HTTP); + final List> groupedAudioStreams = + ListHelper.getGroupedAudioStreams(context, audioStreams); + this.wrappedAudioTracks = new AudioTracksWrapper(groupedAudioStreams, context); + this.selectedAudioTrackIndex = + ListHelper.getDefaultAudioTrackGroup(context, groupedAudioStreams); + + // TODO: Adapt this code when the downloader support other types of stream deliveries + final List videoStreams = ListHelper.getSortedStreamVideosList( + context, + getStreamsOfSpecifiedDelivery(info.getVideoStreams(), PROGRESSIVE_HTTP), + getStreamsOfSpecifiedDelivery(info.getVideoOnlyStreams(), PROGRESSIVE_HTTP), + false, + // If there are multiple languages available, prefer streams without audio + // to allow language selection + wrappedAudioTracks.size() > 1 + ); + + this.wrappedVideoStreams = new StreamInfoWrapper<>(videoStreams, context); + this.wrappedSubtitleStreams = new StreamInfoWrapper<>( + getStreamsOfSpecifiedDelivery(info.getSubtitles(), PROGRESSIVE_HTTP), context); + + this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams); } - public void setAudioStreams(final List audioStreams) { - setAudioStreams(new StreamSizeWrapper<>(audioStreams, getContext())); - } - - public void setAudioStreams(final StreamSizeWrapper was) { - this.wrappedAudioStreams = was; - } - - public void setVideoStreams(final List videoStreams) { - setVideoStreams(new StreamSizeWrapper<>(videoStreams, getContext())); - } - - public void setVideoStreams(final StreamSizeWrapper wvs) { - this.wrappedVideoStreams = wvs; - } - - public void setSubtitleStreams(final List subtitleStreams) { - setSubtitleStreams(new StreamSizeWrapper<>(subtitleStreams, getContext())); - } - - public void setSubtitleStreams( - final StreamSizeWrapper wss) { - this.wrappedSubtitleStreams = wss; - } - - public void setSelectedVideoStream(final int svi) { - this.selectedVideoIndex = svi; - } - - public void setSelectedAudioStream(final int sai) { - this.selectedAudioIndex = sai; - } - - public void setSelectedSubtitleStream(final int ssi) { - this.selectedSubtitleIndex = ssi; - } - - public void setOnDismissListener(@Nullable final OnDismissListener onDismissListener) { - this.onDismissListener = onDismissListener; - } /*////////////////////////////////////////////////////////////////////////// // Android lifecycle @@ -232,35 +209,16 @@ public class DownloadDialog extends DialogFragment return; } + // context will remain null if dismiss() was called above, allowing to check whether the + // dialog is being dismissed in onViewCreated() context = getContext(); setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(context)); Icepick.restoreInstanceState(this, savedInstanceState); - final SparseArray> secondaryStreams - = new SparseArray<>(4); - final List videoStreams = wrappedVideoStreams.getStreamsList(); - - for (int i = 0; i < videoStreams.size(); i++) { - if (!videoStreams.get(i).isVideoOnly()) { - continue; - } - final AudioStream audioStream = SecondaryStreamHelper - .getAudioStreamFor(wrappedAudioStreams.getStreamsList(), videoStreams.get(i)); - - if (audioStream != null) { - secondaryStreams - .append(i, new SecondaryStreamHelper<>(wrappedAudioStreams, audioStream)); - } else if (DEBUG) { - Log.w(TAG, "No audio stream candidates for video format " - + videoStreams.get(i).getFormat().name()); - } - } - - this.videoStreamsAdapter = new StreamItemAdapter<>(context, wrappedVideoStreams, - secondaryStreams); - this.audioStreamsAdapter = new StreamItemAdapter<>(context, wrappedAudioStreams); - this.subtitleStreamsAdapter = new StreamItemAdapter<>(context, wrappedSubtitleStreams); + this.audioTrackAdapter = new AudioTrackAdapter(wrappedAudioTracks); + this.subtitleStreamsAdapter = new StreamItemAdapter<>(wrappedSubtitleStreams); + updateSecondaryStreams(); final Intent intent = new Intent(context, DownloadManagerService.class); context.startService(intent); @@ -287,8 +245,42 @@ public class DownloadDialog extends DialogFragment }, Context.BIND_AUTO_CREATE); } + /** + * Update the displayed video streams based on the selected audio track. + */ + private void updateSecondaryStreams() { + final StreamInfoWrapper audioStreams = getWrappedAudioStreams(); + final var secondaryStreams = new SparseArrayCompat>(4); + final List videoStreams = wrappedVideoStreams.getStreamsList(); + wrappedVideoStreams.resetInfo(); + + for (int i = 0; i < videoStreams.size(); i++) { + if (!videoStreams.get(i).isVideoOnly()) { + continue; + } + final AudioStream audioStream = SecondaryStreamHelper.getAudioStreamFor( + context, audioStreams.getStreamsList(), videoStreams.get(i)); + + if (audioStream != null) { + secondaryStreams.append(i, new SecondaryStreamHelper<>(audioStreams, audioStream)); + } else if (DEBUG) { + final MediaFormat mediaFormat = videoStreams.get(i).getFormat(); + if (mediaFormat != null) { + Log.w(TAG, "No audio stream candidates for video format " + + mediaFormat.name()); + } else { + Log.w(TAG, "No audio stream candidates for unknown video format"); + } + } + } + + this.videoStreamsAdapter = new StreamItemAdapter<>(wrappedVideoStreams, secondaryStreams); + this.audioStreamsAdapter = new StreamItemAdapter<>(audioStreams); + } + @Override - public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, + public View onCreateView(@NonNull final LayoutInflater inflater, + final ViewGroup container, final Bundle savedInstanceState) { if (DEBUG) { Log.d(TAG, "onCreateView() called with: " @@ -299,19 +291,24 @@ public class DownloadDialog extends DialogFragment } @Override - public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + public void onViewCreated(@NonNull final View view, + @Nullable final Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); dialogBinding = DownloadDialogBinding.bind(view); + if (context == null) { + return; // the dialog is being dismissed, see the call to dismiss() in onCreate() + } dialogBinding.fileName.setText(FilenameUtils.createFilename(getContext(), currentInfo.getName())); - selectedAudioIndex = ListHelper - .getDefaultAudioFormat(getContext(), currentInfo.getAudioStreams()); + selectedAudioIndex = ListHelper.getDefaultAudioFormat(getContext(), + getWrappedAudioStreams().getStreamsList()); selectedSubtitleIndex = getSubtitleIndexBy(subtitleStreamsAdapter.getAll()); dialogBinding.qualitySpinner.setOnItemSelectedListener(this); - + dialogBinding.audioStreamSpinner.setOnItemSelectedListener(this); + dialogBinding.audioTrackSpinner.setOnItemSelectedListener(this); dialogBinding.videoAudioGroup.setOnCheckedChangeListener(this); initToolbar(dialogBinding.toolbarLayout.toolbar); @@ -324,7 +321,8 @@ public class DownloadDialog extends DialogFragment dialogBinding.threads.setProgress(threads - 1); dialogBinding.threads.setOnSeekBarChangeListener(new SimpleOnSeekBarChangeListener() { @Override - public void onProgressChanged(@NonNull final SeekBar seekbar, final int progress, + public void onProgressChanged(@NonNull final SeekBar seekbar, + final int progress, final boolean fromUser) { final int newProgress = progress + 1; prefs.edit().putInt(getString(R.string.default_download_threads), newProgress) @@ -359,14 +357,6 @@ public class DownloadDialog extends DialogFragment }); } - @Override - public void onDismiss(@NonNull final DialogInterface dialog) { - super.onDismiss(dialog); - if (onDismissListener != null) { - onDismissListener.onDismiss(dialog); - } - } - @Override public void onDestroy() { super.onDestroy(); @@ -392,7 +382,7 @@ public class DownloadDialog extends DialogFragment private void fetchStreamsSize() { disposables.clear(); - disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedVideoStreams) + disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedVideoStreams) .subscribe(result -> { if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.video_button) { @@ -402,7 +392,7 @@ public class DownloadDialog extends DialogFragment new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, "Downloading video stream size", currentInfo.getServiceId())))); - disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedAudioStreams) + disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(getWrappedAudioStreams()) .subscribe(result -> { if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) { @@ -412,7 +402,7 @@ public class DownloadDialog extends DialogFragment new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, "Downloading audio stream size", currentInfo.getServiceId())))); - disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedSubtitleStreams) + disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedSubtitleStreams) .subscribe(result -> { if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.subtitle_button) { @@ -424,14 +414,28 @@ public class DownloadDialog extends DialogFragment currentInfo.getServiceId())))); } + private void setupAudioTrackSpinner() { + if (getContext() == null) { + return; + } + + dialogBinding.audioTrackSpinner.setAdapter(audioTrackAdapter); + dialogBinding.audioTrackSpinner.setSelection(selectedAudioTrackIndex); + } + private void setupAudioSpinner() { if (getContext() == null) { return; } - dialogBinding.qualitySpinner.setAdapter(audioStreamsAdapter); - dialogBinding.qualitySpinner.setSelection(selectedAudioIndex); + dialogBinding.qualitySpinner.setVisibility(View.GONE); setRadioButtonsState(true); + dialogBinding.audioStreamSpinner.setAdapter(audioStreamsAdapter); + dialogBinding.audioStreamSpinner.setSelection(selectedAudioIndex); + dialogBinding.audioStreamSpinner.setVisibility(View.VISIBLE); + dialogBinding.audioTrackSpinner.setVisibility( + wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE); + dialogBinding.audioTrackPresentInVideoText.setVisibility(View.GONE); } private void setupVideoSpinner() { @@ -441,7 +445,19 @@ public class DownloadDialog extends DialogFragment dialogBinding.qualitySpinner.setAdapter(videoStreamsAdapter); dialogBinding.qualitySpinner.setSelection(selectedVideoIndex); + dialogBinding.qualitySpinner.setVisibility(View.VISIBLE); setRadioButtonsState(true); + dialogBinding.audioStreamSpinner.setVisibility(View.GONE); + onVideoStreamSelected(); + } + + private void onVideoStreamSelected() { + final boolean isVideoOnly = videoStreamsAdapter.getItem(selectedVideoIndex).isVideoOnly(); + + dialogBinding.audioTrackSpinner.setVisibility( + isVideoOnly && wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE); + dialogBinding.audioTrackPresentInVideoText.setVisibility( + !isVideoOnly && wrappedAudioTracks.size() > 1 ? View.VISIBLE : View.GONE); } private void setupSubtitleSpinner() { @@ -451,7 +467,11 @@ public class DownloadDialog extends DialogFragment dialogBinding.qualitySpinner.setAdapter(subtitleStreamsAdapter); dialogBinding.qualitySpinner.setSelection(selectedSubtitleIndex); + dialogBinding.qualitySpinner.setVisibility(View.VISIBLE); setRadioButtonsState(true); + dialogBinding.audioStreamSpinner.setVisibility(View.GONE); + dialogBinding.audioTrackSpinner.setVisibility(View.GONE); + dialogBinding.audioTrackPresentInVideoText.setVisibility(View.GONE); } @@ -469,7 +489,7 @@ public class DownloadDialog extends DialogFragment result, getString(R.string.download_path_video_key), DownloadManager.TAG_VIDEO); } - private void requestDownloadSaveAsResult(final ActivityResult result) { + private void requestDownloadSaveAsResult(@NonNull final ActivityResult result) { if (result.getResultCode() != Activity.RESULT_OK) { return; } @@ -486,8 +506,8 @@ public class DownloadDialog extends DialogFragment return; } - final DocumentFile docFile - = DocumentFile.fromSingleUri(context, result.getData().getData()); + final DocumentFile docFile = DocumentFile.fromSingleUri(context, + result.getData().getData()); if (docFile == null) { showFailedDialog(R.string.general_error); return; @@ -498,7 +518,7 @@ public class DownloadDialog extends DialogFragment docFile.getType()); } - private void requestDownloadPickFolderResult(final ActivityResult result, + private void requestDownloadPickFolderResult(@NonNull final ActivityResult result, final String key, final String tag) { if (result.getResultCode() != Activity.RESULT_OK) { @@ -518,12 +538,11 @@ public class DownloadDialog extends DialogFragment StoredDirectoryHelper.PERMISSION_FLAGS); } - PreferenceManager.getDefaultSharedPreferences(context).edit() - .putString(key, uri.toString()).apply(); + PreferenceManager.getDefaultSharedPreferences(context).edit().putString(key, + uri.toString()).apply(); try { - final StoredDirectoryHelper mainStorage - = new StoredDirectoryHelper(context, uri, tag); + final StoredDirectoryHelper mainStorage = new StoredDirectoryHelper(context, uri, tag); checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp, mimeTmp); } catch (final IOException e) { @@ -531,7 +550,6 @@ public class DownloadDialog extends DialogFragment } } - /*////////////////////////////////////////////////////////////////////////// // Listeners //////////////////////////////////////////////////////////////////////////*/ @@ -561,23 +579,71 @@ public class DownloadDialog extends DialogFragment } @Override - public void onItemSelected(final AdapterView parent, final View view, - final int position, final long id) { + public void onItemSelected(final AdapterView parent, + final View view, + final int position, + final long id) { if (DEBUG) { Log.d(TAG, "onItemSelected() called with: " + "parent = [" + parent + "], view = [" + view + "], " + "position = [" + position + "], id = [" + id + "]"); } - switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) { - case R.id.audio_button: + + switch (parent.getId()) { + case R.id.quality_spinner: + switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) { + case R.id.video_button: + selectedVideoIndex = position; + onVideoStreamSelected(); + break; + case R.id.subtitle_button: + selectedSubtitleIndex = position; + break; + } + onItemSelectedSetFileName(); + break; + case R.id.audio_track_spinner: + final boolean trackChanged = selectedAudioTrackIndex != position; + selectedAudioTrackIndex = position; + if (trackChanged) { + updateSecondaryStreams(); + fetchStreamsSize(); + } + break; + case R.id.audio_stream_spinner: selectedAudioIndex = position; - break; - case R.id.video_button: - selectedVideoIndex = position; - break; - case R.id.subtitle_button: - selectedSubtitleIndex = position; - break; + } + } + + private void onItemSelectedSetFileName() { + final String fileName = FilenameUtils.createFilename(getContext(), currentInfo.getName()); + final String prevFileName = Optional.ofNullable(dialogBinding.fileName.getText()) + .map(Object::toString) + .orElse(""); + + if (prevFileName.isEmpty() + || prevFileName.equals(fileName) + || prevFileName.startsWith(getString(R.string.caption_file_name, fileName, ""))) { + // only update the file name field if it was not edited by the user + + switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) { + case R.id.audio_button: + case R.id.video_button: + if (!prevFileName.equals(fileName)) { + // since the user might have switched between audio and video, the correct + // text might already be in place, so avoid resetting the cursor position + dialogBinding.fileName.setText(fileName); + } + break; + + case R.id.subtitle_button: + final String setSubtitleLanguageCode = subtitleStreamsAdapter + .getItem(selectedSubtitleIndex).getLanguageTag(); + // this will reset the cursor position, which is bad UX, but it can't be avoided + dialogBinding.fileName.setText(getString( + R.string.caption_file_name, fileName, setSubtitleLanguageCode)); + break; + } } } @@ -592,19 +658,22 @@ public class DownloadDialog extends DialogFragment protected void setupDownloadOptions() { setRadioButtonsState(false); + setupAudioTrackSpinner(); final boolean isVideoStreamsAvailable = videoStreamsAdapter.getCount() > 0; final boolean isAudioStreamsAvailable = audioStreamsAdapter.getCount() > 0; final boolean isSubtitleStreamsAvailable = subtitleStreamsAdapter.getCount() > 0; - dialogBinding.audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE : View.GONE); - dialogBinding.videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE : View.GONE); + dialogBinding.audioButton.setVisibility(isAudioStreamsAvailable ? View.VISIBLE + : View.GONE); + dialogBinding.videoButton.setVisibility(isVideoStreamsAvailable ? View.VISIBLE + : View.GONE); dialogBinding.subtitleButton.setVisibility(isSubtitleStreamsAvailable ? View.VISIBLE : View.GONE); prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); final String defaultMedia = prefs.getString(getString(R.string.last_used_download_type), - getString(R.string.last_download_type_video_key)); + getString(R.string.last_download_type_video_key)); if (isVideoStreamsAvailable && (defaultMedia.equals(getString(R.string.last_download_type_video_key)))) { @@ -640,7 +709,14 @@ public class DownloadDialog extends DialogFragment dialogBinding.subtitleButton.setEnabled(enabled); } - private int getSubtitleIndexBy(final List streams) { + private StreamInfoWrapper getWrappedAudioStreams() { + if (selectedAudioTrackIndex < 0 || selectedAudioTrackIndex > wrappedAudioTracks.size()) { + return StreamInfoWrapper.empty(); + } + return wrappedAudioTracks.getTracksList().get(selectedAudioTrackIndex); + } + + private int getSubtitleIndexBy(@NonNull final List streams) { final Localization preferredLocalization = NewPipe.getPreferredLocalization(); int candidate = 0; @@ -666,35 +742,33 @@ public class DownloadDialog extends DialogFragment return candidate; } + @NonNull private String getNameEditText() { - final String str = dialogBinding.fileName.getText().toString().trim(); + final String str = Objects.requireNonNull(dialogBinding.fileName.getText()).toString() + .trim(); return FilenameUtils.createFilename(context, str.isEmpty() ? currentInfo.getName() : str); } private void showFailedDialog(@StringRes final int msg) { - assureCorrectAppLanguage(getContext()); + assureCorrectAppLanguage(requireContext()); new AlertDialog.Builder(context) .setTitle(R.string.general_error) .setMessage(msg) .setNegativeButton(getString(R.string.ok), null) - .create() .show(); } private void launchDirectoryPicker(final ActivityResultLauncher launcher) { - NoFileManagerSafeGuard.launchSafe( - launcher, - StoredDirectoryHelper.getPicker(context), - TAG, - context - ); + NoFileManagerSafeGuard.launchSafe(launcher, StoredDirectoryHelper.getPicker(context), TAG, + context); } private void prepareSelectedDownload() { final StoredDirectoryHelper mainStorage; final MediaFormat format; final String selectedMediaType; + final long size; // first, build the filename and get the output folder (if possible) // later, run a very very very large file checking logic @@ -706,34 +780,45 @@ public class DownloadDialog extends DialogFragment selectedMediaType = getString(R.string.last_download_type_audio_key); mainStorage = mainStorageAudio; format = audioStreamsAdapter.getItem(selectedAudioIndex).getFormat(); + size = getWrappedAudioStreams().getSizeInBytes(selectedAudioIndex); if (format == MediaFormat.WEBMA_OPUS) { mimeTmp = "audio/ogg"; filenameTmp += "opus"; - } else { + } else if (format != null) { mimeTmp = format.mimeType; - filenameTmp += format.suffix; + filenameTmp += format.getSuffix(); } break; case R.id.video_button: selectedMediaType = getString(R.string.last_download_type_video_key); mainStorage = mainStorageVideo; format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat(); - mimeTmp = format.mimeType; - filenameTmp += format.suffix; + size = wrappedVideoStreams.getSizeInBytes(selectedVideoIndex); + if (format != null) { + mimeTmp = format.mimeType; + filenameTmp += format.getSuffix(); + } break; case R.id.subtitle_button: selectedMediaType = getString(R.string.last_download_type_subtitle_key); mainStorage = mainStorageVideo; // subtitle & video files go together format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat(); - mimeTmp = format.mimeType; - filenameTmp += (format == MediaFormat.TTML ? MediaFormat.SRT : format).suffix; + size = wrappedSubtitleStreams.getSizeInBytes(selectedSubtitleIndex); + if (format != null) { + mimeTmp = format.mimeType; + } + + if (format == MediaFormat.TTML) { + filenameTmp += MediaFormat.SRT.getSuffix(); + } else if (format != null) { + filenameTmp += format.getSuffix(); + } break; default: throw new RuntimeException("No stream selected"); } - if (!askForSavePath - && (mainStorage == null + if (!askForSavePath && (mainStorage == null || mainStorage.isDirect() == NewPipeSettings.useStorageAccessFramework(context) || mainStorage.isInvalidSafStorage())) { // Pick new download folder if one of: @@ -767,18 +852,32 @@ public class DownloadDialog extends DialogFragment initialPath = Uri.parse(initialSavePath.getAbsolutePath()); } - NoFileManagerSafeGuard.launchSafe( - requestDownloadSaveAsLauncher, - StoredFileHelper.getNewPicker(context, filenameTmp, mimeTmp, initialPath), - TAG, - context - ); + NoFileManagerSafeGuard.launchSafe(requestDownloadSaveAsLauncher, + StoredFileHelper.getNewPicker(context, filenameTmp, mimeTmp, initialPath), TAG, + context); return; } + // Check for free memory space (for api 24 and up) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) { + final long freeSpace = mainStorage.getFreeMemory(); + if (freeSpace <= size) { + Toast.makeText(context, getString(R. + string.error_insufficient_storage), Toast.LENGTH_LONG).show(); + // move the user to storage setting tab + final Intent storageSettingsIntent = new Intent(Settings. + ACTION_INTERNAL_STORAGE_SETTINGS); + if (storageSettingsIntent.resolveActivity(context.getPackageManager()) != null) { + startActivity(storageSettingsIntent); + } + return; + } + } + // check for existing file with the same name - checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp, mimeTmp); + checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp, + mimeTmp); // remember the last media type downloaded by the user prefs.edit().putString(getString(R.string.last_used_download_type), selectedMediaType) @@ -786,7 +885,8 @@ public class DownloadDialog extends DialogFragment } private void checkSelectedDownload(final StoredDirectoryHelper mainStorage, - final Uri targetFile, final String filename, + final Uri targetFile, + final String filename, final String mime) { StoredFileHelper storage; @@ -888,7 +988,7 @@ public class DownloadDialog extends DialogFragment break; } - askDialog.create().show(); + askDialog.show(); return; } @@ -932,7 +1032,7 @@ public class DownloadDialog extends DialogFragment } }); - askDialog.create().show(); + askDialog.show(); } private void continueSelectedDownload(@NonNull final StoredFileHelper storage) { @@ -947,7 +1047,7 @@ public class DownloadDialog extends DialogFragment storage.truncate(); } } catch (final IOException e) { - Log.e(TAG, "failed to truncate the file: " + storage.getUri().toString(), e); + Log.e(TAG, "Failed to truncate the file: " + storage.getUri().toString(), e); showFailedDialog(R.string.overwrite_failed); return; } @@ -957,7 +1057,7 @@ public class DownloadDialog extends DialogFragment final char kind; int threads = dialogBinding.threads.getProgress() + 1; final String[] urls; - final MissionRecoveryInfo[] recoveryInfo; + final List recoveryInfo; String psName = null; String[] psArgs = null; long nearLength = 0; @@ -991,9 +1091,8 @@ public class DownloadDialog extends DialogFragment psName = Postprocessing.ALGORITHM_WEBM_MUXER; } - psArgs = null; - final long videoSize = wrappedVideoStreams - .getSizeInBytes((VideoStream) selectedStream); + final long videoSize = wrappedVideoStreams.getSizeInBytes( + (VideoStream) selectedStream); // set nearLength, only, if both sizes are fetched or known. This probably // does not work on slow networks but is later updated in the downloader @@ -1009,7 +1108,7 @@ public class DownloadDialog extends DialogFragment if (selectedStream.getFormat() == MediaFormat.TTML) { psName = Postprocessing.ALGORITHM_TTML_CONVERTER; - psArgs = new String[]{ + psArgs = new String[] { selectedStream.getFormat().getSuffix(), "false" // ignore empty frames }; @@ -1020,22 +1119,27 @@ public class DownloadDialog extends DialogFragment } if (secondaryStream == null) { - urls = new String[]{ - selectedStream.getUrl() - }; - recoveryInfo = new MissionRecoveryInfo[]{ - new MissionRecoveryInfo(selectedStream) + urls = new String[] { + selectedStream.getContent() }; + recoveryInfo = List.of(new MissionRecoveryInfo(selectedStream)); } else { - urls = new String[]{ - selectedStream.getUrl(), secondaryStream.getUrl() + if (secondaryStream.getDeliveryMethod() != PROGRESSIVE_HTTP) { + throw new IllegalArgumentException("Unsupported stream delivery format" + + secondaryStream.getDeliveryMethod()); + } + + urls = new String[] { + selectedStream.getContent(), secondaryStream.getContent() }; - recoveryInfo = new MissionRecoveryInfo[]{new MissionRecoveryInfo(selectedStream), - new MissionRecoveryInfo(secondaryStream)}; + recoveryInfo = List.of( + new MissionRecoveryInfo(selectedStream), + new MissionRecoveryInfo(secondaryStream) + ); } DownloadManagerService.startMission(context, urls, storage, kind, threads, - currentInfo.getUrl(), psName, psArgs, nearLength, recoveryInfo); + currentInfo.getUrl(), psName, psArgs, nearLength, new ArrayList<>(recoveryInfo)); Toast.makeText(context, getString(R.string.download_has_started), Toast.LENGTH_SHORT).show(); diff --git a/app/src/main/java/org/schabi/newpipe/download/LoadingDialog.java b/app/src/main/java/org/schabi/newpipe/download/LoadingDialog.java new file mode 100644 index 000000000..9e6861908 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/download/LoadingDialog.java @@ -0,0 +1,87 @@ +package org.schabi.newpipe.download; + +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.Toolbar; +import androidx.fragment.app.DialogFragment; + +import org.schabi.newpipe.MainActivity; +import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.DownloadLoadingDialogBinding; + +/** + * This class contains a dialog which shows a loading indicator and has a customizable title. + */ +public class LoadingDialog extends DialogFragment { + private static final String TAG = "LoadingDialog"; + private static final boolean DEBUG = MainActivity.DEBUG; + private DownloadLoadingDialogBinding dialogLoadingBinding; + private final @StringRes int title; + + /** + * Create a new LoadingDialog. + * + *

+ * The dialog contains a loading indicator and has a customizable title. + *
+ * Use {@code show()} to display the dialog to the user. + *

+ * + * @param title an informative title shown in the dialog's toolbar + */ + public LoadingDialog(final @StringRes int title) { + this.title = title; + } + + @Override + public void onCreate(@Nullable final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (DEBUG) { + Log.d(TAG, "onCreate() called with: " + + "savedInstanceState = [" + savedInstanceState + "]"); + } + this.setCancelable(false); + } + + @Override + public View onCreateView( + @NonNull final LayoutInflater inflater, + final ViewGroup container, + final Bundle savedInstanceState) { + if (DEBUG) { + Log.d(TAG, "onCreateView() called with: " + + "inflater = [" + inflater + "], container = [" + container + "], " + + "savedInstanceState = [" + savedInstanceState + "]"); + } + return inflater.inflate(R.layout.download_loading_dialog, container); + } + + @Override + public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + dialogLoadingBinding = DownloadLoadingDialogBinding.bind(view); + initToolbar(dialogLoadingBinding.toolbarLayout.toolbar); + } + + private void initToolbar(final Toolbar toolbar) { + if (DEBUG) { + Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]"); + } + toolbar.setTitle(requireContext().getString(title)); + toolbar.setNavigationOnClickListener(v -> dismiss()); + + } + + @Override + public void onDestroyView() { + dialogLoadingBinding = null; + super.onDestroyView(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java index bd8430296..831a8cc4b 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java @@ -17,6 +17,7 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; +import androidx.core.content.IntentCompat; import com.grack.nanojson.JsonWriter; @@ -31,6 +32,7 @@ import org.schabi.newpipe.util.external_communication.ShareUtils; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Arrays; +import java.util.stream.Collectors; /* * Created by Christian Schabesberger on 24.10.15. @@ -65,11 +67,11 @@ public class ErrorActivity extends AppCompatActivity { public static final String ERROR_EMAIL_ADDRESS = "crashreport@newpipe.schabi.org"; public static final String ERROR_EMAIL_SUBJECT = "Exception in "; - public static final String ERROR_GITHUB_ISSUE_URL - = "https://github.com/TeamNewPipe/NewPipe/issues"; + public static final String ERROR_GITHUB_ISSUE_URL = + "https://github.com/TeamNewPipe/NewPipe/issues"; - public static final DateTimeFormatter CURRENT_TIMESTAMP_FORMATTER - = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + public static final DateTimeFormatter CURRENT_TIMESTAMP_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); private ErrorInfo errorInfo; @@ -104,7 +106,7 @@ public class ErrorActivity extends AppCompatActivity { actionBar.setDisplayShowTitleEnabled(true); } - errorInfo = intent.getParcelableExtra(ERROR_INFO); + errorInfo = IntentCompat.getParcelableExtra(intent, ERROR_INFO, ErrorInfo.class); // important add guru meditation addGuruMeditation(); @@ -159,7 +161,7 @@ public class ErrorActivity extends AppCompatActivity { .setMessage(R.string.start_accept_privacy_policy) .setCancelable(false) .setNeutralButton(R.string.read_privacy_policy, (dialog, which) -> - ShareUtils.openUrlInBrowser(context, + ShareUtils.openUrlInApp(context, context.getString(R.string.privacy_policy_url))) .setPositiveButton(R.string.accept, (dialog, which) -> { if (action.equals("EMAIL")) { // send on email @@ -170,26 +172,19 @@ public class ErrorActivity extends AppCompatActivity { + getString(R.string.app_name) + " " + BuildConfig.VERSION_NAME) .putExtra(Intent.EXTRA_TEXT, buildJson()); - ShareUtils.openIntentInApp(context, i, true); + ShareUtils.openIntentInApp(context, i); } else if (action.equals("GITHUB")) { // open the NewPipe issue page on GitHub - ShareUtils.openUrlInBrowser(this, ERROR_GITHUB_ISSUE_URL, false); + ShareUtils.openUrlInApp(this, ERROR_GITHUB_ISSUE_URL); } }) - .setNegativeButton(R.string.decline, (dialog, which) -> { - // do nothing - }) + .setNegativeButton(R.string.decline, null) .show(); } private String formErrorText(final String[] el) { - final StringBuilder text = new StringBuilder(); - if (el != null) { - for (final String e : el) { - text.append("-------------------------------------\n").append(e); - } - } - text.append("-------------------------------------"); - return text.toString(); + final String separator = "-------------------------------------"; + return Arrays.stream(el) + .collect(Collectors.joining(separator + "\n", separator + "\n", separator)); } /** diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt index b2ba912ec..6d8c1bd63 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt @@ -7,15 +7,12 @@ import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.schabi.newpipe.R import org.schabi.newpipe.extractor.Info -import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException import org.schabi.newpipe.extractor.exceptions.ExtractionException -import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor.DeobfuscateException import org.schabi.newpipe.ktx.isNetworkRelated -import java.io.PrintWriter -import java.io.StringWriter +import org.schabi.newpipe.util.ServiceHelper @Parcelize class ErrorInfo( @@ -65,7 +62,7 @@ class ErrorInfo( constructor(throwable: Throwable, userAction: UserAction, request: String) : this(throwable, userAction, SERVICE_NONE, request) constructor(throwable: Throwable, userAction: UserAction, request: String, serviceId: Int) : - this(throwable, userAction, NewPipe.getNameOfService(serviceId), request) + this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request) constructor(throwable: Throwable, userAction: UserAction, request: String, info: Info?) : this(throwable, userAction, getInfoServiceName(info), request) @@ -73,29 +70,20 @@ class ErrorInfo( constructor(throwable: List, userAction: UserAction, request: String) : this(throwable, userAction, SERVICE_NONE, request) constructor(throwable: List, userAction: UserAction, request: String, serviceId: Int) : - this(throwable, userAction, NewPipe.getNameOfService(serviceId), request) + this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request) constructor(throwable: List, userAction: UserAction, request: String, info: Info?) : this(throwable, userAction, getInfoServiceName(info), request) companion object { const val SERVICE_NONE = "none" - private fun getStackTrace(throwable: Throwable): String { - StringWriter().use { stringWriter -> - PrintWriter(stringWriter, true).use { printWriter -> - throwable.printStackTrace(printWriter) - return stringWriter.buffer.toString() - } - } - } + fun throwableToStringList(throwable: Throwable) = arrayOf(throwable.stackTraceToString()) - fun throwableToStringList(throwable: Throwable) = arrayOf(getStackTrace(throwable)) - - fun throwableListToStringList(throwable: List) = - Array(throwable.size) { i -> getStackTrace(throwable[i]) } + fun throwableListToStringList(throwableList: List) = + throwableList.map { it.stackTraceToString() }.toTypedArray() private fun getInfoServiceName(info: Info?) = - if (info == null) SERVICE_NONE else NewPipe.getNameOfService(info.serviceId) + if (info == null) SERVICE_NONE else ServiceHelper.getNameOfServiceById(info.serviceId) @StringRes private fun getMessageStringId( @@ -107,7 +95,6 @@ class ErrorInfo( throwable is ContentNotAvailableException -> R.string.content_not_available throwable != null && throwable.isNetworkRelated -> R.string.network_error 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) { diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt index 692cb427a..fcc062102 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt @@ -6,7 +6,6 @@ import android.util.Log import android.view.View import android.widget.Button import android.widget.TextView -import androidx.annotation.Nullable import androidx.annotation.StringRes import androidx.core.view.isVisible import androidx.fragment.app.Fragment @@ -15,7 +14,6 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.disposables.Disposable import org.schabi.newpipe.MainActivity import org.schabi.newpipe.R -import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException @@ -31,6 +29,7 @@ import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.ktx.isInterruptedCaused import org.schabi.newpipe.ktx.isNetworkRelated import org.schabi.newpipe.util.ServiceHelper +import org.schabi.newpipe.util.external_communication.ShareUtils import java.util.concurrent.TimeUnit class ErrorPanelHelper( @@ -53,6 +52,8 @@ class ErrorPanelHelper( errorPanelRoot.findViewById(R.id.error_action_button) private val errorRetryButton: Button = errorPanelRoot.findViewById(R.id.error_retry_button) + private val errorOpenInBrowserButton: Button = + errorPanelRoot.findViewById(R.id.error_open_in_browser) private var errorDisposable: Disposable? = null @@ -70,6 +71,7 @@ class ErrorPanelHelper( errorServiceExplanationTextView.isVisible = false errorActionButton.isVisible = false errorRetryButton.isVisible = false + errorOpenInBrowserButton.isVisible = false } fun showError(errorInfo: ErrorInfo) { @@ -100,13 +102,14 @@ class ErrorPanelHelper( } errorRetryButton.isVisible = true + showAndSetOpenInBrowserButtonAction(errorInfo) } else if (errorInfo.throwable is AccountTerminatedException) { errorTextView.setText(R.string.account_terminated) if (!isNullOrEmpty((errorInfo.throwable as AccountTerminatedException).message)) { errorServiceInfoTextView.text = context.resources.getString( R.string.service_provides_reason, - NewPipe.getNameOfService(ServiceHelper.getSelectedServiceId(context)) + ServiceHelper.getSelectedService(context)?.serviceInfo?.name ?: "" ) errorServiceInfoTextView.isVisible = true @@ -129,6 +132,7 @@ class ErrorPanelHelper( // show retry button only for content which is not unavailable or unsupported errorRetryButton.isVisible = true } + showAndSetOpenInBrowserButtonAction(errorInfo) } setRootVisible() @@ -139,13 +143,22 @@ class ErrorPanelHelper( */ private fun showAndSetErrorButtonAction( @StringRes resid: Int, - @Nullable listener: View.OnClickListener + listener: View.OnClickListener ) { errorActionButton.isVisible = true errorActionButton.setText(resid) errorActionButton.setOnClickListener(listener) } + fun showAndSetOpenInBrowserButtonAction( + errorInfo: ErrorInfo + ) { + errorOpenInBrowserButton.isVisible = true + errorOpenInBrowserButton.setOnClickListener { + ShareUtils.openUrlInBrowser(context, errorInfo.request) + } + } + fun showTextError(errorString: String) { ensureDefaultVisibility() diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt index e4dd2e16d..daa598509 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt @@ -5,11 +5,11 @@ 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.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat import androidx.fragment.app.Fragment import com.google.android.material.snackbar.Snackbar import org.schabi.newpipe.R @@ -104,32 +104,22 @@ class ErrorUtil { */ @JvmStatic fun createNotification(context: Context, errorInfo: 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( - // the vector drawable icon causes crashes on KitKat devices - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) - R.drawable.ic_bug_report - else - android.R.drawable.stat_notify_error - ) + .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( + PendingIntentCompat.getActivity( context, 0, getErrorActivityIntent(context, errorInfo), - pendingIntentFlags + PendingIntent.FLAG_UPDATE_CURRENT, + false ) ) diff --git a/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java b/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java index 555dd709b..3c14cfe4c 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java +++ b/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java @@ -3,14 +3,15 @@ package org.schabi.newpipe.error; import android.annotation.SuppressLint; import android.content.Intent; import android.content.SharedPreferences; -import android.os.Build; import android.os.Bundle; import android.util.Log; import android.view.Menu; import android.view.MenuItem; import android.webkit.CookieManager; +import android.webkit.WebResourceRequest; import android.webkit.WebSettings; import android.webkit.WebView; +import android.webkit.WebViewClient; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -18,16 +19,15 @@ import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.core.app.NavUtils; import androidx.preference.PreferenceManager; -import androidx.webkit.WebViewClientCompat; -import org.schabi.newpipe.databinding.ActivityRecaptchaBinding; import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.ActivityRecaptchaBinding; +import org.schabi.newpipe.extractor.utils.Utils; import org.schabi.newpipe.util.ThemeHelper; import java.io.UnsupportedEncodingException; -import java.net.URLDecoder; /* * Created by beneth on 06.12.16. @@ -86,14 +86,15 @@ public class ReCaptchaActivity extends AppCompatActivity { webSettings.setJavaScriptEnabled(true); webSettings.setUserAgentString(DownloaderImpl.USER_AGENT); - recaptchaBinding.reCaptchaWebView.setWebViewClient(new WebViewClientCompat() { + recaptchaBinding.reCaptchaWebView.setWebViewClient(new WebViewClient() { @Override - public boolean shouldOverrideUrlLoading(final WebView view, final String url) { + public boolean shouldOverrideUrlLoading(final WebView view, + final WebResourceRequest request) { if (MainActivity.DEBUG) { - Log.d(TAG, "shouldOverrideUrlLoading: url=" + url); + Log.d(TAG, "shouldOverrideUrlLoading: url=" + request.getUrl().toString()); } - handleCookiesFromUrl(url); + handleCookiesFromUrl(request.getUrl().toString()); return false; } @@ -107,12 +108,7 @@ public class ReCaptchaActivity extends AppCompatActivity { // cleaning cache, history and cookies from webView recaptchaBinding.reCaptchaWebView.clearCache(true); recaptchaBinding.reCaptchaWebView.clearHistory(); - final CookieManager cookieManager = CookieManager.getInstance(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - cookieManager.removeAllCookies(value -> { }); - } else { - cookieManager.removeAllCookie(); - } + CookieManager.getInstance().removeAllCookies(null); recaptchaBinding.reCaptchaWebView.loadUrl(url); } @@ -192,7 +188,7 @@ public class ReCaptchaActivity extends AppCompatActivity { try { String abuseCookie = url.substring(abuseStart + 13, abuseEnd); - abuseCookie = URLDecoder.decode(abuseCookie, "UTF-8"); + abuseCookie = Utils.decodeUrlUtf8(abuseCookie); handleCookies(abuseCookie); } catch (UnsupportedEncodingException | StringIndexOutOfBoundsException e) { if (MainActivity.DEBUG) { diff --git a/app/src/main/java/org/schabi/newpipe/error/UserAction.java b/app/src/main/java/org/schabi/newpipe/error/UserAction.java index 976173373..c8701cd77 100644 --- a/app/src/main/java/org/schabi/newpipe/error/UserAction.java +++ b/app/src/main/java/org/schabi/newpipe/error/UserAction.java @@ -19,6 +19,7 @@ public enum UserAction { REQUESTED_PLAYLIST("requested playlist"), REQUESTED_KIOSK("requested kiosk"), REQUESTED_COMMENTS("requested comments"), + REQUESTED_COMMENT_REPLIES("requested comment replies"), REQUESTED_FEED("requested feed"), REQUESTED_BOOKMARK("bookmark"), DELETE_FROM_HISTORY("delete from history"), diff --git a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java index 9b4bf8377..a3d3d8b60 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java @@ -1,12 +1,16 @@ package org.schabi.newpipe.fragments; +import static org.schabi.newpipe.ktx.ViewUtils.animate; + import android.os.Bundle; import android.util.Log; import android.view.View; import android.widget.ProgressBar; +import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.StringRes; import androidx.fragment.app.Fragment; import org.schabi.newpipe.BaseFragment; @@ -20,15 +24,15 @@ import java.util.concurrent.atomic.AtomicBoolean; import icepick.State; -import static org.schabi.newpipe.ktx.ViewUtils.animate; - public abstract class BaseStateFragment extends BaseFragment implements ViewContract { @State protected AtomicBoolean wasLoading = new AtomicBoolean(); protected AtomicBoolean isLoading = new AtomicBoolean(); @Nullable - private View emptyStateView; + protected View emptyStateView; + @Nullable + protected TextView emptyStateMessageView; @Nullable private ProgressBar loadingProgressBar; @@ -65,6 +69,7 @@ public abstract class BaseStateFragment extends BaseFragment implements ViewC protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); emptyStateView = rootView.findViewById(R.id.empty_state_view); + emptyStateMessageView = rootView.findViewById(R.id.empty_state_message); loadingProgressBar = rootView.findViewById(R.id.loading_progress_bar); errorPanelHelper = new ErrorPanelHelper(this, rootView, this::onRetryButtonClicked); } @@ -75,6 +80,8 @@ public abstract class BaseStateFragment extends BaseFragment implements ViewC if (errorPanelHelper != null) { errorPanelHelper.dispose(); } + emptyStateView = null; + emptyStateMessageView = null; } protected void onRetryButtonClicked() { @@ -189,6 +196,12 @@ public abstract class BaseStateFragment extends BaseFragment implements ViewC errorPanelHelper.showTextError(errorString); } + protected void setEmptyStateMessage(@StringRes final int text) { + if (emptyStateMessageView != null) { + emptyStateMessageView.setText(text); + } + } + public final void hideErrorPanel() { errorPanelHelper.hide(); lastPanelError = null; diff --git a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java index de68269e9..52a41d38f 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java @@ -1,6 +1,16 @@ package org.schabi.newpipe.fragments; +import static android.widget.RelativeLayout.ABOVE; +import static android.widget.RelativeLayout.ALIGN_PARENT_BOTTOM; +import static android.widget.RelativeLayout.ALIGN_PARENT_TOP; +import static android.widget.RelativeLayout.BELOW; +import static com.google.android.material.tabs.TabLayout.INDICATOR_GRAVITY_BOTTOM; +import static com.google.android.material.tabs.TabLayout.INDICATOR_GRAVITY_TOP; + import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.ColorStateList; +import android.graphics.Color; import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; @@ -9,7 +19,9 @@ import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; +import android.widget.RelativeLayout; +import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; @@ -17,6 +29,7 @@ import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentStatePagerAdapterMenuWorkaround; import androidx.preference.PreferenceManager; +import androidx.viewpager.widget.ViewPager; import com.google.android.material.tabs.TabLayout; @@ -25,10 +38,13 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.FragmentMainBinding; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.local.playlist.LocalPlaylistFragment; import org.schabi.newpipe.settings.tabs.Tab; import org.schabi.newpipe.settings.tabs.TabsManager; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.ServiceHelper; +import org.schabi.newpipe.util.ThemeHelper; +import org.schabi.newpipe.views.ScrollableTabLayout; import java.util.ArrayList; import java.util.List; @@ -42,8 +58,11 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte private boolean hasTabsChanged = false; - private boolean previousYoutubeRestrictedModeEnabled; + private SharedPreferences prefs; + private boolean youtubeRestrictedModeEnabled; private String youtubeRestrictedModeEnabledKey; + private boolean mainTabsPositionBottom; + private String mainTabsPositionKey; /*////////////////////////////////////////////////////////////////////////// // Fragment's LifeCycle @@ -66,10 +85,11 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte } }); + prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled); - previousYoutubeRestrictedModeEnabled = - PreferenceManager.getDefaultSharedPreferences(requireContext()) - .getBoolean(youtubeRestrictedModeEnabledKey, false); + youtubeRestrictedModeEnabled = prefs.getBoolean(youtubeRestrictedModeEnabledKey, false); + mainTabsPositionKey = getString(R.string.main_tabs_position_key); + mainTabsPositionBottom = prefs.getBoolean(mainTabsPositionKey, false); } @Override @@ -87,25 +107,27 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte binding.mainTabLayout.setupWithViewPager(binding.pager); binding.mainTabLayout.addOnTabSelectedListener(this); - binding.mainTabLayout.setTabRippleColor(binding.mainTabLayout.getTabRippleColor() - .withAlpha(32)); setupTabs(); + updateTabLayoutPosition(); } @Override public void onResume() { super.onResume(); - final boolean youtubeRestrictedModeEnabled = - PreferenceManager.getDefaultSharedPreferences(requireContext()) - .getBoolean(youtubeRestrictedModeEnabledKey, false); - if (previousYoutubeRestrictedModeEnabled != youtubeRestrictedModeEnabled) { - previousYoutubeRestrictedModeEnabled = youtubeRestrictedModeEnabled; - setupTabs(); - } else if (hasTabsChanged) { + final boolean newYoutubeRestrictedModeEnabled = + prefs.getBoolean(youtubeRestrictedModeEnabledKey, false); + if (youtubeRestrictedModeEnabled != newYoutubeRestrictedModeEnabled || hasTabsChanged) { + youtubeRestrictedModeEnabled = newYoutubeRestrictedModeEnabled; setupTabs(); } + + final boolean newMainTabsPosition = prefs.getBoolean(mainTabsPositionKey, false); + if (mainTabsPositionBottom != newMainTabsPosition) { + mainTabsPositionBottom = newMainTabsPosition; + updateTabLayoutPosition(); + } } @Override @@ -118,6 +140,12 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte } } + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + /*////////////////////////////////////////////////////////////////////////// // Menu //////////////////////////////////////////////////////////////////////////*/ @@ -166,7 +194,6 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte } binding.pager.setAdapter(null); - binding.pager.setOffscreenPageLimit(tabsList.size()); binding.pager.setAdapter(pagerAdapter); updateTabsIconAndDescription(); @@ -190,6 +217,44 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte setTitle(tabsList.get(tabPosition).getTabName(requireContext())); } + public void commitPlaylistTabs() { + pagerAdapter.getLocalPlaylistFragments() + .stream() + .forEach(LocalPlaylistFragment::saveImmediate); + } + + private void updateTabLayoutPosition() { + final ScrollableTabLayout tabLayout = binding.mainTabLayout; + final ViewPager viewPager = binding.pager; + final boolean bottom = mainTabsPositionBottom; + + // change layout params to make the tab layout appear either at the top or at the bottom + final var tabParams = (RelativeLayout.LayoutParams) tabLayout.getLayoutParams(); + final var pagerParams = (RelativeLayout.LayoutParams) viewPager.getLayoutParams(); + + tabParams.removeRule(bottom ? ALIGN_PARENT_TOP : ALIGN_PARENT_BOTTOM); + tabParams.addRule(bottom ? ALIGN_PARENT_BOTTOM : ALIGN_PARENT_TOP); + pagerParams.removeRule(bottom ? BELOW : ABOVE); + pagerParams.addRule(bottom ? ABOVE : BELOW, R.id.main_tab_layout); + tabLayout.setSelectedTabIndicatorGravity( + bottom ? INDICATOR_GRAVITY_TOP : INDICATOR_GRAVITY_BOTTOM); + + tabLayout.setLayoutParams(tabParams); + viewPager.setLayoutParams(pagerParams); + + // change the background and icon color of the tab layout: + // service-colored at the top, app-background-colored at the bottom + tabLayout.setBackgroundColor(ThemeHelper.resolveColorFromAttr(requireContext(), + bottom ? R.attr.colorSecondary : R.attr.colorPrimary)); + + @ColorInt final int iconColor = bottom + ? ThemeHelper.resolveColorFromAttr(requireContext(), R.attr.colorAccent) + : Color.WHITE; + tabLayout.setTabRippleColor(ColorStateList.valueOf(iconColor).withAlpha(32)); + tabLayout.setTabIconTint(ColorStateList.valueOf(iconColor)); + tabLayout.setSelectedTabIndicatorColor(iconColor); + } + @Override public void onTabSelected(final TabLayout.Tab selectedTab) { if (DEBUG) { @@ -209,10 +274,18 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte updateTitleForTab(tab.getPosition()); } - private static final class SelectedTabsPagerAdapter + public static final class SelectedTabsPagerAdapter extends FragmentStatePagerAdapterMenuWorkaround { private final Context context; private final List internalTabsList; + /** + * Keep reference to LocalPlaylistFragments, because their data can be modified by the user + * during runtime and changes are not committed immediately. However, in some cases, + * the changes need to be committed immediately by calling + * {@link LocalPlaylistFragment#saveImmediate()}. + * The fragments are removed when {@link LocalPlaylistFragment#onDestroy()} is called. + */ + private final List localPlaylistFragments = new ArrayList<>(); private SelectedTabsPagerAdapter(final Context context, final FragmentManager fragmentManager, @@ -239,9 +312,17 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte ((BaseFragment) fragment).useAsFrontPage(true); } + if (fragment instanceof LocalPlaylistFragment) { + localPlaylistFragments.add((LocalPlaylistFragment) fragment); + } + return fragment; } + public List getLocalPlaylistFragments() { + return localPlaylistFragments; + } + @Override public int getItemPosition(@NonNull final Object object) { // Causes adapter to reload all Fragments when diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java new file mode 100644 index 000000000..4789b02e6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/BaseDescriptionFragment.java @@ -0,0 +1,281 @@ +package org.schabi.newpipe.fragments.detail; + +import static android.text.TextUtils.isEmpty; +import static org.schabi.newpipe.extractor.utils.Utils.isBlank; +import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD; + +import android.graphics.Typeface; +import android.os.Bundle; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.method.LinkMovementMethod; +import android.text.style.ClickableSpan; +import android.text.style.StyleSpan; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.appcompat.widget.TooltipCompat; +import androidx.core.text.HtmlCompat; + +import com.google.android.material.chip.Chip; + +import org.schabi.newpipe.BaseFragment; +import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.FragmentDescriptionBinding; +import org.schabi.newpipe.databinding.ItemMetadataBinding; +import org.schabi.newpipe.databinding.ItemMetadataTagsBinding; +import org.schabi.newpipe.extractor.Image; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.stream.Description; +import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.external_communication.ShareUtils; +import org.schabi.newpipe.util.image.ImageStrategy; +import org.schabi.newpipe.util.text.TextLinkifier; + +import java.util.List; + +import io.reactivex.rxjava3.disposables.CompositeDisposable; + +public abstract class BaseDescriptionFragment extends BaseFragment { + private final CompositeDisposable descriptionDisposables = new CompositeDisposable(); + protected FragmentDescriptionBinding binding; + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + binding = FragmentDescriptionBinding.inflate(inflater, container, false); + setupDescription(); + setupMetadata(inflater, binding.detailMetadataLayout); + addTagsMetadataItem(inflater, binding.detailMetadataLayout); + return binding.getRoot(); + } + + @Override + public void onDestroy() { + descriptionDisposables.clear(); + super.onDestroy(); + } + + /** + * Get the description to display. + * @return description object, if available + */ + @Nullable + protected abstract Description getDescription(); + + /** + * Get the streaming service. Used for generating description links. + * @return streaming service + */ + @NonNull + protected abstract StreamingService getService(); + + /** + * Get the streaming service ID. Used for tag links. + * @return service ID + */ + protected abstract int getServiceId(); + + /** + * Get the URL of the described video or audio, used to generate description links. + * @return stream URL + */ + @Nullable + protected abstract String getStreamUrl(); + + /** + * Get the list of tags to display below the description. + * @return tag list + */ + @NonNull + public abstract List getTags(); + + /** + * Add additional metadata to display. + * @param inflater LayoutInflater + * @param layout detailMetadataLayout + */ + protected abstract void setupMetadata(LayoutInflater inflater, LinearLayout layout); + + private void setupDescription() { + final Description description = getDescription(); + if (description == null || isEmpty(description.getContent()) + || description == Description.EMPTY_DESCRIPTION) { + binding.detailDescriptionView.setVisibility(View.GONE); + binding.detailSelectDescriptionButton.setVisibility(View.GONE); + return; + } + + // start with disabled state. This also loads description content (!) + disableDescriptionSelection(); + + binding.detailSelectDescriptionButton.setOnClickListener(v -> { + if (binding.detailDescriptionNoteView.getVisibility() == View.VISIBLE) { + disableDescriptionSelection(); + } else { + // enable selection only when button is clicked to prevent flickering + enableDescriptionSelection(); + } + }); + } + + private void enableDescriptionSelection() { + binding.detailDescriptionNoteView.setVisibility(View.VISIBLE); + binding.detailDescriptionView.setTextIsSelectable(true); + + final String buttonLabel = getString(R.string.description_select_disable); + binding.detailSelectDescriptionButton.setContentDescription(buttonLabel); + TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel); + binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_close); + } + + private void disableDescriptionSelection() { + // show description content again, otherwise some links are not clickable + final Description description = getDescription(); + if (description != null) { + TextLinkifier.fromDescription(binding.detailDescriptionView, + description, HtmlCompat.FROM_HTML_MODE_LEGACY, + getService(), getStreamUrl(), + descriptionDisposables, SET_LINK_MOVEMENT_METHOD); + } + + binding.detailDescriptionNoteView.setVisibility(View.GONE); + binding.detailDescriptionView.setTextIsSelectable(false); + + final String buttonLabel = getString(R.string.description_select_enable); + binding.detailSelectDescriptionButton.setContentDescription(buttonLabel); + TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel); + binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all); + } + + protected void addMetadataItem(final LayoutInflater inflater, + final LinearLayout layout, + final boolean linkifyContent, + @StringRes final int type, + @NonNull final String content) { + if (isBlank(content)) { + return; + } + + final ItemMetadataBinding itemBinding = + ItemMetadataBinding.inflate(inflater, layout, false); + + itemBinding.metadataTypeView.setText(type); + itemBinding.metadataTypeView.setOnLongClickListener(v -> { + ShareUtils.copyToClipboard(requireContext(), content); + return true; + }); + + if (linkifyContent) { + TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null, + descriptionDisposables, SET_LINK_MOVEMENT_METHOD); + } else { + itemBinding.metadataContentView.setText(content); + } + + itemBinding.metadataContentView.setClickable(true); + + layout.addView(itemBinding.getRoot()); + } + + private String imageSizeToText(final int heightOrWidth) { + if (heightOrWidth < 0) { + return getString(R.string.question_mark); + } else { + return String.valueOf(heightOrWidth); + } + } + + protected void addImagesMetadataItem(final LayoutInflater inflater, + final LinearLayout layout, + @StringRes final int type, + final List images) { + final String preferredImageUrl = ImageStrategy.choosePreferredImage(images); + if (preferredImageUrl == null) { + return; // null will be returned in case there is no image + } + + final ItemMetadataBinding itemBinding = + ItemMetadataBinding.inflate(inflater, layout, false); + itemBinding.metadataTypeView.setText(type); + + final SpannableStringBuilder urls = new SpannableStringBuilder(); + for (final Image image : images) { + if (urls.length() != 0) { + urls.append(", "); + } + final int entryBegin = urls.length(); + + if (image.getHeight() != Image.HEIGHT_UNKNOWN + || image.getWidth() != Image.WIDTH_UNKNOWN + // if even the resolution level is unknown, ?x? will be shown + || image.getEstimatedResolutionLevel() == Image.ResolutionLevel.UNKNOWN) { + urls.append(imageSizeToText(image.getHeight())); + urls.append('x'); + urls.append(imageSizeToText(image.getWidth())); + } else { + switch (image.getEstimatedResolutionLevel()) { + case LOW -> urls.append(getString(R.string.image_quality_low)); + case MEDIUM -> urls.append(getString(R.string.image_quality_medium)); + case HIGH -> urls.append(getString(R.string.image_quality_high)); + default -> { + // unreachable, Image.ResolutionLevel.UNKNOWN is already filtered out + } + } + } + + urls.setSpan(new ClickableSpan() { + @Override + public void onClick(@NonNull final View widget) { + ShareUtils.openUrlInBrowser(requireContext(), image.getUrl()); + } + }, entryBegin, urls.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + if (preferredImageUrl.equals(image.getUrl())) { + urls.setSpan(new StyleSpan(Typeface.BOLD), entryBegin, urls.length(), + Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + } + } + + itemBinding.metadataContentView.setText(urls); + itemBinding.metadataContentView.setMovementMethod(LinkMovementMethod.getInstance()); + layout.addView(itemBinding.getRoot()); + } + + private void addTagsMetadataItem(final LayoutInflater inflater, final LinearLayout layout) { + final List tags = getTags(); + + if (!tags.isEmpty()) { + final var itemBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false); + + tags.stream().sorted(String.CASE_INSENSITIVE_ORDER).forEach(tag -> { + final Chip chip = (Chip) inflater.inflate(R.layout.chip, + itemBinding.metadataTagsChips, false); + chip.setText(tag); + chip.setOnClickListener(this::onTagClick); + chip.setOnLongClickListener(this::onTagLongClick); + itemBinding.metadataTagsChips.addView(chip); + }); + + layout.addView(itemBinding.getRoot()); + } + } + + private void onTagClick(final View chip) { + if (getParentFragment() != null) { + NavigationHelper.openSearchFragment(getParentFragment().getParentFragmentManager(), + getServiceId(), ((Chip) chip).getText().toString()); + } + } + + private boolean onTagLongClick(final View chip) { + ShareUtils.copyToClipboard(requireContext(), ((Chip) chip).getText().toString()); + return true; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java index d57ddb02d..7fc66b42f 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java @@ -1,239 +1,108 @@ package org.schabi.newpipe.fragments.detail; -import android.os.Bundle; +import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; +import static org.schabi.newpipe.util.Localization.getAppLocale; + import android.view.LayoutInflater; import android.view.View; -import android.view.ViewGroup; import android.widget.LinearLayout; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; -import androidx.appcompat.widget.TooltipCompat; -import androidx.core.text.HtmlCompat; -import com.google.android.material.chip.Chip; - -import org.schabi.newpipe.BaseFragment; import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.FragmentDescriptionBinding; -import org.schabi.newpipe.databinding.ItemMetadataBinding; -import org.schabi.newpipe.databinding.ItemMetadataTagsBinding; +import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.stream.Description; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.external_communication.TextLinkifier; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; import icepick.State; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import static android.text.TextUtils.isEmpty; -import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; -import static org.schabi.newpipe.extractor.utils.Utils.isBlank; - -public class DescriptionFragment extends BaseFragment { +public class DescriptionFragment extends BaseDescriptionFragment { @State - StreamInfo streamInfo = null; - final CompositeDisposable descriptionDisposables = new CompositeDisposable(); - FragmentDescriptionBinding binding; - - public DescriptionFragment() { - } + StreamInfo streamInfo; public DescriptionFragment(final StreamInfo streamInfo) { this.streamInfo = streamInfo; } + + @Nullable @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - binding = FragmentDescriptionBinding.inflate(inflater, container, false); - if (streamInfo != null) { - setupUploadDate(); - setupDescription(); - setupMetadata(inflater, binding.detailMetadataLayout); - } - return binding.getRoot(); + protected Description getDescription() { + return streamInfo.getDescription(); + } + + @NonNull + @Override + protected StreamingService getService() { + return streamInfo.getService(); } @Override - public void onDestroy() { - descriptionDisposables.clear(); - super.onDestroy(); + protected int getServiceId() { + return streamInfo.getServiceId(); } + @NonNull + @Override + protected String getStreamUrl() { + return streamInfo.getUrl(); + } - private void setupUploadDate() { - if (streamInfo.getUploadDate() != null) { + @NonNull + @Override + public List getTags() { + return streamInfo.getTags(); + } + + @Override + protected void setupMetadata(final LayoutInflater inflater, + final LinearLayout layout) { + if (streamInfo != null && streamInfo.getUploadDate() != null) { binding.detailUploadDateView.setText(Localization .localizeUploadDate(activity, streamInfo.getUploadDate().offsetDateTime())); } else { binding.detailUploadDateView.setVisibility(View.GONE); } - } - - private void setupDescription() { - final Description description = streamInfo.getDescription(); - if (description == null || isEmpty(description.getContent()) - || description == Description.EMPTY_DESCRIPTION) { - binding.detailDescriptionView.setVisibility(View.GONE); - binding.detailSelectDescriptionButton.setVisibility(View.GONE); + if (streamInfo == null) { return; } - // start with disabled state. This also loads description content (!) - disableDescriptionSelection(); + addMetadataItem(inflater, layout, false, R.string.metadata_category, + streamInfo.getCategory()); - binding.detailSelectDescriptionButton.setOnClickListener(v -> { - if (binding.detailDescriptionNoteView.getVisibility() == View.VISIBLE) { - disableDescriptionSelection(); - } else { - // enable selection only when button is clicked to prevent flickering - enableDescriptionSelection(); - } - }); - } - - private void enableDescriptionSelection() { - binding.detailDescriptionNoteView.setVisibility(View.VISIBLE); - binding.detailDescriptionView.setTextIsSelectable(true); - - final String buttonLabel = getString(R.string.description_select_disable); - binding.detailSelectDescriptionButton.setContentDescription(buttonLabel); - TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel); - binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_close); - } - - private void disableDescriptionSelection() { - // show description content again, otherwise some links are not clickable - loadDescriptionContent(); - - binding.detailDescriptionNoteView.setVisibility(View.GONE); - binding.detailDescriptionView.setTextIsSelectable(false); - - final String buttonLabel = getString(R.string.description_select_enable); - binding.detailSelectDescriptionButton.setContentDescription(buttonLabel); - TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel); - binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all); - } - - private void loadDescriptionContent() { - final Description description = streamInfo.getDescription(); - switch (description.getType()) { - case Description.HTML: - TextLinkifier.createLinksFromHtmlBlock(binding.detailDescriptionView, - description.getContent(), HtmlCompat.FROM_HTML_MODE_LEGACY, streamInfo, - descriptionDisposables); - break; - case Description.MARKDOWN: - TextLinkifier.createLinksFromMarkdownText(binding.detailDescriptionView, - description.getContent(), streamInfo, descriptionDisposables); - break; - case Description.PLAIN_TEXT: default: - TextLinkifier.createLinksFromPlainText(binding.detailDescriptionView, - description.getContent(), streamInfo, descriptionDisposables); - break; - } - } - - - private void setupMetadata(final LayoutInflater inflater, - final LinearLayout layout) { - addMetadataItem(inflater, layout, false, - R.string.metadata_category, streamInfo.getCategory()); - - addMetadataItem(inflater, layout, false, - R.string.metadata_licence, streamInfo.getLicence()); + addMetadataItem(inflater, layout, false, R.string.metadata_licence, + streamInfo.getLicence()); addPrivacyMetadataItem(inflater, layout); if (streamInfo.getAgeLimit() != NO_AGE_LIMIT) { - addMetadataItem(inflater, layout, false, - R.string.metadata_age_limit, String.valueOf(streamInfo.getAgeLimit())); + addMetadataItem(inflater, layout, false, R.string.metadata_age_limit, + String.valueOf(streamInfo.getAgeLimit())); } if (streamInfo.getLanguageInfo() != null) { - addMetadataItem(inflater, layout, false, - R.string.metadata_language, streamInfo.getLanguageInfo().getDisplayLanguage()); + addMetadataItem(inflater, layout, false, R.string.metadata_language, + streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale(getContext()))); } - addMetadataItem(inflater, layout, true, - R.string.metadata_support, streamInfo.getSupportInfo()); - addMetadataItem(inflater, layout, true, - R.string.metadata_host, streamInfo.getHost()); - addMetadataItem(inflater, layout, true, - R.string.metadata_thumbnail_url, streamInfo.getThumbnailUrl()); + addMetadataItem(inflater, layout, true, R.string.metadata_support, + streamInfo.getSupportInfo()); + addMetadataItem(inflater, layout, true, R.string.metadata_host, + streamInfo.getHost()); - addTagsMetadataItem(inflater, layout); - } - - private void addMetadataItem(final LayoutInflater inflater, - final LinearLayout layout, - final boolean linkifyContent, - @StringRes final int type, - @Nullable final String content) { - if (isBlank(content)) { - return; - } - - final ItemMetadataBinding itemBinding - = ItemMetadataBinding.inflate(inflater, layout, false); - - itemBinding.metadataTypeView.setText(type); - itemBinding.metadataTypeView.setOnLongClickListener(v -> { - ShareUtils.copyToClipboard(requireContext(), content); - return true; - }); - - if (linkifyContent) { - TextLinkifier.createLinksFromPlainText(itemBinding.metadataContentView, content, null, - descriptionDisposables); - } else { - itemBinding.metadataContentView.setText(content); - } - - layout.addView(itemBinding.getRoot()); - } - - private void addTagsMetadataItem(final LayoutInflater inflater, final LinearLayout layout) { - if (streamInfo.getTags() != null && !streamInfo.getTags().isEmpty()) { - final ItemMetadataTagsBinding itemBinding - = ItemMetadataTagsBinding.inflate(inflater, layout, false); - - final List tags = new ArrayList<>(streamInfo.getTags()); - Collections.sort(tags); - for (final String tag : tags) { - final Chip chip = (Chip) inflater.inflate(R.layout.chip, - itemBinding.metadataTagsChips, false); - chip.setText(tag); - chip.setOnClickListener(this::onTagClick); - chip.setOnLongClickListener(this::onTagLongClick); - itemBinding.metadataTagsChips.addView(chip); - } - - layout.addView(itemBinding.getRoot()); - } - } - - private void onTagClick(final View chip) { - if (getParentFragment() != null) { - NavigationHelper.openSearchFragment(getParentFragment().getParentFragmentManager(), - streamInfo.getServiceId(), ((Chip) chip).getText().toString()); - } - } - - private boolean onTagLongClick(final View chip) { - ShareUtils.copyToClipboard(requireContext(), ((Chip) chip).getText().toString()); - return true; + addImagesMetadataItem(inflater, layout, R.string.metadata_thumbnails, + streamInfo.getThumbnails()); + addImagesMetadataItem(inflater, layout, R.string.metadata_uploader_avatars, + streamInfo.getUploaderAvatars()); + addImagesMetadataItem(inflater, layout, R.string.metadata_subchannel_avatars, + streamInfo.getSubChannelAvatars()); } private void addPrivacyMetadataItem(final LayoutInflater inflater, final LinearLayout layout) { @@ -252,14 +121,15 @@ public class DescriptionFragment extends BaseFragment { case INTERNAL: contentRes = R.string.metadata_privacy_internal; break; - case OTHER: default: + case OTHER: + default: contentRes = 0; break; } if (contentRes != 0) { - addMetadataItem(inflater, layout, false, - R.string.metadata_privacy, getString(contentRes)); + addMetadataItem(inflater, layout, false, R.string.metadata_privacy, + getString(contentRes)); } } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java index c57942aa5..95b54f65a 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java @@ -1,6 +1,19 @@ package org.schabi.newpipe.fragments.detail; +import static android.text.TextUtils.isEmpty; +import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.COMMENTS; +import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; +import static org.schabi.newpipe.ktx.ViewUtils.animate; +import static org.schabi.newpipe.ktx.ViewUtils.animateRotation; +import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientationLocked; +import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfirmationRequired; +import static org.schabi.newpipe.util.DependentPreferenceHelper.getResumePlaybackEnabled; +import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView; +import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; +import static org.schabi.newpipe.util.NavigationHelper.openPlayQueue; + import android.animation.ValueAnimator; +import android.annotation.SuppressLint; import android.app.Activity; import android.content.BroadcastReceiver; import android.content.Context; @@ -10,9 +23,7 @@ import android.content.SharedPreferences; import android.content.pm.ActivityInfo; import android.database.ContentObserver; import android.graphics.Color; -import android.graphics.Point; import android.graphics.Rect; -import android.graphics.drawable.Drawable; import android.net.Uri; import android.os.Build; import android.os.Bundle; @@ -31,6 +42,7 @@ import android.view.WindowManager; import android.view.animation.DecelerateInterpolator; import android.widget.FrameLayout; import android.widget.RelativeLayout; +import android.widget.Toast; import androidx.annotation.AttrRes; import androidx.annotation.NonNull; @@ -41,6 +53,7 @@ import androidx.appcompat.content.res.AppCompatResources; import androidx.appcompat.widget.Toolbar; import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; import androidx.preference.PreferenceManager; import com.google.android.exoplayer2.PlaybackException; @@ -48,7 +61,6 @@ import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.material.appbar.AppBarLayout; import com.google.android.material.bottomsheet.BottomSheetBehavior; import com.google.android.material.tabs.TabLayout; -import com.squareup.picasso.Callback; import org.schabi.newpipe.App; import org.schabi.newpipe.R; @@ -59,8 +71,9 @@ 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; +import org.schabi.newpipe.extractor.Image; import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.stream.AudioStream; @@ -71,14 +84,16 @@ import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.EmptyFragment; +import org.schabi.newpipe.fragments.MainFragment; import org.schabi.newpipe.fragments.list.comments.CommentsFragment; import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment; import org.schabi.newpipe.ktx.AnimationType; import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.player.MainPlayer; -import org.schabi.newpipe.player.MainPlayer.PlayerType; +import org.schabi.newpipe.local.playlist.LocalPlaylistFragment; import org.schabi.newpipe.player.Player; +import org.schabi.newpipe.player.PlayerService; +import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.player.event.OnKeyDownListener; import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener; import org.schabi.newpipe.player.helper.PlayerHelper; @@ -86,25 +101,31 @@ import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; +import org.schabi.newpipe.player.ui.MainPlayerUi; +import org.schabi.newpipe.player.ui.VideoPlayerUi; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.ExtractorHelper; +import org.schabi.newpipe.util.InfoCache; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; -import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.PlayButtonHelper; +import org.schabi.newpipe.util.StreamTypeUtil; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.external_communication.ShareUtils; +import org.schabi.newpipe.util.image.PicassoHelper; import java.util.ArrayList; -import java.util.Collections; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; import icepick.State; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; @@ -112,22 +133,9 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; -import static android.text.TextUtils.isEmpty; -import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.COMMENTS; -import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; -import static org.schabi.newpipe.ktx.ViewUtils.animate; -import static org.schabi.newpipe.ktx.ViewUtils.animateRotation; -import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientationLocked; -import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfirmationRequired; -import static org.schabi.newpipe.player.playqueue.PlayQueueItem.RECOVERY_UNSET; -import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView; - public final class VideoDetailFragment extends BaseStateFragment implements BackPressable, - SharedPreferences.OnSharedPreferenceChangeListener, - View.OnClickListener, - View.OnLongClickListener, PlayerServiceExtendedEventListener, OnKeyDownListener { public static final String KEY_SWITCHING_PLAYERS = "switching_players"; @@ -158,11 +166,29 @@ public final class VideoDetailFragment private boolean showRelatedItems; private boolean showDescription; private String selectedTabTag; - @AttrRes @NonNull final List tabIcons = new ArrayList<>(); - @StringRes @NonNull final List tabContentDescriptions = new ArrayList<>(); + @AttrRes + @NonNull + final List tabIcons = new ArrayList<>(); + @StringRes + @NonNull + final List tabContentDescriptions = new ArrayList<>(); private boolean tabSettingsChanged = false; private int lastAppBarVerticalOffset = Integer.MAX_VALUE; // prevents useless updates + private final SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener = + (sharedPreferences, key) -> { + if (getString(R.string.show_comments_key).equals(key)) { + showComments = sharedPreferences.getBoolean(key, true); + tabSettingsChanged = true; + } else if (getString(R.string.show_next_video_key).equals(key)) { + showRelatedItems = sharedPreferences.getBoolean(key, true); + tabSettingsChanged = true; + } else if (getString(R.string.show_description_key).equals(key)) { + showDescription = sharedPreferences.getBoolean(key, true); + tabSettingsChanged = true; + } + }; + @State protected int serviceId = Constants.NO_SERVICE_ID; @State @@ -176,6 +202,8 @@ public final class VideoDetailFragment @State int bottomSheetState = BottomSheetBehavior.STATE_EXPANDED; @State + int lastStableBottomSheetState = BottomSheetBehavior.STATE_EXPANDED; + @State protected boolean autoPlayEnabled = true; @Nullable @@ -186,9 +214,8 @@ public final class VideoDetailFragment @Nullable private Disposable positionSubscriber = null; - private List sortedVideoStreams; - private int selectedVideoStreamIndex = -1; private BottomSheetBehavior bottomSheetBehavior; + private BottomSheetBehavior.BottomSheetCallback bottomSheetCallback; private BroadcastReceiver broadcastReceiver; /*////////////////////////////////////////////////////////////////////////// @@ -201,7 +228,7 @@ public final class VideoDetailFragment private ContentObserver settingsContentObserver; @Nullable - private MainPlayer playerService; + private PlayerService playerService; private Player player; private final PlayerHolder playerHolder = PlayerHolder.getInstance(); @@ -210,7 +237,7 @@ public final class VideoDetailFragment //////////////////////////////////////////////////////////////////////////*/ @Override public void onServiceConnected(final Player connectedPlayer, - final MainPlayer connectedPlayerService, + final PlayerService connectedPlayerService, final boolean playAfterConnect) { player = connectedPlayer; playerService = connectedPlayerService; @@ -218,6 +245,7 @@ public final class VideoDetailFragment // It will do nothing if the player is not in fullscreen mode hideSystemUiIfNeeded(); + final Optional playerUi = player.UIs().get(MainPlayerUi.class); if (!player.videoPlayerSelected() && !playAfterConnect) { return; } @@ -226,25 +254,22 @@ public final class VideoDetailFragment // If the video is playing but orientation changed // let's make the video in fullscreen again checkLandscape(); - } else if (player.isFullscreen() && !player.isVerticalVideo() + } else if (playerUi.map(ui -> ui.isFullscreen() && !ui.isVerticalVideo()).orElse(false) // Tablet UI has orientation-independent fullscreen && !DeviceUtils.isTablet(activity)) { // Device is in portrait orientation after rotation but UI is in fullscreen. // Return back to non-fullscreen state - player.toggleFullscreen(); - } - - if (playerIsNotStopped() && player.videoPlayerSelected()) { - addVideoPlayerView(); + playerUi.ifPresent(MainPlayerUi::toggleFullscreen); } if (playAfterConnect || (currentInfo != null && isAutoplayEnabled() - && player.getParentActivity() == null)) { + && playerUi.isEmpty())) { autoPlayEnabled = true; // forcefully start playing openVideoPlayerAutoFullscreen(); } + updateOverlayPlayQueueButtonVisibility(); } @Override @@ -268,7 +293,7 @@ public final class VideoDetailFragment public static VideoDetailFragment getInstanceInCollapsedState() { final VideoDetailFragment instance = new VideoDetailFragment(); - instance.bottomSheetState = BottomSheetBehavior.STATE_COLLAPSED; + instance.updateBottomSheetState(BottomSheetBehavior.STATE_COLLAPSED); return instance; } @@ -287,7 +312,7 @@ public final class VideoDetailFragment showDescription = prefs.getBoolean(getString(R.string.show_description_key), true); selectedTabTag = prefs.getString( getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG); - prefs.registerOnSharedPreferenceChangeListener(this); + prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener); setupBroadcastReceiver(); @@ -328,9 +353,14 @@ public final class VideoDetailFragment @Override public void onResume() { super.onResume(); + if (DEBUG) { + Log.d(TAG, "onResume() called"); + } activity.sendBroadcast(new Intent(ACTION_VIDEO_FRAGMENT_RESUMED)); + updateOverlayPlayQueueButtonVisibility(); + setupBrightness(); if (tabSettingsChanged) { @@ -369,7 +399,7 @@ public final class VideoDetailFragment } PreferenceManager.getDefaultSharedPreferences(activity) - .unregisterOnSharedPreferenceChangeListener(this); + .unregisterOnSharedPreferenceChangeListener(preferenceChangeListener); activity.unregisterReceiver(broadcastReceiver); activity.getContentResolver().unregisterContentObserver(settingsContentObserver); @@ -382,7 +412,7 @@ public final class VideoDetailFragment disposables.clear(); positionSubscriber = null; currentWorker = null; - bottomSheetBehavior.setBottomSheetCallback(null); + bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback); if (activity.isFinishing()) { playQueue = null; @@ -415,121 +445,136 @@ public final class VideoDetailFragment } } - @Override - public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, - final String key) { - if (key.equals(getString(R.string.show_comments_key))) { - showComments = sharedPreferences.getBoolean(key, true); - tabSettingsChanged = true; - } else if (key.equals(getString(R.string.show_next_video_key))) { - showRelatedItems = sharedPreferences.getBoolean(key, true); - tabSettingsChanged = true; - } else if (key.equals(getString(R.string.show_description_key))) { - showDescription = sharedPreferences.getBoolean(key, true); - tabSettingsChanged = true; - } - } - /*////////////////////////////////////////////////////////////////////////// // OnClick //////////////////////////////////////////////////////////////////////////*/ - @Override - public void onClick(final View v) { - switch (v.getId()) { - case R.id.detail_controls_background: - openBackgroundPlayer(false); - break; - case R.id.detail_controls_popup: - openPopupPlayer(false); - break; - case R.id.detail_controls_playlist_append: - if (getFM() != null && currentInfo != null) { - disposables.add( - PlaylistDialog.createCorrespondingDialog( - getContext(), - Collections.singletonList(new StreamEntity(currentInfo)), - dialog -> dialog.show(getFM(), TAG) - ) - ); - } - break; - case R.id.detail_controls_download: - if (PermissionHelper.checkStoragePermissions(activity, - PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { - this.openDownloadDialog(); - } - break; - case R.id.detail_controls_share: - if (currentInfo != null) { - ShareUtils.shareText(requireContext(), currentInfo.getName(), - currentInfo.getUrl(), currentInfo.getThumbnailUrl()); - } - break; - case R.id.detail_controls_open_in_browser: - if (currentInfo != null) { - ShareUtils.openUrlInBrowser(requireContext(), currentInfo.getUrl()); - } - break; - case R.id.detail_controls_play_with_kodi: - if (currentInfo != null) { - try { - NavigationHelper.playWithKore( - requireContext(), Uri.parse(currentInfo.getUrl())); - } catch (final Exception e) { - if (DEBUG) { - Log.i(TAG, "Failed to start kore", e); - } - KoreUtils.showInstallKoreDialog(requireContext()); - } - } - break; - case R.id.detail_uploader_root_layout: - if (isEmpty(currentInfo.getSubChannelUrl())) { - if (!isEmpty(currentInfo.getUploaderUrl())) { - openChannel(currentInfo.getUploaderUrl(), currentInfo.getUploaderName()); - } - - if (DEBUG) { - Log.i(TAG, "Can't open sub-channel because we got no channel URL"); - } - } else { - openChannel(currentInfo.getSubChannelUrl(), - currentInfo.getSubChannelName()); - } - 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: - toggleTitleAndSecondaryControls(); - break; - case R.id.overlay_thumbnail: - case R.id.overlay_metadata_layout: - case R.id.overlay_buttons_layout: - bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED); - break; - case R.id.overlay_play_pause_button: - if (playerIsNotStopped()) { - player.playPause(); - player.hideControls(0, 0); - showSystemUi(); - } else { - autoPlayEnabled = true; // forcefully start playing - openVideoPlayer(false); + private void setOnClickListeners() { + binding.detailTitleRootLayout.setOnClickListener(v -> toggleTitleAndSecondaryControls()); + binding.detailUploaderRootLayout.setOnClickListener(makeOnClickListener(info -> { + if (isEmpty(info.getSubChannelUrl())) { + if (!isEmpty(info.getUploaderUrl())) { + openChannel(info.getUploaderUrl(), info.getUploaderName()); } - setOverlayPlayPauseImage(isPlayerAvailable() && player.isPlaying()); - break; - case R.id.overlay_close_button: - bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); - break; + if (DEBUG) { + Log.i(TAG, "Can't open sub-channel because we got no channel URL"); + } + } else { + openChannel(info.getSubChannelUrl(), info.getSubChannelName()); + } + })); + binding.detailThumbnailRootLayout.setOnClickListener(v -> { + autoPlayEnabled = true; // forcefully start playing + // FIXME Workaround #7427 + if (isPlayerAvailable()) { + player.setRecovery(); + } + openVideoPlayerAutoFullscreen(); + }); + + binding.detailControlsBackground.setOnClickListener(v -> openBackgroundPlayer(false)); + binding.detailControlsPopup.setOnClickListener(v -> openPopupPlayer(false)); + binding.detailControlsPlaylistAppend.setOnClickListener(makeOnClickListener(info -> { + if (getFM() != null && currentInfo != null) { + final Fragment fragment = getParentFragmentManager(). + findFragmentById(R.id.fragment_holder); + + // commit previous pending changes to database + if (fragment instanceof LocalPlaylistFragment) { + ((LocalPlaylistFragment) fragment).saveImmediate(); + } else if (fragment instanceof MainFragment) { + ((MainFragment) fragment).commitPlaylistTabs(); + } + + disposables.add(PlaylistDialog.createCorrespondingDialog(requireContext(), + List.of(new StreamEntity(info)), + dialog -> dialog.show(getParentFragmentManager(), TAG))); + } + })); + binding.detailControlsDownload.setOnClickListener(v -> { + if (PermissionHelper.checkStoragePermissions(activity, + PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { + openDownloadDialog(); + } + }); + binding.detailControlsShare.setOnClickListener(makeOnClickListener(info -> + ShareUtils.shareText(requireContext(), info.getName(), info.getUrl(), + info.getThumbnails()))); + binding.detailControlsOpenInBrowser.setOnClickListener(makeOnClickListener(info -> + ShareUtils.openUrlInBrowser(requireContext(), info.getUrl()))); + binding.detailControlsPlayWithKodi.setOnClickListener(makeOnClickListener(info -> + KoreUtils.playWithKore(requireContext(), Uri.parse(info.getUrl())))); + if (DEBUG) { + binding.detailControlsCrashThePlayer.setOnClickListener(v -> + VideoDetailPlayerCrasher.onCrashThePlayer(requireContext(), player)); } + + final View.OnClickListener overlayListener = v -> bottomSheetBehavior + .setState(BottomSheetBehavior.STATE_EXPANDED); + binding.overlayThumbnail.setOnClickListener(overlayListener); + binding.overlayMetadataLayout.setOnClickListener(overlayListener); + binding.overlayButtonsLayout.setOnClickListener(overlayListener); + binding.overlayCloseButton.setOnClickListener(v -> bottomSheetBehavior + .setState(BottomSheetBehavior.STATE_HIDDEN)); + binding.overlayPlayQueueButton.setOnClickListener(v -> openPlayQueue(requireContext())); + binding.overlayPlayPauseButton.setOnClickListener(v -> { + if (playerIsNotStopped()) { + player.playPause(); + player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); + showSystemUi(); + } else { + autoPlayEnabled = true; // forcefully start playing + openVideoPlayer(false); + } + + setOverlayPlayPauseImage(isPlayerAvailable() && player.isPlaying()); + }); + } + + private View.OnClickListener makeOnClickListener(final Consumer consumer) { + return v -> { + if (!isLoading.get() && currentInfo != null) { + consumer.accept(currentInfo); + } + }; + } + + private void setOnLongClickListeners() { + binding.detailTitleRootLayout.setOnLongClickListener(makeOnLongClickListener(info -> + ShareUtils.copyToClipboard(requireContext(), + binding.detailVideoTitleView.getText().toString()))); + binding.detailUploaderRootLayout.setOnLongClickListener(makeOnLongClickListener(info -> { + if (isEmpty(info.getSubChannelUrl())) { + Log.w(TAG, "Can't open parent channel because we got no parent channel URL"); + } else { + openChannel(info.getUploaderUrl(), info.getUploaderName()); + } + })); + + binding.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(info -> + openBackgroundPlayer(true) + )); + binding.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(info -> + openPopupPlayer(true) + )); + binding.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(info -> + NavigationHelper.openDownloads(activity))); + + final View.OnLongClickListener overlayListener = makeOnLongClickListener(info -> + openChannel(info.getUploaderUrl(), info.getUploaderName())); + binding.overlayThumbnail.setOnLongClickListener(overlayListener); + binding.overlayMetadataLayout.setOnLongClickListener(overlayListener); + } + + private View.OnLongClickListener makeOnLongClickListener(final Consumer consumer) { + return v -> { + if (isLoading.get() || currentInfo == null) { + return false; + } + consumer.accept(currentInfo); + return true; + }; } private void openChannel(final String subChannelUrl, final String subChannelName) { @@ -541,53 +586,16 @@ public final class VideoDetailFragment } } - @Override - public boolean onLongClick(final View v) { - if (isLoading.get() || currentInfo == null) { - return false; - } - - switch (v.getId()) { - case R.id.detail_controls_background: - openBackgroundPlayer(true); - break; - case R.id.detail_controls_popup: - openPopupPlayer(true); - break; - case R.id.detail_controls_download: - NavigationHelper.openDownloads(activity); - break; - case R.id.overlay_thumbnail: - case R.id.overlay_metadata_layout: - openChannel(currentInfo.getUploaderUrl(), currentInfo.getUploaderName()); - break; - case R.id.detail_uploader_root_layout: - if (isEmpty(currentInfo.getSubChannelUrl())) { - Log.w(TAG, - "Can't open parent channel because we got no parent channel URL"); - } else { - openChannel(currentInfo.getUploaderUrl(), currentInfo.getUploaderName()); - } - break; - case R.id.detail_title_root_layout: - ShareUtils.copyToClipboard(requireContext(), - binding.detailVideoTitleView.getText().toString()); - break; - } - - return true; - } - private void toggleTitleAndSecondaryControls() { if (binding.detailSecondaryControlPanel.getVisibility() == View.GONE) { binding.detailVideoTitleView.setMaxLines(10); animateRotation(binding.detailToggleSecondaryControlsView, - Player.DEFAULT_CONTROLS_DURATION, 180); + VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 180); binding.detailSecondaryControlPanel.setVisibility(View.VISIBLE); } else { binding.detailVideoTitleView.setMaxLines(1); animateRotation(binding.detailToggleSecondaryControlsView, - Player.DEFAULT_CONTROLS_DURATION, 0); + VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 0); binding.detailSecondaryControlPanel.setVisibility(View.GONE); } // view pager height has changed, update the tab layout @@ -598,11 +606,6 @@ public final class VideoDetailFragment // Init //////////////////////////////////////////////////////////////////////////*/ - @Override - public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { - super.onViewCreated(rootView, savedInstanceState); - } - @Override // called from onViewCreated in {@link BaseFragment#onViewCreated} protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); @@ -624,60 +627,28 @@ public final class VideoDetailFragment ? View.VISIBLE : View.GONE ); - - if (DeviceUtils.isTv(getContext())) { - // remove ripple effects from detail controls - final int transparent = ContextCompat.getColor(requireContext(), - R.color.transparent_background_color); - binding.detailControlsPlaylistAppend.setBackgroundColor(transparent); - binding.detailControlsBackground.setBackgroundColor(transparent); - binding.detailControlsPopup.setBackgroundColor(transparent); - binding.detailControlsDownload.setBackgroundColor(transparent); - binding.detailControlsShare.setBackgroundColor(transparent); - binding.detailControlsOpenInBrowser.setBackgroundColor(transparent); - binding.detailControlsPlayWithKodi.setBackgroundColor(transparent); - } + accommodateForTvAndDesktopMode(); } @Override + @SuppressLint("ClickableViewAccessibility") protected void initListeners() { super.initListeners(); - binding.detailTitleRootLayout.setOnClickListener(this); - binding.detailTitleRootLayout.setOnLongClickListener(this); - binding.detailUploaderRootLayout.setOnClickListener(this); - binding.detailUploaderRootLayout.setOnLongClickListener(this); - binding.detailThumbnailRootLayout.setOnClickListener(this); + setOnClickListeners(); + setOnLongClickListeners(); - binding.detailControlsBackground.setOnClickListener(this); - binding.detailControlsBackground.setOnLongClickListener(this); - binding.detailControlsPopup.setOnClickListener(this); - binding.detailControlsPopup.setOnLongClickListener(this); - binding.detailControlsPlaylistAppend.setOnClickListener(this); - binding.detailControlsDownload.setOnClickListener(this); - binding.detailControlsDownload.setOnLongClickListener(this); - binding.detailControlsShare.setOnClickListener(this); - binding.detailControlsOpenInBrowser.setOnClickListener(this); - binding.detailControlsPlayWithKodi.setOnClickListener(this); - if (DEBUG) { - binding.detailControlsCrashThePlayer.setOnClickListener( - v -> VideoDetailPlayerCrasher.onCrashThePlayer( - this.getContext(), - this.player, - getLayoutInflater()) - ); - } + final View.OnTouchListener controlsTouchListener = (view, motionEvent) -> { + if (motionEvent.getAction() == MotionEvent.ACTION_DOWN + && PlayButtonHelper.shouldShowHoldToAppendTip(activity)) { - binding.overlayThumbnail.setOnClickListener(this); - binding.overlayThumbnail.setOnLongClickListener(this); - binding.overlayMetadataLayout.setOnClickListener(this); - binding.overlayMetadataLayout.setOnLongClickListener(this); - binding.overlayButtonsLayout.setOnClickListener(this); - binding.overlayCloseButton.setOnClickListener(this); - binding.overlayPlayPauseButton.setOnClickListener(this); - - binding.detailControlsBackground.setOnTouchListener(getOnControlsTouchListener()); - binding.detailControlsPopup.setOnTouchListener(getOnControlsTouchListener()); + animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, 0, () -> + animate(binding.touchAppendDetail, false, 1500, AnimationType.ALPHA, 1000)); + } + return false; + }; + binding.detailControlsBackground.setOnTouchListener(controlsTouchListener); + binding.detailControlsPopup.setOnTouchListener(controlsTouchListener); binding.appBarLayout.addOnOffsetChangedListener((layout, verticalOffset) -> { // prevent useless updates to tab layout visibility if nothing changed @@ -696,44 +667,6 @@ public final class VideoDetailFragment } } - private View.OnTouchListener getOnControlsTouchListener() { - return (view, motionEvent) -> { - if (!PreferenceManager.getDefaultSharedPreferences(activity) - .getBoolean(getString(R.string.show_hold_to_append_key), true)) { - return false; - } - - if (motionEvent.getAction() == MotionEvent.ACTION_DOWN) { - animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, - 0, () -> - animate(binding.touchAppendDetail, false, 1500, - AnimationType.ALPHA, 1000)); - } - return false; - }; - } - - private void initThumbnailViews(@NonNull final StreamInfo info) { - PicassoHelper.loadThumbnail(info.getThumbnailUrl()).tag(PICASSO_VIDEO_DETAILS_TAG) - .into(binding.detailThumbnailImageView, new Callback() { - @Override - public void onSuccess() { - // nothing to do, the image was loaded correctly into the thumbnail - } - - @Override - public void onError(final Exception e) { - showSnackBarError(new ErrorInfo(e, UserAction.LOAD_IMAGE, - info.getThumbnailUrl(), info)); - } - }); - - PicassoHelper.loadAvatar(info.getSubChannelAvatarUrl()).tag(PICASSO_VIDEO_DETAILS_TAG) - .into(binding.detailSubChannelThumbnailView); - PicassoHelper.loadAvatar(info.getUploaderAvatarUrl()).tag(PICASSO_VIDEO_DETAILS_TAG) - .into(binding.detailUploaderThumbnailView); - } - /*////////////////////////////////////////////////////////////////////////// // OwnStack //////////////////////////////////////////////////////////////////////////*/ @@ -746,7 +679,9 @@ public final class VideoDetailFragment @Override public boolean onKeyDown(final int keyCode) { - return isPlayerAvailable() && player.onKeyDown(keyCode); + return isPlayerAvailable() + && player.UIs().get(VideoPlayerUi.class) + .map(playerUi -> playerUi.onKeyDown(keyCode)).orElse(false); } @Override @@ -756,7 +691,7 @@ public final class VideoDetailFragment } // If we are in fullscreen mode just exit from it via first back press - if (isPlayerAvailable() && player.isFullscreen()) { + if (isFullscreen()) { if (!DeviceUtils.isTablet(activity)) { player.pause(); } @@ -805,7 +740,7 @@ public final class VideoDetailFragment final boolean isPlayerStopped = !isPlayerAvailable() || player.isStopped(); if (playQueueItem != null && isPlayerStopped) { updateOverlayData(playQueueItem.getTitle(), - playQueueItem.getUploader(), playQueueItem.getThumbnailUrl()); + playQueueItem.getUploader(), playQueueItem.getThumbnails()); } } @@ -920,7 +855,8 @@ public final class VideoDetailFragment if (playQueue == null) { playQueue = new SinglePlayQueue(result); } - if (stack.isEmpty() || !stack.peek().getPlayQueue().equals(playQueue)) { + if (stack.isEmpty() || !stack.peek().getPlayQueue() + .equalStreams(playQueue)) { stack.push(new StackItem(serviceId, url, title, playQueue)); } } @@ -1006,8 +942,7 @@ public final class VideoDetailFragment getChildFragmentManager().beginTransaction() .replace(R.id.relatedItemsLayout, RelatedItemsFragment.getInstance(info)) .commitAllowingStateLoss(); - binding.relatedItemsLayout.setVisibility( - isPlayerAvailable() && player.isFullscreen() ? View.GONE : View.VISIBLE); + binding.relatedItemsLayout.setVisibility(isFullscreen() ? View.GONE : View.VISIBLE); } } @@ -1047,15 +982,13 @@ public final class VideoDetailFragment // call `post()` to be sure `viewPager.getHitRect()` // is up to date and not being currently recomputed binding.tabLayout.post(() -> { - if (getContext() != null) { + final var activity = getActivity(); + if (activity != null) { final Rect pagerHitRect = new Rect(); binding.viewPager.getHitRect(pagerHitRect); - final Point displaySize = new Point(); - Objects.requireNonNull(ContextCompat.getSystemService(getContext(), - WindowManager.class)).getDefaultDisplay().getSize(displaySize); - - final int viewPagerVisibleHeight = displaySize.y - pagerHitRect.top; + final int height = DeviceUtils.getWindowHeight(activity.getWindowManager()); + final int viewPagerVisibleHeight = height - pagerHitRect.top; // see TabLayout.DEFAULT_HEIGHT, which is equal to 48dp final float tabLayoutHeight = TypedValue.applyDimension( TypedValue.COMPLEX_UNIT_DIP, 48, getResources().getDisplayMetrics()); @@ -1080,6 +1013,20 @@ public final class VideoDetailFragment updateTabLayoutVisibility(); } + public void scrollToComment(final CommentsInfoItem comment) { + final int commentsTabPos = pageAdapter.getItemPositionByTitle(COMMENTS_TAB_TAG); + final Fragment fragment = pageAdapter.getItem(commentsTabPos); + if (!(fragment instanceof CommentsFragment)) { + return; + } + + // unexpand the app bar only if scrolling to the comment succeeded + if (((CommentsFragment) fragment).scrollToComment(comment)) { + binding.appBarLayout.setExpanded(false, false); + binding.viewPager.setCurrentItem(commentsTabPos, false); + } + } + /*////////////////////////////////////////////////////////////////////////// // Play Utils //////////////////////////////////////////////////////////////////////////*/ @@ -1087,15 +1034,16 @@ public final class VideoDetailFragment private void toggleFullscreenIfInFullscreenMode() { // If a user watched video inside fullscreen mode and than chose another player // return to non-fullscreen mode - if (isPlayerAvailable() && player.isFullscreen()) { - player.toggleFullscreen(); + if (isPlayerAvailable()) { + player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> { + if (playerUi.isFullscreen()) { + playerUi.toggleFullscreen(); + } + }); } } private void openBackgroundPlayer(final boolean append) { - final AudioStream audioStream = currentInfo.getAudioStreams() - .get(ListHelper.getDefaultAudioFormat(activity, currentInfo.getAudioStreams())); - final boolean useExternalAudioPlayer = PreferenceManager .getDefaultSharedPreferences(activity) .getBoolean(activity.getString(R.string.use_external_audio_player_key), false); @@ -1107,16 +1055,15 @@ public final class VideoDetailFragment player.setRecovery(); } - if (!useExternalAudioPlayer) { - openNormalBackgroundPlayer(append); + if (useExternalAudioPlayer) { + showExternalAudioPlaybackDialog(); } else { - startOnExternalPlayer(activity, currentInfo, audioStream); + openNormalBackgroundPlayer(append); } } private void openPopupPlayer(final boolean append) { - if (!PermissionHelper.isPopupEnabled(activity)) { - PermissionHelper.showPopupEnablementToast(activity); + if (!PermissionHelper.isPopupEnabledElseAsk(activity)) { return; } @@ -1157,14 +1104,14 @@ public final class VideoDetailFragment // doesn't tell which state it was settling to, and thus the bottom sheet settles to // STATE_COLLAPSED. This can be solved by manually setting the state that will be // restored (i.e. bottomSheetState) to STATE_EXPANDED. - bottomSheetState = BottomSheetBehavior.STATE_EXPANDED; + updateBottomSheetState(BottomSheetBehavior.STATE_EXPANDED); // toggle landscape in order to open directly in fullscreen onScreenRotationButtonClicked(); } if (PreferenceManager.getDefaultSharedPreferences(activity) .getBoolean(this.getString(R.string.use_external_video_player_key), false)) { - showExternalPlaybackDialog(); + showExternalVideoPlaybackDialog(); } else { replaceQueueIfUserConfirms(this::openMainPlayer); } @@ -1207,16 +1154,10 @@ public final class VideoDetailFragment } final PlayQueue queue = setupPlayQueueForIntent(false); - - // Video view can have elements visible from popup, - // We hide it here but once it ready the view will be shown in handleIntent() - if (playerService.getView() != null) { - playerService.getView().setVisibility(View.GONE); - } - addVideoPlayerView(); + tryAddVideoPlayerView(); final Intent playerIntent = NavigationHelper.getPlayerIntent(requireContext(), - MainPlayer.class, queue, true, autoPlayEnabled); + PlayerService.class, queue, true, autoPlayEnabled); ContextCompat.startForegroundService(activity, playerIntent); } @@ -1228,16 +1169,15 @@ public final class VideoDetailFragment * be reused in a few milliseconds and the flickering would be annoying. */ private void hideMainPlayerOnLoadingNewStream() { - if (!isPlayerServiceAvailable() - || playerService.getView() == null - || !player.videoPlayerSelected()) { + final var root = getRoot(); + if (!isPlayerServiceAvailable() || root.isEmpty() || !player.videoPlayerSelected()) { return; } removeVideoPlayerView(); if (isAutoplayEnabled()) { playerService.stopForImmediateReusing(); - playerService.getView().setVisibility(View.GONE); + root.ifPresent(view -> view.setVisibility(View.GONE)); } else { playerHolder.stopService(); } @@ -1294,27 +1234,41 @@ public final class VideoDetailFragment && PlayerHelper.isAutoplayAllowedByUser(requireContext()); } - private void addVideoPlayerView() { - if (!isPlayerAvailable() || getView() == null) { - return; + private void tryAddVideoPlayerView() { + if (isPlayerAvailable() && getView() != null) { + // Setup the surface view height, so that it fits the video correctly; this is done also + // here, and not only in the Handler, to avoid a choppy fullscreen rotation animation. + setHeightThumbnail(); } - // Check if viewHolder already contains a child - if (player.getRootView().getParent() != binding.playerPlaceholder) { - playerService.removeViewFromParent(); - } - setHeightThumbnail(); + // do all the null checks in the posted lambda, too, since the player, the binding and the + // view could be set or unset before the lambda gets executed on the next main thread cycle + new Handler(Looper.getMainLooper()).post(() -> { + if (!isPlayerAvailable() || getView() == null) { + return; + } - // Prevent from re-adding a view multiple times - if (player.getRootView().getParent() == null) { - binding.playerPlaceholder.addView(player.getRootView()); - } + // setup the surface view height, so that it fits the video correctly + setHeightThumbnail(); + + player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> { + // sometimes binding would be null here, even though getView() != null above u.u + if (binding != null) { + // prevent from re-adding a view multiple times + playerUi.removeViewFromParent(); + binding.playerPlaceholder.addView(playerUi.getBinding().getRoot()); + playerUi.setupVideoSurfaceIfNeeded(); + } + }); + }); } private void removeVideoPlayerView() { makeDefaultHeightForVideoPlaceholder(); - playerService.removeViewFromParent(); + if (player != null) { + player.UIs().get(VideoPlayerUi.class).ifPresent(VideoPlayerUi::removeViewFromParent); + } } private void makeDefaultHeightForVideoPlaceholder() { @@ -1355,7 +1309,7 @@ public final class VideoDetailFragment final boolean isPortrait = metrics.heightPixels > metrics.widthPixels; requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener); - if (isPlayerAvailable() && player.isFullscreen()) { + if (isFullscreen()) { final int height = (DeviceUtils.isInMultiWindow(activity) ? requireView() : activity.getWindow().getDecorView()).getHeight(); @@ -1380,8 +1334,9 @@ public final class VideoDetailFragment binding.detailThumbnailImageView.setMinimumHeight(newHeight); if (isPlayerAvailable()) { final int maxHeight = (int) (metrics.heightPixels * MAX_PLAYER_HEIGHT); - player.getSurfaceView() - .setHeights(newHeight, player.isFullscreen() ? newHeight : maxHeight); + player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> + ui.getBinding().surfaceView.setHeights(newHeight, + ui.isFullscreen() ? newHeight : maxHeight)); } } @@ -1490,14 +1445,14 @@ public final class VideoDetailFragment super.showLoading(); //if data is already cached, transition from VISIBLE -> INVISIBLE -> VISIBLE is not required - if (!ExtractorHelper.isCached(serviceId, url, InfoItem.InfoType.STREAM)) { + if (!ExtractorHelper.isCached(serviceId, url, InfoCache.Type.STREAM)) { binding.detailContentRootHiding.setVisibility(View.INVISIBLE); } animate(binding.detailThumbnailPlayButton, false, 50); animate(binding.detailDurationView, false, 100); - animate(binding.detailPositionView, false, 100); - animate(binding.positionView, false, 50); + binding.detailPositionView.setVisibility(View.GONE); + binding.positionView.setVisibility(View.GONE); binding.detailVideoTitleView.setText(title); binding.detailVideoTitleView.setMaxLines(1); @@ -1510,7 +1465,7 @@ public final class VideoDetailFragment if (binding.relatedItemsLayout != null) { if (showRelatedItems) { binding.relatedItemsLayout.setVisibility( - isPlayerAvailable() && player.isFullscreen() ? View.GONE : View.INVISIBLE); + isFullscreen() ? View.GONE : View.INVISIBLE); } else { binding.relatedItemsLayout.setVisibility(View.GONE); } @@ -1537,17 +1492,10 @@ public final class VideoDetailFragment if (!isEmpty(info.getSubChannelName())) { displayBothUploaderAndSubChannel(info); - } else if (!isEmpty(info.getUploaderName())) { - displayUploaderAsSubChannel(info); } else { - binding.detailUploaderTextView.setVisibility(View.GONE); - binding.detailUploaderThumbnailView.setVisibility(View.GONE); + displayUploaderAsSubChannel(info); } - final Drawable buddyDrawable = AppCompatResources.getDrawable(activity, R.drawable.buddy); - binding.detailSubChannelThumbnailView.setImageDrawable(buddyDrawable); - binding.detailUploaderThumbnailView.setImageDrawable(buddyDrawable); - if (info.getViewCount() >= 0) { if (info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { binding.detailViewCountView.setText(Localization.listeningCount(activity, @@ -1613,21 +1561,14 @@ public final class VideoDetailFragment binding.detailToggleSecondaryControlsView.setVisibility(View.VISIBLE); binding.detailSecondaryControlPanel.setVisibility(View.GONE); - sortedVideoStreams = ListHelper.getSortedStreamVideosList( - activity, - info.getVideoStreams(), - info.getVideoOnlyStreams(), - false, - false); - selectedVideoStreamIndex = ListHelper - .getDefaultResolutionIndex(activity, sortedVideoStreams); - updateProgressInfo(info); - initThumbnailViews(info); + checkUpdateProgressInfo(info); + PicassoHelper.loadDetailsThumbnail(info.getThumbnails()).tag(PICASSO_VIDEO_DETAILS_TAG) + .into(binding.detailThumbnailImageView); showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, binding.detailMetaInfoSeparator, disposables); if (!isPlayerAvailable() || player.isStopped()) { - updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnailUrl()); + updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails()); } if (!info.getErrors().isEmpty()) { @@ -1646,10 +1587,11 @@ public final class VideoDetailFragment } } - binding.detailControlsDownload.setVisibility(info.getStreamType() == StreamType.LIVE_STREAM - || info.getStreamType() == StreamType.AUDIO_LIVE_STREAM ? View.GONE : View.VISIBLE); - binding.detailControlsBackground.setVisibility(info.getAudioStreams().isEmpty() - ? View.GONE : View.VISIBLE); + binding.detailControlsDownload.setVisibility( + StreamTypeUtil.isLiveStream(info.getStreamType()) ? View.GONE : View.VISIBLE); + binding.detailControlsBackground.setVisibility( + info.getAudioStreams().isEmpty() && info.getVideoStreams().isEmpty() + ? View.GONE : View.VISIBLE); final boolean noVideoStreams = info.getVideoStreams().isEmpty() && info.getVideoOnlyStreams().isEmpty(); @@ -1662,7 +1604,19 @@ public final class VideoDetailFragment binding.detailSubChannelTextView.setText(info.getUploaderName()); binding.detailSubChannelTextView.setVisibility(View.VISIBLE); binding.detailSubChannelTextView.setSelected(true); - binding.detailUploaderTextView.setVisibility(View.GONE); + + if (info.getUploaderSubscriberCount() > -1) { + binding.detailUploaderTextView.setText( + Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount())); + binding.detailUploaderTextView.setVisibility(View.VISIBLE); + } else { + binding.detailUploaderTextView.setVisibility(View.GONE); + } + + PicassoHelper.loadAvatar(info.getUploaderAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG) + .into(binding.detailSubChannelThumbnailView); + binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); + binding.detailUploaderThumbnailView.setVisibility(View.GONE); } private void displayBothUploaderAndSubChannel(final StreamInfo info) { @@ -1670,16 +1624,33 @@ public final class VideoDetailFragment binding.detailSubChannelTextView.setVisibility(View.VISIBLE); binding.detailSubChannelTextView.setSelected(true); - binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); - + final StringBuilder subText = new StringBuilder(); if (!isEmpty(info.getUploaderName())) { - binding.detailUploaderTextView.setText( + subText.append( String.format(getString(R.string.video_detail_by), info.getUploaderName())); + } + if (info.getUploaderSubscriberCount() > -1) { + if (subText.length() > 0) { + subText.append(Localization.DOT_SEPARATOR); + } + subText.append( + Localization.shortSubscriberCount(activity, info.getUploaderSubscriberCount())); + } + + if (subText.length() > 0) { + binding.detailUploaderTextView.setText(subText); binding.detailUploaderTextView.setVisibility(View.VISIBLE); binding.detailUploaderTextView.setSelected(true); } else { binding.detailUploaderTextView.setVisibility(View.GONE); } + + PicassoHelper.loadAvatar(info.getSubChannelAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG) + .into(binding.detailSubChannelThumbnailView); + binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); + PicassoHelper.loadAvatar(info.getUploaderAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG) + .into(binding.detailUploaderThumbnailView); + binding.detailUploaderThumbnailView.setVisibility(View.VISIBLE); } public void openDownloadDialog() { @@ -1688,12 +1659,7 @@ public final class VideoDetailFragment } try { - final DownloadDialog downloadDialog = DownloadDialog.newInstance(currentInfo); - downloadDialog.setVideoStreams(sortedVideoStreams); - downloadDialog.setAudioStreams(currentInfo.getAudioStreams()); - downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex); - downloadDialog.setSubtitleStreams(currentInfo.getSubtitles()); - + final DownloadDialog downloadDialog = new DownloadDialog(activity, currentInfo); downloadDialog.show(activity.getSupportFragmentManager(), "downloadDialog"); } catch (final Exception e) { ErrorUtil.showSnackbar(activity, new ErrorInfo(e, UserAction.DOWNLOAD_OPEN_DIALOG, @@ -1705,68 +1671,43 @@ public final class VideoDetailFragment // Stream Results //////////////////////////////////////////////////////////////////////////*/ - private void updateProgressInfo(@NonNull final StreamInfo info) { + private void checkUpdateProgressInfo(@NonNull final StreamInfo info) { if (positionSubscriber != null) { positionSubscriber.dispose(); } - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); - final boolean playbackResumeEnabled = prefs - .getBoolean(activity.getString(R.string.enable_watch_history_key), true) - && prefs.getBoolean(activity.getString(R.string.enable_playback_resume_key), true); - final boolean showPlaybackPosition = prefs.getBoolean( - activity.getString(R.string.enable_playback_state_lists_key), true); - if (!playbackResumeEnabled) { - if (playQueue == null || playQueue.getStreams().isEmpty() - || playQueue.getItem().getRecoveryPosition() == RECOVERY_UNSET - || !showPlaybackPosition) { - binding.positionView.setVisibility(View.INVISIBLE); - binding.detailPositionView.setVisibility(View.GONE); - // TODO: Remove this check when separation of concerns is done. - // (live streams weren't getting updated because they are mixed) - if (!info.getStreamType().equals(StreamType.LIVE_STREAM) - && !info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { - return; - } - } else { - // Show saved position from backStack if user allows it - showPlaybackProgress(playQueue.getItem().getRecoveryPosition(), - playQueue.getItem().getDuration() * 1000); - animate(binding.positionView, true, 500); - animate(binding.detailPositionView, true, 500); - } + if (!getResumePlaybackEnabled(activity)) { + binding.positionView.setVisibility(View.GONE); + binding.detailPositionView.setVisibility(View.GONE); return; } final HistoryRecordManager recordManager = new HistoryRecordManager(requireContext()); - - // TODO: Separate concerns when updating database data. - // (move the updating part to when the loading happens) positionSubscriber = recordManager.loadStreamState(info) .subscribeOn(Schedulers.io()) .onErrorComplete() .observeOn(AndroidSchedulers.mainThread()) .subscribe(state -> { - showPlaybackProgress(state.getProgressMillis(), info.getDuration() * 1000); - animate(binding.positionView, true, 500); - animate(binding.detailPositionView, true, 500); + updatePlaybackProgress( + state.getProgressMillis(), info.getDuration() * 1000); }, e -> { - if (DEBUG) { - e.printStackTrace(); - } + // impossible since the onErrorComplete() }, () -> { binding.positionView.setVisibility(View.GONE); binding.detailPositionView.setVisibility(View.GONE); }); } - private void showPlaybackProgress(final long progress, final long duration) { + private void updatePlaybackProgress(final long progress, final long duration) { + if (!getResumePlaybackEnabled(activity)) { + return; + } final int progressSeconds = (int) TimeUnit.MILLISECONDS.toSeconds(progress); final int durationSeconds = (int) TimeUnit.MILLISECONDS.toSeconds(duration); - // If the old and the new progress values have a big difference then use - // animation. Otherwise don't because it affects CPU - final boolean shouldAnimate = Math.abs(binding.positionView.getProgress() - - progressSeconds) > 2; + // If the old and the new progress values have a big difference then use animation. + // Otherwise don't because it affects CPU + final int progressDifference = Math.abs(binding.positionView.getProgress() + - progressSeconds); binding.positionView.setMax(durationSeconds); - if (shouldAnimate) { + if (progressDifference > 2) { binding.positionView.setProgressAnimated(progressSeconds); } else { binding.positionView.setProgress(progressSeconds); @@ -1785,6 +1726,11 @@ public final class VideoDetailFragment // Player event listener //////////////////////////////////////////////////////////////////////////*/ + @Override + public void onViewCreated() { + tryAddVideoPlayerView(); + } + @Override public void onQueueUpdate(final PlayQueue queue) { playQueue = queue; @@ -1794,12 +1740,20 @@ public final class VideoDetailFragment + title + "], playQueue = [" + playQueue + "]"); } + // Register broadcast receiver to listen to playQueue changes + // and hide the overlayPlayQueueButton when the playQueue is empty / destroyed. + if (playQueue != null && playQueue.getBroadcastReceiver() != null) { + playQueue.getBroadcastReceiver().subscribe( + event -> updateOverlayPlayQueueButtonVisibility() + ); + } + // This should be the only place where we push data to stack. // It will allow to have live instance of PlayQueue with actual information about // deleted/added items inside Channel/Playlist queue and makes possible to have // a history of played items @Nullable final StackItem stackPeek = stack.peek(); - if (stackPeek != null && !stackPeek.getPlayQueue().equals(queue)) { + if (stackPeek != null && !stackPeek.getPlayQueue().equalStreams(queue)) { @Nullable final PlayQueueItem playQueueItem = queue.getItem(); if (playQueueItem != null) { stack.push(new StackItem(playQueueItem.getServiceId(), playQueueItem.getUrl(), @@ -1848,7 +1802,7 @@ public final class VideoDetailFragment } if (player.getPlayQueue().getItem().getUrl().equals(url)) { - showPlaybackProgress(currentProgress, duration); + updatePlaybackProgress(currentProgress, duration); } } @@ -1865,11 +1819,11 @@ public final class VideoDetailFragment // They are not equal when user watches something in popup while browsing in fragment and // then changes screen orientation. In that case the fragment will set itself as // a service listener and will receive initial call to onMetadataUpdate() - if (!queue.equals(playQueue)) { + if (!queue.equalStreams(playQueue)) { return; } - updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnailUrl()); + updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails()); if (currentInfo != null && info.getUrl().equals(currentInfo.getUrl())) { return; } @@ -1898,22 +1852,17 @@ public final class VideoDetailFragment if (currentInfo != null) { updateOverlayData(currentInfo.getName(), currentInfo.getUploaderName(), - currentInfo.getThumbnailUrl()); + currentInfo.getThumbnails()); } + updateOverlayPlayQueueButtonVisibility(); } @Override public void onFullscreenStateChanged(final boolean fullscreen) { setupBrightness(); if (!isPlayerAndPlayerServiceAvailable() - || playerService.getView() == null - || player.getParentActivity() == null) { - return; - } - - final View view = playerService.getView(); - final ViewGroup parent = (ViewGroup) view.getParent(); - if (parent == null) { + || player.UIs().get(MainPlayerUi.class).isEmpty() + || getRoot().map(View::getParent).isEmpty()) { return; } @@ -1929,13 +1878,7 @@ public final class VideoDetailFragment } scrollToTop(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - addVideoPlayerView(); - } else { - // KitKat needs a delay before addVideoPlayerView call or it reports wrong height in - // activity.getWindow().getDecorView().getHeight() - new Handler().post(this::addVideoPlayerView); - } + tryAddVideoPlayerView(); } @Override @@ -1947,7 +1890,7 @@ public final class VideoDetailFragment final boolean isLandscape = DeviceUtils.isLandscape(requireContext()); if (DeviceUtils.isTablet(activity) && (!globalScreenOrientationLocked(activity) || isLandscape)) { - player.toggleFullscreen(); + player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen); return; } @@ -1998,10 +1941,8 @@ public final class VideoDetailFragment } activity.getWindow().getDecorView().setSystemUiVisibility(0); activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - activity.getWindow().setStatusBarColor(ThemeHelper.resolveColorFromAttr( - requireContext(), android.R.attr.colorPrimary)); - } + activity.getWindow().setStatusBarColor(ThemeHelper.resolveColorFromAttr( + requireContext(), android.R.attr.colorPrimary)); } private void hideSystemUi() { @@ -2032,8 +1973,7 @@ public final class VideoDetailFragment } activity.getWindow().getDecorView().setSystemUiVisibility(visibility); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP - && (isInMultiWindow || (isPlayerAvailable() && player.isFullscreen()))) { + if (isInMultiWindow || isFullscreen()) { activity.getWindow().setStatusBarColor(Color.TRANSPARENT); activity.getWindow().setNavigationBarColor(Color.TRANSPARENT); } @@ -2041,14 +1981,19 @@ public final class VideoDetailFragment } // Listener implementation + @Override public void hideSystemUiIfNeeded() { - if (isPlayerAvailable() - && player.isFullscreen() + if (isFullscreen() && bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { hideSystemUi(); } } + private boolean isFullscreen() { + return isPlayerAvailable() && player.UIs().get(VideoPlayerUi.class) + .map(VideoPlayerUi::isFullscreen).orElse(false); + } + private boolean playerIsNotStopped() { return isPlayerAvailable() && !player.isStopped(); } @@ -2071,15 +2016,15 @@ public final class VideoDetailFragment } final WindowManager.LayoutParams lp = activity.getWindow().getAttributes(); - if (!isPlayerAvailable() - || !player.videoPlayerSelected() - || !player.isFullscreen() - || bottomSheetState != BottomSheetBehavior.STATE_EXPANDED) { + if (!isFullscreen() || bottomSheetState != BottomSheetBehavior.STATE_EXPANDED) { // Apply system brightness when the player is not in fullscreen restoreDefaultBrightness(); } else { // Do not restore if user has disabled brightness gesture - if (!PlayerHelper.isBrightnessGestureEnabled(activity)) { + if (!PlayerHelper.getActionForRightGestureSide(activity) + .equals(getString(R.string.brightness_control_key)) + && !PlayerHelper.getActionForLeftGestureSide(activity) + .equals(getString(R.string.brightness_control_key))) { return; } // Restore already saved brightness level @@ -2092,13 +2037,37 @@ public final class VideoDetailFragment } } + /** + * Make changes to the UI to accommodate for better usability on bigger screens such as TVs + * or in Android's desktop mode (DeX etc). + */ + private void accommodateForTvAndDesktopMode() { + if (DeviceUtils.isTv(getContext())) { + // remove ripple effects from detail controls + final int transparent = ContextCompat.getColor(requireContext(), + R.color.transparent_background_color); + binding.detailControlsPlaylistAppend.setBackgroundColor(transparent); + binding.detailControlsBackground.setBackgroundColor(transparent); + binding.detailControlsPopup.setBackgroundColor(transparent); + binding.detailControlsDownload.setBackgroundColor(transparent); + binding.detailControlsShare.setBackgroundColor(transparent); + binding.detailControlsOpenInBrowser.setBackgroundColor(transparent); + binding.detailControlsPlayWithKodi.setBackgroundColor(transparent); + } + if (DeviceUtils.isDesktopMode(getContext())) { + // Remove the "hover" overlay (since it is visible on all mouse events and interferes + // with the video content being played) + binding.detailThumbnailRootLayout.setForeground(null); + } + } + private void checkLandscape() { if ((!player.isPlaying() && player.getPlayQueue() != playQueue) || player.getPlayQueue() == null) { setAutoPlay(true); } - player.checkLandscape(); + player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::checkLandscape); // Let's give a user time to look at video information page if video is not playing if (globalScreenOrientationLocked(activity) && !player.isPlaying()) { player.play(); @@ -2119,7 +2088,7 @@ public final class VideoDetailFragment final Iterator iterator = stack.descendingIterator(); while (iterator.hasNext()) { final StackItem next = iterator.next(); - if (next.getPlayQueue().equals(queue)) { + if (next.getPlayQueue().equalStreams(queue)) { item = next; break; } @@ -2134,7 +2103,7 @@ public final class VideoDetailFragment if (isClearingQueueConfirmationRequired(activity) && playerIsNotStopped() && activeQueue != null - && !activeQueue.equals(playQueue)) { + && !activeQueue.equalStreams(playQueue)) { showClearingQueueConfirmation(onAllow); } else { onAllow.run(); @@ -2148,33 +2117,94 @@ public final class VideoDetailFragment .setPositiveButton(R.string.ok, (dialog, which) -> { onAllow.run(); dialog.dismiss(); - }).show(); + }) + .show(); } - private void showExternalPlaybackDialog() { - if (sortedVideoStreams == null) { + private void showExternalVideoPlaybackDialog() { + if (currentInfo == null) { return; } - final CharSequence[] resolutions = new CharSequence[sortedVideoStreams.size()]; - for (int i = 0; i < sortedVideoStreams.size(); i++) { - resolutions[i] = sortedVideoStreams.get(i).getResolution(); - } - final AlertDialog.Builder builder = new AlertDialog.Builder(activity) - .setNegativeButton(R.string.cancel, null) - .setNeutralButton(R.string.open_in_browser, (dialog, i) -> - ShareUtils.openUrlInBrowser(requireActivity(), url) + + final AlertDialog.Builder builder = new AlertDialog.Builder(activity); + builder.setTitle(R.string.select_quality_external_players); + builder.setNeutralButton(R.string.open_in_browser, (dialog, i) -> + ShareUtils.openUrlInBrowser(requireActivity(), url)); + + final List videoStreamsForExternalPlayers = + ListHelper.getSortedStreamVideosList( + activity, + getUrlAndNonTorrentStreams(currentInfo.getVideoStreams()), + getUrlAndNonTorrentStreams(currentInfo.getVideoOnlyStreams()), + false, + false ); - // Maybe there are no video streams available, show just `open in browser` button - if (resolutions.length > 0) { - builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndex, (dialog, i) -> { - dialog.dismiss(); - startOnExternalPlayer(activity, currentInfo, sortedVideoStreams.get(i)); - } - ); + + if (videoStreamsForExternalPlayers.isEmpty()) { + builder.setMessage(R.string.no_video_streams_available_for_external_players); + builder.setPositiveButton(R.string.ok, null); + + } else { + final int selectedVideoStreamIndexForExternalPlayers = + ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers); + final CharSequence[] resolutions = videoStreamsForExternalPlayers.stream() + .map(VideoStream::getResolution).toArray(CharSequence[]::new); + + builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndexForExternalPlayers, + null); + builder.setNegativeButton(R.string.cancel, null); + builder.setPositiveButton(R.string.ok, (dialog, i) -> { + final int index = ((AlertDialog) dialog).getListView().getCheckedItemPosition(); + // We don't have to manage the index validity because if there is no stream + // available for external players, this code will be not executed and if there is + // no stream which matches the default resolution, 0 is returned by + // ListHelper.getDefaultResolutionIndex. + // The index cannot be outside the bounds of the list as its always between 0 and + // the list size - 1, . + startOnExternalPlayer(activity, currentInfo, + videoStreamsForExternalPlayers.get(index)); + }); } builder.show(); } + private void showExternalAudioPlaybackDialog() { + if (currentInfo == null) { + return; + } + + final List audioStreams = getUrlAndNonTorrentStreams( + currentInfo.getAudioStreams()); + final List audioTracks = + ListHelper.getFilteredAudioStreams(activity, audioStreams); + + if (audioTracks.isEmpty()) { + Toast.makeText(activity, R.string.no_audio_streams_available_for_external_players, + Toast.LENGTH_SHORT).show(); + } else if (audioTracks.size() == 1) { + startOnExternalPlayer(activity, currentInfo, audioTracks.get(0)); + } else { + final int selectedAudioStream = + ListHelper.getDefaultAudioFormat(activity, audioTracks); + final CharSequence[] trackNames = audioTracks.stream() + .map(audioStream -> Localization.audioTrackName(activity, audioStream)) + .toArray(CharSequence[]::new); + + new AlertDialog.Builder(activity) + .setTitle(R.string.select_audio_track_external_players) + .setNeutralButton(R.string.open_in_browser, (dialog, i) -> + ShareUtils.openUrlInBrowser(requireActivity(), url)) + .setSingleChoiceItems(trackNames, selectedAudioStream, null) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.ok, (dialog, i) -> { + final int index = ((AlertDialog) dialog).getListView() + .getCheckedItemPosition(); + startOnExternalPlayer(activity, currentInfo, audioTracks.get(index)); + }) + .show(); + } + } + /* * Remove unneeded information while waiting for a next task * */ @@ -2187,7 +2217,7 @@ public final class VideoDetailFragment playerHolder.stopService(); setInitialData(0, null, "", null); currentInfo = null; - updateOverlayData(null, null, null); + updateOverlayData(null, null, List.of()); } /*////////////////////////////////////////////////////////////////////////// @@ -2259,7 +2289,9 @@ public final class VideoDetailFragment final FrameLayout bottomSheetLayout = activity.findViewById(R.id.fragment_player_holder); bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout); - bottomSheetBehavior.setState(bottomSheetState); + bottomSheetBehavior.setState(lastStableBottomSheetState); + updateBottomSheetState(lastStableBottomSheetState); + final int peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height); if (bottomSheetState != BottomSheetBehavior.STATE_HIDDEN) { manageSpaceAtTheBottom(false); @@ -2272,10 +2304,10 @@ public final class VideoDetailFragment } } - bottomSheetBehavior.setBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() { + bottomSheetCallback = new BottomSheetBehavior.BottomSheetCallback() { @Override public void onStateChanged(@NonNull final View bottomSheet, final int newState) { - bottomSheetState = newState; + updateBottomSheetState(newState); switch (newState) { case BottomSheetBehavior.STATE_HIDDEN: @@ -2298,10 +2330,10 @@ public final class VideoDetailFragment if (DeviceUtils.isLandscape(requireContext()) && isPlayerAvailable() && player.isPlaying() - && !player.isFullscreen() - && !DeviceUtils.isTablet(activity) - && player.videoPlayerSelected()) { - player.toggleFullscreen(); + && !isFullscreen() + && !DeviceUtils.isTablet(activity)) { + player.UIs().get(MainPlayerUi.class) + .ifPresent(MainPlayerUi::toggleFullscreen); } setOverlayLook(binding.appBarLayout, behavior, 1); break; @@ -2314,19 +2346,26 @@ public final class VideoDetailFragment // Re-enable clicks setOverlayElementsClickable(true); if (isPlayerAvailable()) { - player.closeItemsList(); + player.UIs().get(MainPlayerUi.class) + .ifPresent(MainPlayerUi::closeItemsList); } setOverlayLook(binding.appBarLayout, behavior, 0); break; case BottomSheetBehavior.STATE_DRAGGING: case BottomSheetBehavior.STATE_SETTLING: - if (isPlayerAvailable() && player.isFullscreen()) { + if (isFullscreen()) { showSystemUi(); } - if (isPlayerAvailable() && player.isControlsVisible()) { - player.hideControls(0, 0); + if (isPlayerAvailable()) { + player.UIs().get(MainPlayerUi.class).ifPresent(ui -> { + if (ui.isControlsVisible()) { + ui.hideControls(0, 0); + } + }); } break; + case BottomSheetBehavior.STATE_HALF_EXPANDED: + break; } } @@ -2334,7 +2373,9 @@ public final class VideoDetailFragment public void onSlide(@NonNull final View bottomSheet, final float slideOffset) { setOverlayLook(binding.appBarLayout, behavior, slideOffset); } - }); + }; + + bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback); // User opened a new page and the player will hide itself activity.getSupportFragmentManager().addOnBackStackChangedListener(() -> { @@ -2344,13 +2385,25 @@ public final class VideoDetailFragment }); } + private void updateOverlayPlayQueueButtonVisibility() { + final boolean isPlayQueueEmpty = + player == null // no player => no play queue :) + || player.getPlayQueue() == null + || player.getPlayQueue().isEmpty(); + if (binding != null) { + // binding is null when rotating the device... + binding.overlayPlayQueueButton.setVisibility( + isPlayQueueEmpty ? View.GONE : View.VISIBLE); + } + } + private void updateOverlayData(@Nullable final String overlayTitle, @Nullable final String uploader, - @Nullable final String thumbnailUrl) { + @NonNull final List thumbnails) { binding.overlayTitleTextView.setText(isEmpty(overlayTitle) ? "" : overlayTitle); binding.overlayChannelTextView.setText(isEmpty(uploader) ? "" : uploader); - binding.overlayThumbnail.setImageResource(R.drawable.dummy_thumbnail_dark); - PicassoHelper.loadThumbnail(thumbnailUrl).tag(PICASSO_VIDEO_DETAILS_TAG) + binding.overlayThumbnail.setImageDrawable(null); + PicassoHelper.loadDetailsThumbnail(thumbnails).tag(PICASSO_VIDEO_DETAILS_TAG) .into(binding.overlayThumbnail); } @@ -2382,20 +2435,35 @@ public final class VideoDetailFragment binding.overlayMetadataLayout.setClickable(enable); binding.overlayMetadataLayout.setLongClickable(enable); binding.overlayButtonsLayout.setClickable(enable); + binding.overlayPlayQueueButton.setClickable(enable); binding.overlayPlayPauseButton.setClickable(enable); binding.overlayCloseButton.setClickable(enable); } // helpers to check the state of player and playerService boolean isPlayerAvailable() { - return (player != null); + return player != null; } boolean isPlayerServiceAvailable() { - return (playerService != null); + return playerService != null; } boolean isPlayerAndPlayerServiceAvailable() { - return (player != null && playerService != null); + return player != null && playerService != null; + } + + public Optional getRoot() { + return Optional.ofNullable(player) + .flatMap(player1 -> player1.UIs().get(VideoPlayerUi.class)) + .map(playerUi -> playerUi.getBinding().getRoot()); + } + + private void updateBottomSheetState(final int newState) { + bottomSheetState = newState; + if (newState != BottomSheetBehavior.STATE_DRAGGING + && newState != BottomSheetBehavior.STATE_SETTLING) { + lastStableBottomSheetState = newState; + } } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java index ae704e88c..c816723ff 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailPlayerCrasher.java @@ -1,7 +1,12 @@ package org.schabi.newpipe.fragments.detail; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_DECODING_FAILED; +import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_UNSPECIFIED; + import android.content.Context; import android.util.Log; +import android.util.Pair; import android.view.ContextThemeWrapper; import android.view.LayoutInflater; import android.view.ViewGroup; @@ -24,15 +29,9 @@ import org.schabi.newpipe.player.Player; import org.schabi.newpipe.util.ThemeHelper; import java.io.IOException; -import java.util.Collections; -import java.util.LinkedHashMap; -import java.util.Map; +import java.util.List; import java.util.function.Supplier; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_DECODING_FAILED; -import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_UNSPECIFIED; - /** * Outsourced logic for crashing the player in the {@link VideoDetailFragment}. */ @@ -43,50 +42,34 @@ public final class VideoDetailPlayerCrasher { // https://stackoverflow.com/a/54744028 private static final String TAG = "VideoDetPlayerCrasher"; - private static final Map> AVAILABLE_EXCEPTION_TYPES = - getExceptionTypes(); + private static final String DEFAULT_MSG = "Dummy"; + + private static final List>> + AVAILABLE_EXCEPTION_TYPES = List.of( + new Pair<>("Source", () -> ExoPlaybackException.createForSource( + new IOException(DEFAULT_MSG), + ERROR_CODE_BEHIND_LIVE_WINDOW + )), + new Pair<>("Renderer", () -> ExoPlaybackException.createForRenderer( + new Exception(DEFAULT_MSG), + "Dummy renderer", + 0, + null, + C.FORMAT_HANDLED, + /*isRecoverable=*/false, + ERROR_CODE_DECODING_FAILED + )), + new Pair<>("Unexpected", () -> ExoPlaybackException.createForUnexpected( + new RuntimeException(DEFAULT_MSG), + ERROR_CODE_UNSPECIFIED + )), + new Pair<>("Remote", () -> ExoPlaybackException.createForRemote(DEFAULT_MSG)) + ); private VideoDetailPlayerCrasher() { // No impls } - private static Map> getExceptionTypes() { - final String defaultMsg = "Dummy"; - final Map> exceptionTypes = new LinkedHashMap<>(); - exceptionTypes.put( - "Source", - () -> ExoPlaybackException.createForSource( - new IOException(defaultMsg), - ERROR_CODE_BEHIND_LIVE_WINDOW - ) - ); - exceptionTypes.put( - "Renderer", - () -> ExoPlaybackException.createForRenderer( - new Exception(defaultMsg), - "Dummy renderer", - 0, - null, - C.FORMAT_HANDLED, - /*isRecoverable=*/false, - ERROR_CODE_DECODING_FAILED - ) - ); - exceptionTypes.put( - "Unexpected", - () -> ExoPlaybackException.createForUnexpected( - new RuntimeException(defaultMsg), - ERROR_CODE_UNSPECIFIED - ) - ); - exceptionTypes.put( - "Remote", - () -> ExoPlaybackException.createForRemote(defaultMsg) - ); - - return Collections.unmodifiableMap(exceptionTypes); - } - private static Context getThemeWrapperContext(final Context context) { return new ContextThemeWrapper( context, @@ -97,8 +80,7 @@ public final class VideoDetailPlayerCrasher { public static void onCrashThePlayer( @NonNull final Context context, - @Nullable final Player player, - @NonNull final LayoutInflater layoutInflater + @Nullable final Player player ) { if (player == null) { Log.d(TAG, "Player is not available"); @@ -109,24 +91,22 @@ public final class VideoDetailPlayerCrasher { } // -- Build the dialog/UI -- - final Context themeWrapperContext = getThemeWrapperContext(context); - final LayoutInflater inflater = LayoutInflater.from(themeWrapperContext); - final RadioGroup radioGroup = SingleChoiceDialogViewBinding.inflate(layoutInflater) - .list; - final AlertDialog alertDialog = new AlertDialog.Builder(getThemeWrapperContext(context)) + final SingleChoiceDialogViewBinding binding = + SingleChoiceDialogViewBinding.inflate(inflater); + + final AlertDialog alertDialog = new AlertDialog.Builder(themeWrapperContext) .setTitle("Choose an exception") - .setView(radioGroup) + .setView(binding.getRoot()) .setCancelable(true) .setNegativeButton(R.string.cancel, null) .create(); - for (final Map.Entry> entry - : AVAILABLE_EXCEPTION_TYPES.entrySet()) { + for (final Pair> entry : AVAILABLE_EXCEPTION_TYPES) { final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater).getRoot(); - radioButton.setText(entry.getKey()); + radioButton.setText(entry.first); radioButton.setChecked(false); radioButton.setLayoutParams( new RadioGroup.LayoutParams( @@ -135,12 +115,10 @@ public final class VideoDetailPlayerCrasher { ) ); radioButton.setOnClickListener(v -> { - tryCrashPlayerWith(player, entry.getValue().get()); - if (alertDialog != null) { - alertDialog.cancel(); - } + tryCrashPlayerWith(player, entry.second.get()); + alertDialog.cancel(); }); - radioGroup.addView(radioButton); + binding.list.addView(radioButton); } alertDialog.show(); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java index 27e5a8571..8a117a47a 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListFragment.java @@ -5,7 +5,6 @@ import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingSc import android.content.Context; import android.content.SharedPreferences; -import android.content.res.Configuration; import android.content.res.Resources; import android.os.Bundle; import android.util.Log; @@ -23,17 +22,16 @@ import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.R; 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; -import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; -import org.schabi.newpipe.info_list.dialog.InfoItemDialog; import org.schabi.newpipe.info_list.InfoListAdapter; +import org.schabi.newpipe.info_list.ItemViewMode; +import org.schabi.newpipe.info_list.dialog.InfoItemDialog; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.StateSaver; +import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.views.SuperScrollLayoutManager; import java.util.List; @@ -94,11 +92,7 @@ public abstract class BaseListFragment extends BaseStateFragment if (updateFlags != 0) { if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) { - final boolean useGrid = isGridLayout(); - itemsList.setLayoutManager(useGrid - ? getGridLayoutManager() : getListLayoutManager()); - infoListAdapter.setUseGridVariant(useGrid); - infoListAdapter.notifyDataSetChanged(); + refreshItemViewMode(); } updateFlags = 0; } @@ -218,22 +212,29 @@ public abstract class BaseListFragment extends BaseStateFragment final Resources resources = activity.getResources(); int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width); width += (24 * resources.getDisplayMetrics().density); - final int spanCount = (int) Math.floor(resources.getDisplayMetrics().widthPixels - / (double) width); + final int spanCount = Math.floorDiv(resources.getDisplayMetrics().widthPixels, width); final GridLayoutManager lm = new GridLayoutManager(activity, spanCount); lm.setSpanSizeLookup(infoListAdapter.getSpanSizeLookup(spanCount)); return lm; } + /** + * Updates the item view mode based on user preference. + */ + private void refreshItemViewMode() { + final ItemViewMode itemViewMode = getItemViewMode(); + itemsList.setLayoutManager((itemViewMode == ItemViewMode.GRID) + ? getGridLayoutManager() : getListLayoutManager()); + infoListAdapter.setItemViewMode(itemViewMode); + infoListAdapter.notifyDataSetChanged(); + } + @Override protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); - final boolean useGrid = isGridLayout(); itemsList = rootView.findViewById(R.id.items_list); - itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager()); - - infoListAdapter.setUseGridVariant(useGrid); + refreshItemViewMode(); final Supplier listHeaderSupplier = getListHeaderSupplier(); if (listHeaderSupplier != null) { @@ -264,45 +265,28 @@ public abstract class BaseListFragment extends BaseStateFragment } }); - infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<>() { - @Override - public void selected(final ChannelInfoItem selectedItem) { - try { - onItemSelected(selectedItem); - NavigationHelper.openChannelFragment(getFM(), - selectedItem.getServiceId(), - selectedItem.getUrl(), - selectedItem.getName()); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar( - BaseListFragment.this, "Opening channel fragment", e); - } - } - }); - - infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture<>() { - @Override - public void selected(final PlaylistInfoItem selectedItem) { - try { - onItemSelected(selectedItem); - NavigationHelper.openPlaylistFragment(getFM(), - selectedItem.getServiceId(), - selectedItem.getUrl(), - selectedItem.getName()); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(BaseListFragment.this, - "Opening playlist fragment", e); - } - } - }); - - infoListAdapter.setOnCommentsSelectedListener(new OnClickGesture<>() { - @Override - public void selected(final CommentsInfoItem selectedItem) { + infoListAdapter.setOnChannelSelectedListener(selectedItem -> { + try { onItemSelected(selectedItem); + NavigationHelper.openChannelFragment(getFM(), selectedItem.getServiceId(), + selectedItem.getUrl(), selectedItem.getName()); + } catch (final Exception e) { + ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); } }); + infoListAdapter.setOnPlaylistSelectedListener(selectedItem -> { + try { + onItemSelected(selectedItem); + NavigationHelper.openPlaylistFragment(getFM(), selectedItem.getServiceId(), + selectedItem.getUrl(), selectedItem.getName()); + } catch (final Exception e) { + ErrorUtil.showUiErrorSnackbar(this, "Opening playlist fragment", e); + } + }); + + infoListAdapter.setOnCommentsSelectedListener(this::onItemSelected); + // Ensure that there is always a scroll listener (e.g. when rotating the device) useNormalItemListScrollListener(); } @@ -490,21 +474,16 @@ public abstract class BaseListFragment extends BaseStateFragment @Override public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, final String key) { - if (key.equals(getString(R.string.list_view_mode_key))) { + if (getString(R.string.list_view_mode_key).equals(key)) { updateFlags |= LIST_MODE_UPDATE_FLAG; } } - protected boolean isGridLayout() { - final String listMode = PreferenceManager.getDefaultSharedPreferences(activity) - .getString(getString(R.string.list_view_mode_key), - getString(R.string.list_view_mode_value)); - if ("auto".equals(listMode)) { - final Configuration configuration = getResources().getConfiguration(); - return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - && configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE); - } else { - return "grid".equals(listMode); - } + /** + * Returns preferred item view mode. + * @return ItemViewMode + */ + protected ItemViewMode getItemViewMode() { + return ThemeHelper.getItemViewMode(requireContext()); } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java index 35424437d..dd5eb6c8a 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.fragments.list; +import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; + import android.os.Bundle; import android.text.TextUtils; import android.util.Log; @@ -7,13 +9,13 @@ import android.view.View; import androidx.annotation.NonNull; +import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.Page; -import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.views.NewPipeRecyclerView; @@ -229,13 +231,11 @@ public abstract class BaseListInfoFragment getTags() { + return channelInfo.getTags(); + } + + @Override + protected void setupMetadata(final LayoutInflater inflater, + final LinearLayout layout) { + // There is no upload date available for channels, so hide the relevant UI element + binding.detailUploadDateView.setVisibility(View.GONE); + + if (channelInfo == null) { + return; + } + + if (channelInfo.getSubscriberCount() != UNKNOWN_SUBSCRIBER_COUNT) { + addMetadataItem(inflater, layout, false, R.string.metadata_subscribers, + Localization.localizeNumber( + requireContext(), + channelInfo.getSubscriberCount())); + } + + addImagesMetadataItem(inflater, layout, R.string.metadata_avatars, + channelInfo.getAvatars()); + addImagesMetadataItem(inflater, layout, R.string.metadata_banners, + channelInfo.getBanners()); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java index 869503b5b..7e83d9958 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java @@ -5,6 +5,7 @@ import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor; import android.content.Context; +import android.content.SharedPreferences; import android.graphics.Color; import android.os.Bundle; import android.text.TextUtils; @@ -16,51 +17,50 @@ import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; -import android.widget.Button; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.appcompat.app.ActionBar; import androidx.core.content.ContextCompat; +import androidx.core.graphics.ColorUtils; +import androidx.preference.PreferenceManager; import com.google.android.material.snackbar.Snackbar; +import com.google.android.material.tabs.TabLayout; import com.jakewharton.rxbinding4.view.RxView; import org.schabi.newpipe.R; import org.schabi.newpipe.database.subscription.NotificationMode; 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.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.fragments.list.BaseListInfoFragment; +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; +import org.schabi.newpipe.fragments.BaseStateFragment; +import org.schabi.newpipe.fragments.detail.TabAdapter; import org.schabi.newpipe.ktx.AnimationType; -import org.schabi.newpipe.local.subscription.SubscriptionManager; import org.schabi.newpipe.local.feed.notifications.NotificationHelper; -import org.schabi.newpipe.player.MainPlayer.PlayerType; -import org.schabi.newpipe.player.playqueue.ChannelPlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueue; +import org.schabi.newpipe.local.subscription.SubscriptionManager; +import org.schabi.newpipe.util.ChannelTabHelper; +import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.StateSaver; +import org.schabi.newpipe.util.image.ImageStrategy; +import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.List; +import java.util.Queue; import java.util.concurrent.TimeUnit; -import java.util.function.Supplier; -import java.util.stream.Collectors; +import icepick.State; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.functions.Action; @@ -68,27 +68,37 @@ import io.reactivex.rxjava3.functions.Consumer; import io.reactivex.rxjava3.functions.Function; import io.reactivex.rxjava3.schedulers.Schedulers; -public class ChannelFragment extends BaseListInfoFragment - implements View.OnClickListener { +public class ChannelFragment extends BaseStateFragment + implements StateSaver.WriteRead { private static final int BUTTON_DEBOUNCE_INTERVAL = 100; private static final String PICASSO_CHANNEL_TAG = "PICASSO_CHANNEL_TAG"; + @State + protected int serviceId = Constants.NO_SERVICE_ID; + @State + protected String name; + @State + protected String url; + + private ChannelInfo currentInfo; + private Disposable currentWorker; private final CompositeDisposable disposables = new CompositeDisposable(); private Disposable subscribeButtonMonitor; + private SubscriptionManager subscriptionManager; + private int lastTab; + private boolean channelContentNotSupported = false; /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ - private SubscriptionManager subscriptionManager; - - private FragmentChannelBinding channelBinding; - private ChannelHeaderBinding headerBinding; - private PlaylistControlBinding playlistControlBinding; + private FragmentChannelBinding binding; + private TabAdapter tabAdapter; private MenuItem menuRssButton; private MenuItem menuNotifyButton; + private SubscriptionEntity channelSubscription; public static ChannelFragment getInstance(final int serviceId, final String url, final String name) { @@ -97,22 +107,23 @@ public class ChannelFragment extends BaseListInfoFragment getListHeaderSupplier() { - headerBinding = ChannelHeaderBinding - .inflate(activity.getLayoutInflater(), itemsList, false); - playlistControlBinding = headerBinding.playlistControl; - - return headerBinding::getRoot; } @Override protected void initListeners() { super.initListeners(); - headerBinding.subChannelTitleView.setOnClickListener(this); - headerBinding.subChannelAvatarView.setOnClickListener(this); + final View.OnClickListener openSubChannel = v -> { + if (!TextUtils.isEmpty(currentInfo.getParentChannelUrl())) { + try { + NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(), + currentInfo.getParentChannelUrl(), + currentInfo.getParentChannelName()); + } catch (final Exception 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"); + } + }; + binding.subChannelAvatarView.setOnClickListener(openSubChannel); + binding.subChannelTitleView.setOnClickListener(openSubChannel); } + @Override + public void onDestroy() { + super.onDestroy(); + if (currentWorker != null) { + currentWorker.dispose(); + } + disposables.clear(); + binding = null; + } + + /*////////////////////////////////////////////////////////////////////////// // Menu //////////////////////////////////////////////////////////////////////////*/ @@ -173,36 +194,36 @@ public class ChannelFragment extends BaseListInfoFragment onError = (Throwable throwable) -> { - animate(headerBinding.channelSubscribeButton, false, 100); + animate(binding.channelSubscribeButton, false, 100); showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_GET, "Get subscription status", currentInfo)); }; @@ -261,10 +283,9 @@ public class ChannelFragment extends BaseListInfoFragment mapOnSubscribe(final SubscriptionEntity subscription, - final ChannelInfo info) { + private Function mapOnSubscribe(final SubscriptionEntity subscription) { return (@NonNull Object o) -> { - subscriptionManager.insertSubscription(subscription, info); + subscriptionManager.insertSubscription(subscription); return o; }; } @@ -296,8 +317,7 @@ public class ChannelFragment extends BaseListInfoFragment action) { + private Disposable monitorSubscribeButton(final Function action) { final Consumer onNext = (@NonNull Object o) -> { if (DEBUG) { Log.d(TAG, "Changed subscription status to this channel!"); @@ -309,7 +329,7 @@ public class ChannelFragment extends BaseListInfoFragment setNotify(true)) .setActionTextColor(Color.YELLOW) .show(); } - /*////////////////////////////////////////////////////////////////////////// - // Load and handle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - protected Single> loadMoreItemsLogic() { - return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextPage); - } - - @Override - protected Single loadResult(final boolean forceLoad) { - return ExtractorHelper.getChannelInfo(serviceId, url, forceLoad); - } /*////////////////////////////////////////////////////////////////////////// - // OnClick + // Init //////////////////////////////////////////////////////////////////////////*/ - @Override - public void onClick(final View v) { - if (isLoading.get() || currentInfo == null) { - return; - } + private void updateTabs() { + tabAdapter.clearAllItems(); - switch (v.getId()) { - case R.id.sub_channel_avatar_view: - case R.id.sub_channel_title_view: - if (!TextUtils.isEmpty(currentInfo.getParentChannelUrl())) { - try { - NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(), - currentInfo.getParentChannelUrl(), - currentInfo.getParentChannelName()); - } catch (final Exception 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"); + if (currentInfo != null && !channelContentNotSupported) { + final Context context = requireContext(); + final SharedPreferences preferences = PreferenceManager + .getDefaultSharedPreferences(context); + + for (final ListLinkHandler linkHandler : currentInfo.getTabs()) { + final String tab = linkHandler.getContentFilters().get(0); + if (ChannelTabHelper.showChannelTab(context, preferences, tab)) { + final ChannelTabFragment channelTabFragment = + ChannelTabFragment.getInstance(serviceId, linkHandler, name); + channelTabFragment.useAsFrontPage(useAsFrontPage); + tabAdapter.addFragment(channelTabFragment, + context.getString(ChannelTabHelper.getTranslationKey(tab))); } - break; + } + + if (ChannelTabHelper.showChannelTab( + context, preferences, R.string.show_channel_tabs_about)) { + tabAdapter.addFragment( + new ChannelAboutFragment(currentInfo), + context.getString(R.string.channel_tab_about)); + } + } + + tabAdapter.notifyDataSetUpdate(); + + for (int i = 0; i < tabAdapter.getCount(); i++) { + binding.tabLayout.getTabAt(i).setText(tabAdapter.getItemTitle(i)); + } + + // Restore previously selected tab + final TabLayout.Tab ltab = binding.tabLayout.getTabAt(lastTab); + if (ltab != null) { + binding.tabLayout.selectTab(ltab); } } + + /*////////////////////////////////////////////////////////////////////////// + // State Saving + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public String generateSuffix() { + return null; + } + + @Override + public void writeTo(final Queue objectsToSave) { + objectsToSave.add(currentInfo); + objectsToSave.add(binding == null ? 0 : binding.tabLayout.getSelectedTabPosition()); + } + + @Override + public void readFrom(@NonNull final Queue savedObjects) { + currentInfo = (ChannelInfo) savedObjects.poll(); + lastTab = (Integer) savedObjects.poll(); + } + + @Override + public void onSaveInstanceState(final @NonNull Bundle outState) { + super.onSaveInstanceState(outState); + if (binding != null) { + outState.putInt("LastTab", binding.tabLayout.getSelectedTabPosition()); + } + } + + @Override + protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) { + super.onRestoreInstanceState(savedInstanceState); + lastTab = savedInstanceState.getInt("LastTab", 0); + } + + /*////////////////////////////////////////////////////////////////////////// // Contract //////////////////////////////////////////////////////////////////////////*/ + @Override + protected void doInitialLoadLogic() { + if (currentInfo == null) { + startLoading(false); + } else { + handleResult(currentInfo); + } + } + + @Override + public void startLoading(final boolean forceLoad) { + super.startLoading(forceLoad); + + currentInfo = null; + updateTabs(); + if (currentWorker != null) { + currentWorker.dispose(); + } + + runWorker(forceLoad); + } + + private void runWorker(final boolean forceLoad) { + currentWorker = ExtractorHelper.getChannelInfo(serviceId, url, forceLoad) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(result -> { + isLoading.set(false); + handleResult(result); + }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_CHANNEL, + url == null ? "No URL" : url, serviceId))); + } + @Override public void showLoading() { super.showLoading(); PicassoHelper.cancelTag(PICASSO_CHANNEL_TAG); - animate(headerBinding.channelSubscribeButton, false, 100); + animate(binding.channelSubscribeButton, false, 100); } @Override public void handleResult(@NonNull final ChannelInfo result) { super.handleResult(result); + currentInfo = result; + setInitialData(result.getServiceId(), result.getOriginalUrl(), result.getName()); - headerBinding.getRoot().setVisibility(View.VISIBLE); - PicassoHelper.loadBanner(result.getBannerUrl()).tag(PICASSO_CHANNEL_TAG) - .into(headerBinding.channelBannerImage); - PicassoHelper.loadAvatar(result.getAvatarUrl()).tag(PICASSO_CHANNEL_TAG) - .into(headerBinding.channelAvatarView); - PicassoHelper.loadAvatar(result.getParentChannelAvatarUrl()).tag(PICASSO_CHANNEL_TAG) - .into(headerBinding.subChannelAvatarView); + if (ImageStrategy.shouldLoadImages() && !result.getBanners().isEmpty()) { + PicassoHelper.loadBanner(result.getBanners()).tag(PICASSO_CHANNEL_TAG) + .into(binding.channelBannerImage); + } else { + // do not waste space for the banner, if the user disabled images or there is not one + binding.channelBannerImage.setImageDrawable(null); + } - headerBinding.channelSubscriberView.setVisibility(View.VISIBLE); + PicassoHelper.loadAvatar(result.getAvatars()).tag(PICASSO_CHANNEL_TAG) + .into(binding.channelAvatarView); + PicassoHelper.loadAvatar(result.getParentChannelAvatars()).tag(PICASSO_CHANNEL_TAG) + .into(binding.subChannelAvatarView); + + binding.channelTitleView.setText(result.getName()); + binding.channelSubscriberView.setVisibility(View.VISIBLE); if (result.getSubscriberCount() >= 0) { - headerBinding.channelSubscriberView.setText(Localization + binding.channelSubscriberView.setText(Localization .shortSubscriberCount(activity, result.getSubscriberCount())); } else { - headerBinding.channelSubscriberView.setText(R.string.subscribers_count_not_available); + binding.channelSubscriberView.setText(R.string.subscribers_count_not_available); } if (!TextUtils.isEmpty(currentInfo.getParentChannelName())) { - headerBinding.subChannelTitleView.setText(String.format( + binding.subChannelTitleView.setText(String.format( getString(R.string.channel_created_by), currentInfo.getParentChannelName()) ); - headerBinding.subChannelTitleView.setVisibility(View.VISIBLE); - headerBinding.subChannelAvatarView.setVisibility(View.VISIBLE); - } else { - headerBinding.subChannelTitleView.setVisibility(View.GONE); + binding.subChannelTitleView.setVisibility(View.VISIBLE); + binding.subChannelAvatarView.setVisibility(View.VISIBLE); } if (menuRssButton != null) { menuRssButton.setVisible(!TextUtils.isEmpty(result.getFeedUrl())); } - // PlaylistControls should be visible only if there is some item in - // infoListAdapter other than header - if (infoListAdapter.getItemCount() != 1) { - playlistControlBinding.getRoot().setVisibility(View.VISIBLE); - } else { - playlistControlBinding.getRoot().setVisibility(View.GONE); - } - + channelContentNotSupported = false; for (final Throwable throwable : result.getErrors()) { if (throwable instanceof ContentNotSupportedException) { - showContentNotSupported(); + channelContentNotSupported = true; + showContentNotSupportedIfNeeded(); + break; } } @@ -534,60 +627,21 @@ public class ChannelFragment extends BaseListInfoFragment NavigationHelper - .playOnMainPlayer(activity, getPlayQueue())); - playlistControlBinding.playlistCtrlPlayPopupButton - .setOnClickListener(view -> NavigationHelper - .playOnPopupPlayer(activity, getPlayQueue(), false)); - playlistControlBinding.playlistCtrlPlayBgButton - .setOnClickListener(view -> NavigationHelper - .playOnBackgroundPlayer(activity, getPlayQueue(), false)); - - playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> { - NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.POPUP); - return true; - }); - - playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> { - NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.AUDIO); - return true; - }); } - private void showContentNotSupported() { - channelBinding.errorContentNotSupported.setVisibility(View.VISIBLE); - channelBinding.channelKaomoji.setText("(︶︹︺)"); - channelBinding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f); - channelBinding.channelNoVideos.setVisibility(View.GONE); - } - - private PlayQueue getPlayQueue() { - return getPlayQueue(0); - } - - private PlayQueue getPlayQueue(final int index) { - final List streamItems = infoListAdapter.getItemsList().stream() - .filter(StreamInfoItem.class::isInstance) - .map(StreamInfoItem.class::cast) - .collect(Collectors.toList()); - - return new ChannelPlayQueue(currentInfo.getServiceId(), currentInfo.getUrl(), - currentInfo.getNextPage(), streamItems, index); - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void setTitle(final String title) { - super.setTitle(title); - if (!useAsFrontPage) { - headerBinding.channelTitleView.setText(title); + private void showContentNotSupportedIfNeeded() { + // channelBinding might not be initialized when handleResult() is called + // (e.g. after rotating the screen, #6696) + if (!channelContentNotSupported || binding == null) { + return; } + + binding.errorContentNotSupported.setVisibility(View.VISIBLE); + binding.channelKaomoji.setText("(︶︹︺)"); + binding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f); } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java new file mode 100644 index 000000000..95ac42eed --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java @@ -0,0 +1,168 @@ +package org.schabi.newpipe.fragments.list.channel; + +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.PlaylistControlBinding; +import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.ListExtractor; +import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; +import org.schabi.newpipe.extractor.exceptions.ParsingException; +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory; +import org.schabi.newpipe.extractor.linkhandler.ReadyChannelTabListLinkHandler; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.fragments.list.BaseListInfoFragment; +import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; +import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue; +import org.schabi.newpipe.player.playqueue.PlayQueue; +import org.schabi.newpipe.util.ChannelTabHelper; +import org.schabi.newpipe.util.ExtractorHelper; +import org.schabi.newpipe.util.PlayButtonHelper; + +import java.util.List; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import icepick.State; +import io.reactivex.rxjava3.core.Single; + +public class ChannelTabFragment extends BaseListInfoFragment + implements PlaylistControlViewHolder { + + // states must be protected and not private for IcePick being able to access them + @State + protected ListLinkHandler tabHandler; + @State + protected String channelName; + + private PlaylistControlBinding playlistControlBinding; + + @NonNull + public static ChannelTabFragment getInstance(final int serviceId, + final ListLinkHandler tabHandler, + final String channelName) { + final ChannelTabFragment instance = new ChannelTabFragment(); + instance.serviceId = serviceId; + instance.tabHandler = tabHandler; + instance.channelName = channelName; + return instance; + } + + public ChannelTabFragment() { + super(UserAction.REQUESTED_CHANNEL); + } + + /*////////////////////////////////////////////////////////////////////////// + // LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate(final Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setHasOptionsMenu(false); + } + + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_channel_tab, container, false); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + playlistControlBinding = null; + } + + @Override + protected Supplier getListHeaderSupplier() { + if (ChannelTabHelper.isStreamsTab(tabHandler)) { + playlistControlBinding = PlaylistControlBinding + .inflate(activity.getLayoutInflater(), itemsList, false); + return playlistControlBinding::getRoot; + } + return null; + } + + @Override + protected Single loadResult(final boolean forceLoad) { + return ExtractorHelper.getChannelTab(serviceId, tabHandler, forceLoad); + } + + @Override + protected Single> loadMoreItemsLogic() { + return ExtractorHelper.getMoreChannelTabItems(serviceId, tabHandler, currentNextPage); + } + + @Override + public void setTitle(final String title) { + // The channel name is displayed as title in the toolbar. + // The title is always a description of the content of the tab fragment. + // It should be unique for each channel because multiple channel tabs + // can be added to the main page. Therefore, the channel name is used. + // Using the title variable would cause the title to be the same for all channel tabs. + super.setTitle(channelName); + } + + @Override + public void handleResult(@NonNull final ChannelTabInfo result) { + super.handleResult(result); + + // FIXME this is a really hacky workaround, to avoid storing useless data in the fragment + // state. The problem is, `ReadyChannelTabListLinkHandler` might contain raw JSON data that + // uses a lot of memory (e.g. ~800KB for YouTube). While 800KB doesn't seem much, if + // you combine just a couple of channel tab fragments you easily go over the 1MB + // save&restore transaction limit, and get `TransactionTooLargeException`s. A proper + // solution would require rethinking about `ReadyChannelTabListLinkHandler`s. + if (tabHandler instanceof ReadyChannelTabListLinkHandler) { + try { + // once `handleResult` is called, the parsed data was already saved to cache, so + // we can discard any raw data in ReadyChannelTabListLinkHandler and create a + // link handler with identical properties, but without any raw data + final ListLinkHandlerFactory channelTabLHFactory = result.getService() + .getChannelTabLHFactory(); + if (channelTabLHFactory != null) { + // some services do not not have a ChannelTabLHFactory + tabHandler = channelTabLHFactory.fromQuery(tabHandler.getId(), + tabHandler.getContentFilters(), tabHandler.getSortFilter()); + } + } catch (final ParsingException e) { + // silently ignore the error, as the app can continue to function normally + Log.w(TAG, "Could not recreate channel tab handler", e); + } + } + + if (playlistControlBinding != null) { + // PlaylistControls should be visible only if there is some item in + // infoListAdapter other than header + if (infoListAdapter.getItemCount() > 1) { + playlistControlBinding.getRoot().setVisibility(View.VISIBLE); + } else { + playlistControlBinding.getRoot().setVisibility(View.GONE); + } + + PlayButtonHelper.initPlaylistControlClickListener( + activity, playlistControlBinding, this); + } + } + + public PlayQueue getPlayQueue() { + final List streamItems = infoListAdapter.getItemsList().stream() + .filter(StreamInfoItem.class::isInstance) + .map(StreamInfoItem.class::cast) + .collect(Collectors.toList()); + + return new ChannelTabPlayQueue(currentInfo.getServiceId(), tabHandler, + currentInfo.getNextPage(), streamItems, 0); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.java new file mode 100644 index 000000000..a816b149f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesFragment.java @@ -0,0 +1,168 @@ +package org.schabi.newpipe.fragments.list.comments; + +import static org.schabi.newpipe.util.ServiceHelper.getServiceById; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.constraintlayout.widget.ConstraintLayout; +import androidx.core.text.HtmlCompat; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.CommentRepliesHeaderBinding; +import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.extractor.ListExtractor; +import org.schabi.newpipe.extractor.comments.CommentsInfoItem; +import org.schabi.newpipe.fragments.list.BaseListInfoFragment; +import org.schabi.newpipe.info_list.ItemViewMode; +import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.util.ExtractorHelper; +import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.image.ImageStrategy; +import org.schabi.newpipe.util.image.PicassoHelper; +import org.schabi.newpipe.util.text.TextLinkifier; + +import java.util.Queue; +import java.util.function.Supplier; + +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.disposables.CompositeDisposable; + +public final class CommentRepliesFragment + extends BaseListInfoFragment { + + public static final String TAG = CommentRepliesFragment.class.getSimpleName(); + + private CommentsInfoItem commentsInfoItem; // the comment to show replies of + private final CompositeDisposable disposables = new CompositeDisposable(); + + + /*////////////////////////////////////////////////////////////////////////// + // Constructors and lifecycle + //////////////////////////////////////////////////////////////////////////*/ + + // only called by the Android framework, after which readFrom is called and restores all data + public CommentRepliesFragment() { + super(UserAction.REQUESTED_COMMENT_REPLIES); + } + + public CommentRepliesFragment(@NonNull final CommentsInfoItem commentsInfoItem) { + this(); + this.commentsInfoItem = commentsInfoItem; + // setting "" as title since the title will be properly set right after + setInitialData(commentsInfoItem.getServiceId(), commentsInfoItem.getUrl(), ""); + } + + @Nullable + @Override + public View onCreateView(@NonNull final LayoutInflater inflater, + @Nullable final ViewGroup container, + @Nullable final Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_comments, container, false); + } + + @Override + public void onDestroyView() { + disposables.clear(); + super.onDestroyView(); + } + + @Override + protected Supplier getListHeaderSupplier() { + return () -> { + final CommentRepliesHeaderBinding binding = CommentRepliesHeaderBinding + .inflate(activity.getLayoutInflater(), itemsList, false); + final CommentsInfoItem item = commentsInfoItem; + + // load the author avatar + PicassoHelper.loadAvatar(item.getUploaderAvatars()).into(binding.authorAvatar); + binding.authorAvatar.setVisibility(ImageStrategy.shouldLoadImages() + ? View.VISIBLE : View.GONE); + + // setup author name and comment date + binding.authorName.setText(item.getUploaderName()); + binding.uploadDate.setText(Localization.relativeTimeOrTextual( + getContext(), item.getUploadDate(), item.getTextualUploadDate())); + binding.authorTouchArea.setOnClickListener( + v -> NavigationHelper.openCommentAuthorIfPresent(requireActivity(), item)); + + // setup like count, hearted and pinned + binding.thumbsUpCount.setText( + Localization.likeCount(requireContext(), item.getLikeCount())); + // for heartImage goneMarginEnd was used, but there is no way to tell ConstraintLayout + // not to use a different margin only when both the next two views are gone + ((ConstraintLayout.LayoutParams) binding.thumbsUpCount.getLayoutParams()) + .setMarginEnd(DeviceUtils.dpToPx( + (item.isHeartedByUploader() || item.isPinned() ? 8 : 16), + requireContext())); + binding.heartImage.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE); + binding.pinnedImage.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE); + + // setup comment content + TextLinkifier.fromDescription(binding.commentContent, item.getCommentText(), + HtmlCompat.FROM_HTML_MODE_LEGACY, getServiceById(item.getServiceId()), + item.getUrl(), disposables, null); + + return binding.getRoot(); + }; + } + + + /*////////////////////////////////////////////////////////////////////////// + // State saving + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void writeTo(final Queue objectsToSave) { + super.writeTo(objectsToSave); + objectsToSave.add(commentsInfoItem); + } + + @Override + public void readFrom(@NonNull final Queue savedObjects) throws Exception { + super.readFrom(savedObjects); + commentsInfoItem = (CommentsInfoItem) savedObjects.poll(); + } + + + /*////////////////////////////////////////////////////////////////////////// + // Data loading + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected Single loadResult(final boolean forceLoad) { + return Single.fromCallable(() -> new CommentRepliesInfo(commentsInfoItem, + // the reply count string will be shown as the activity title + Localization.replyCount(requireContext(), commentsInfoItem.getReplyCount()))); + } + + @Override + protected Single> loadMoreItemsLogic() { + // commentsInfoItem.getUrl() should contain the url of the original + // ListInfo, which should be the stream url + return ExtractorHelper.getMoreCommentItems( + serviceId, commentsInfoItem.getUrl(), currentNextPage); + } + + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + + @Override + protected ItemViewMode getItemViewMode() { + return ItemViewMode.LIST; + } + + /** + * @return the comment to which the replies are shown + */ + public CommentsInfoItem getCommentsInfoItem() { + return commentsInfoItem; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.java b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.java new file mode 100644 index 000000000..cc160c395 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentRepliesInfo.java @@ -0,0 +1,22 @@ +package org.schabi.newpipe.fragments.list.comments; + +import org.schabi.newpipe.extractor.ListInfo; +import org.schabi.newpipe.extractor.comments.CommentsInfoItem; +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; + +import java.util.Collections; + +public final class CommentRepliesInfo extends ListInfo { + /** + * This class is used to wrap the comment replies page into a ListInfo object. + * + * @param comment the comment from which to get replies + * @param name will be shown as the fragment title + */ + public CommentRepliesInfo(final CommentsInfoItem comment, final String name) { + super(comment.getServiceId(), + new ListLinkHandler("", "", "", Collections.emptyList(), null), name); + setNextPage(comment.getReplies()); + setRelatedItems(Collections.emptyList()); // since it must be non-null + } +} diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java index 3b092cc28..e25e02794 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.java @@ -17,6 +17,7 @@ import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.comments.CommentsInfo; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; +import org.schabi.newpipe.info_list.ItemViewMode; import org.schabi.newpipe.ktx.ViewUtils; import org.schabi.newpipe.util.ExtractorHelper; @@ -106,7 +107,17 @@ public class CommentsFragment extends BaseListInfoFragment { +public class PlaylistFragment extends BaseListInfoFragment + implements PlaylistControlViewHolder { private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG"; @@ -83,6 +89,9 @@ public class PlaylistFragment extends BaseListInfoFragment dialog.show(getFM(), TAG) + )); + } + break; default: return super.onOptionsItemSelected(item); } @@ -258,6 +280,12 @@ public class PlaylistFragment extends BaseListInfoFragment + headerBinding.playlistDescriptionReadMore.setText( + Boolean.TRUE.equals(isEllipsized) ? R.string.show_more : R.string.show_less + )); + ellipsizer.setOnContentChanged(canBeEllipsized -> { + headerBinding.playlistDescriptionReadMore.setVisibility( + Boolean.TRUE.equals(canBeEllipsized) ? View.VISIBLE : View.GONE); + if (Boolean.TRUE.equals(canBeEllipsized)) { + ellipsizer.ellipsize(); + } + }); + ellipsizer.setContent(description); + headerBinding.playlistDescriptionReadMore.setOnClickListener(v -> ellipsizer.toggle()); + } else { + headerBinding.playlistDescription.setVisibility(View.GONE); + headerBinding.playlistDescriptionReadMore.setVisibility(View.GONE); + } if (!result.getErrors().isEmpty()) { showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.REQUESTED_PLAYLIST, @@ -320,25 +368,10 @@ public class PlaylistFragment extends BaseListInfoFragment - NavigationHelper.playOnMainPlayer(activity, getPlayQueue())); - playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view -> - NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false)); - playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view -> - NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false)); - - playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> { - NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.POPUP); - return true; - }); - - playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> { - NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.AUDIO); - return true; - }); + PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this); } - private PlayQueue getPlayQueue() { + public PlayQueue getPlayQueue() { return getPlayQueue(0); } @@ -462,4 +495,20 @@ public class PlaylistFragment extends BaseListInfoFragment list, + final boolean isDurationComplete) { + if (activity != null && headerBinding != null) { + playlistOverallDurationSeconds += list.stream() + .mapToLong(x -> x.getDuration()) + .sum(); + headerBinding.playlistStreamCount.setText( + Localization.concatenateStrings( + Localization.localizeStreamCount(activity, streamCount), + Localization.getDurationString(playlistOverallDurationSeconds, + isDurationComplete)) + ); + } + } + } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java index 055c27733..eef3455ae 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.fragments.list.search; import static androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags; +import static org.schabi.newpipe.extractor.utils.Utils.isBlank; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView; import static java.util.Arrays.asList; @@ -33,6 +34,7 @@ import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.TooltipCompat; +import androidx.collection.SparseArrayCompat; import androidx.core.text.HtmlCompat; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.ItemTouchHelper; @@ -70,9 +72,7 @@ import org.schabi.newpipe.util.ServiceHelper; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Queue; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -141,7 +141,7 @@ public class SearchFragment extends BaseListFragment menuItemToFilterName = null; + private final SparseArrayCompat menuItemToFilterName = new SparseArrayCompat<>(); private StreamingService service; private Page nextPage; private boolean showLocalSuggestions = true; @@ -168,6 +168,10 @@ public class SearchFragment extends BaseListFragment(); - int itemId = 0; boolean isFirstItem = true; final Context c = getContext(); @@ -466,11 +470,8 @@ public class SearchFragment extends BaseListFragment cf = new ArrayList<>(1); - cf.add(menuItemToFilterName.get(item.getItemId())); - changeContentFilter(item, cf); - } + final var filter = Collections.singletonList(menuItemToFilterName.get(item.getItemId())); + changeContentFilter(item, filter); return true; } @@ -497,11 +498,9 @@ public class SearchFragment extends BaseListFragment()); + suggestionListAdapter.submitList(null); showKeyboardSearch(); }); @@ -590,11 +589,13 @@ public class SearchFragment extends BaseListFragment suggestionPublisher - .onNext(searchEditText.getText().toString()), + .onNext(getSearchEditString()), throwable -> showSnackBarError(new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, "Deleting item failed"))); @@ -724,9 +726,9 @@ public class SearchFragment extends BaseListFragment - searchHistoryEntries.stream() - .map(entry -> new SuggestionItem(true, entry)) - .collect(Collectors.toList())); + searchHistoryEntries.stream() + .map(entry -> new SuggestionItem(true, entry)) + .collect(Collectors.toList())); } private Observable> getRemoteSuggestionsObservable(final String query) { @@ -793,12 +795,12 @@ public class SearchFragment extends BaseListFragment showSnackBarError(new ErrorInfo( - throwable, UserAction.GET_SUGGESTIONS, searchString, serviceId))); + throwable, UserAction.GET_SUGGESTIONS, searchString, serviceId))); } @Override @@ -806,7 +808,13 @@ public class SearchFragment extends BaseListFragment NavigationHelper.getIntentByLink(activity, - streamingService, theSearchString)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(intent -> { - getFM().popBackStackImmediate(); - activity.startActivity(intent); - }, throwable -> showTextError(getString(R.string.unsupported_url)))); - return; - } + showLoading(); + disposables.add(Observable + .fromCallable(() -> NavigationHelper.getIntentByLink(activity, + streamingService, theSearchString)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(intent -> { + getFM().popBackStackImmediate(); + activity.startActivity(intent); + }, throwable -> showTextError(getString(R.string.unsupported_url)))); + return; } catch (final Exception ignored) { // Exception occurred, it's not a url } + // prepare search lastSearchedString = this.searchString; this.searchString = theSearchString; infoListAdapter.clearStreamItemList(); @@ -843,13 +852,17 @@ public class SearchFragment extends BaseListFragment { }, + ignored -> { + }, throwable -> showSnackBarError(new ErrorInfo(throwable, UserAction.SEARCHED, theSearchString, serviceId)) )); + + // load search results suggestionPublisher.onNext(theSearchString); startLoading(false); } @@ -922,7 +935,7 @@ public class SearchFragment extends BaseListFragment suggestionListAdapter.setItems(suggestions)); + suggestionListAdapter.submitList(suggestions, + () -> searchBinding.suggestionsList.scrollToPosition(0)); if (suggestionsPanelVisible && isErrorPanelVisible()) { hideLoading(); @@ -980,11 +1001,13 @@ public class SearchFragment extends BaseListFragment cannot be bundled without creating some containers - metaInfo = new MetaInfo[result.getMetaInfo().size()]; - metaInfo = result.getMetaInfo().toArray(metaInfo); + metaInfo = result.getMetaInfo().toArray(new MetaInfo[0]); showMetaInfoInTextView(result.getMetaInfo(), searchBinding.searchMetaInfoTextView, searchBinding.searchMetaInfoSeparator, disposables); @@ -1070,19 +1093,19 @@ public class SearchFragment extends BaseListFragment suggestionPublisher - .onNext(searchEditText.getText().toString()), + .onNext(getSearchEditString()), throwable -> showSnackBarError(new ErrorInfo(throwable, UserAction.DELETE_FROM_HISTORY, "Deleting item failed"))); disposables.add(onDelete); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java index fb983b01e..856ba22f1 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java @@ -1,34 +1,22 @@ package org.schabi.newpipe.fragments.list.search; -import android.content.Context; import android.view.LayoutInflater; -import android.view.View; import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.R; - -import java.util.ArrayList; -import java.util.List; +import org.schabi.newpipe.databinding.ItemSearchSuggestionBinding; public class SuggestionListAdapter - extends RecyclerView.Adapter { - private final ArrayList items = new ArrayList<>(); - private final Context context; + extends ListAdapter { private OnSuggestionItemSelected listener; - public SuggestionListAdapter(final Context context) { - this.context = context; - } - - public void setItems(final List items) { - this.items.clear(); - this.items.addAll(items); - notifyDataSetChanged(); + public SuggestionListAdapter() { + super(new SuggestionItemCallback()); } public void setListener(final OnSuggestionItemSelected listener) { @@ -39,45 +27,32 @@ public class SuggestionListAdapter @Override public SuggestionItemHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { - return new SuggestionItemHolder(LayoutInflater.from(context) - .inflate(R.layout.item_search_suggestion, parent, false)); + return new SuggestionItemHolder(ItemSearchSuggestionBinding + .inflate(LayoutInflater.from(parent.getContext()), parent, false)); } @Override public void onBindViewHolder(final SuggestionItemHolder holder, final int position) { final SuggestionItem currentItem = getItem(position); holder.updateFrom(currentItem); - holder.queryView.setOnClickListener(v -> { + holder.itemBinding.suggestionSearch.setOnClickListener(v -> { if (listener != null) { listener.onSuggestionItemSelected(currentItem); } }); - holder.queryView.setOnLongClickListener(v -> { + holder.itemBinding.suggestionSearch.setOnLongClickListener(v -> { if (listener != null) { listener.onSuggestionItemLongClick(currentItem); } return true; }); - holder.insertView.setOnClickListener(v -> { + holder.itemBinding.suggestionInsert.setOnClickListener(v -> { if (listener != null) { listener.onSuggestionItemInserted(currentItem); } }); } - SuggestionItem getItem(final int position) { - return items.get(position); - } - - @Override - public int getItemCount() { - return items.size(); - } - - public boolean isEmpty() { - return getItemCount() == 0; - } - public interface OnSuggestionItemSelected { void onSuggestionItemSelected(SuggestionItem item); @@ -87,30 +62,32 @@ public class SuggestionListAdapter } public static final class SuggestionItemHolder extends RecyclerView.ViewHolder { - private final TextView itemSuggestionQuery; - private final ImageView suggestionIcon; - private final View queryView; - private final View insertView; + private final ItemSearchSuggestionBinding itemBinding; - // Cache some ids, as they can potentially be constantly updated/recycled - private final int historyResId; - private final int searchResId; - - private SuggestionItemHolder(final View rootView) { - super(rootView); - suggestionIcon = rootView.findViewById(R.id.item_suggestion_icon); - itemSuggestionQuery = rootView.findViewById(R.id.item_suggestion_query); - - queryView = rootView.findViewById(R.id.suggestion_search); - insertView = rootView.findViewById(R.id.suggestion_insert); - - historyResId = R.drawable.ic_history; - searchResId = R.drawable.ic_search; + private SuggestionItemHolder(final ItemSearchSuggestionBinding binding) { + super(binding.getRoot()); + this.itemBinding = binding; } private void updateFrom(final SuggestionItem item) { - suggestionIcon.setImageResource(item.fromHistory ? historyResId : searchResId); - itemSuggestionQuery.setText(item.query); + itemBinding.itemSuggestionIcon.setImageResource(item.fromHistory ? R.drawable.ic_history + : R.drawable.ic_search); + itemBinding.itemSuggestionQuery.setText(item.query); + } + } + + private static class SuggestionItemCallback extends DiffUtil.ItemCallback { + @Override + public boolean areItemsTheSame(@NonNull final SuggestionItem oldItem, + @NonNull final SuggestionItem newItem) { + return oldItem.fromHistory == newItem.fromHistory + && oldItem.query.equals(newItem.query); + } + + @Override + public boolean areContentsTheSame(@NonNull final SuggestionItem oldItem, + @NonNull final SuggestionItem newItem) { + return true; // items' contents never change; the list of items themselves does } } } diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java index f0ece69f3..e46937ede 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java @@ -19,19 +19,19 @@ import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; +import org.schabi.newpipe.info_list.ItemViewMode; import org.schabi.newpipe.ktx.ViewUtils; -import org.schabi.newpipe.util.RelatedItemInfo; import java.io.Serializable; import java.util.function.Supplier; import io.reactivex.rxjava3.core.Single; -public class RelatedItemsFragment extends BaseListInfoFragment +public class RelatedItemsFragment extends BaseListInfoFragment implements SharedPreferences.OnSharedPreferenceChangeListener { private static final String INFO_KEY = "related_info_key"; - private RelatedItemInfo relatedItemInfo; + private RelatedItemsInfo relatedItemsInfo; /*////////////////////////////////////////////////////////////////////////// // Views @@ -68,7 +68,7 @@ public class RelatedItemsFragment extends BaseListInfoFragment getListHeaderSupplier() { - if (relatedItemInfo == null || relatedItemInfo.getRelatedItems() == null) { + if (relatedItemsInfo == null || relatedItemsInfo.getRelatedItems() == null) { return null; } @@ -96,8 +96,8 @@ public class RelatedItemsFragment extends BaseListInfoFragment loadResult(final boolean forceLoad) { - return Single.fromCallable(() -> relatedItemInfo); + protected Single loadResult(final boolean forceLoad) { + return Single.fromCallable(() -> relatedItemsInfo); } @Override @@ -109,7 +109,7 @@ public class RelatedItemsFragment extends BaseListInfoFragment { + /** + * This class is used to wrap the related items of a StreamInfo into a ListInfo object. + * + * @param info the stream info from which to get related items + */ + public RelatedItemsInfo(final StreamInfo info) { + super(info.getServiceId(), new ListLinkHandler(info.getOriginalUrl(), info.getUrl(), + info.getId(), Collections.emptyList(), null), info.getName()); + setRelatedItems(new ArrayList<>(info.getRelatedItems())); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java index d78bf1076..d959c6327 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/InfoItemBuilder.java @@ -13,8 +13,7 @@ import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder; -import org.schabi.newpipe.info_list.holder.CommentsInfoItemHolder; -import org.schabi.newpipe.info_list.holder.CommentsMiniInfoItemHolder; +import org.schabi.newpipe.info_list.holder.CommentInfoItemHolder; import org.schabi.newpipe.info_list.holder.InfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder; @@ -67,8 +66,8 @@ public class InfoItemBuilder { public View buildView(@NonNull final ViewGroup parent, @NonNull final InfoItem infoItem, final HistoryRecordManager historyRecordManager, final boolean useMiniVariant) { - final InfoItemHolder holder - = holderFromInfoType(parent, infoItem.getInfoType(), useMiniVariant); + final InfoItemHolder holder = + holderFromInfoType(parent, infoItem.getInfoType(), useMiniVariant); holder.updateFromItem(infoItem, historyRecordManager); return holder.itemView; } @@ -87,8 +86,7 @@ public class InfoItemBuilder { return useMiniVariant ? new PlaylistMiniInfoItemHolder(this, parent) : new PlaylistInfoItemHolder(this, parent); case COMMENT: - return useMiniVariant ? new CommentsMiniInfoItemHolder(this, parent) - : new CommentsInfoItemHolder(this, parent); + return new CommentInfoItemHolder(this, parent); default: throw new RuntimeException("InfoType not expected = " + infoType.name()); } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java index fb27593e7..575568c00 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/InfoListAdapter.java @@ -17,15 +17,17 @@ import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.info_list.holder.ChannelCardInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelGridInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder; -import org.schabi.newpipe.info_list.holder.CommentsInfoItemHolder; -import org.schabi.newpipe.info_list.holder.CommentsMiniInfoItemHolder; +import org.schabi.newpipe.info_list.holder.CommentInfoItemHolder; import org.schabi.newpipe.info_list.holder.InfoItemHolder; +import org.schabi.newpipe.info_list.holder.PlaylistCardInfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder; +import org.schabi.newpipe.info_list.holder.StreamCardInfoItemHolder; import org.schabi.newpipe.info_list.holder.StreamGridInfoItemHolder; import org.schabi.newpipe.info_list.holder.StreamInfoItemHolder; import org.schabi.newpipe.info_list.holder.StreamMiniInfoItemHolder; @@ -67,14 +69,16 @@ public class InfoListAdapter extends RecyclerView.Adapter headerSupplier = null; public InfoListAdapter(final Context context) { @@ -114,8 +119,8 @@ public class InfoListAdapter extends RecyclerView.Adapter data) { @@ -234,16 +239,37 @@ public class InfoListAdapter extends RecyclerView.Adapter(R.id.textViewStartSeconds).text = Localization.getDurationString(item.startTimeSeconds.toLong()) viewHolder.root.setOnClickListener { onClick.onItemClick(this, item.startTimeSeconds) } + viewHolder.root.setOnLongClickListener { onClick.onItemLongClick(this, item.startTimeSeconds); true } viewHolder.root.isSelected = isSelected } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java index 5a266c0a8..0c69557bf 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java @@ -24,6 +24,7 @@ import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.player.helper.PlayerHolder; +import org.schabi.newpipe.util.StreamTypeUtil; import org.schabi.newpipe.util.external_communication.KoreUtils; import java.util.ArrayList; @@ -251,10 +252,11 @@ public final class InfoItemDialog { * @return the current {@link Builder} instance */ public Builder addEnqueueEntriesIfNeeded() { - if (PlayerHolder.getInstance().isPlayQueueReady()) { + final PlayerHolder holder = PlayerHolder.getInstance(); + if (holder.isPlayQueueReady()) { addEntry(StreamDialogDefaultEntry.ENQUEUE); - if (PlayerHolder.getInstance().getQueueSize() > 1) { + if (holder.getQueuePosition() < holder.getQueueSize() - 1) { addEntry(StreamDialogDefaultEntry.ENQUEUE_NEXT); } } @@ -269,8 +271,7 @@ public final class InfoItemDialog { */ public Builder addStartHereEntries() { addEntry(StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND); - if (infoItem.getStreamType() != StreamType.AUDIO_STREAM - && infoItem.getStreamType() != StreamType.AUDIO_LIVE_STREAM) { + if (!StreamTypeUtil.isAudio(infoItem.getStreamType())) { addEntry(StreamDialogDefaultEntry.START_HERE_ON_POPUP); } return this; @@ -285,9 +286,7 @@ public final class InfoItemDialog { final boolean isWatchHistoryEnabled = PreferenceManager .getDefaultSharedPreferences(context) .getBoolean(context.getString(R.string.enable_watch_history_key), false); - if (isWatchHistoryEnabled - && infoItem.getStreamType() != StreamType.LIVE_STREAM - && infoItem.getStreamType() != StreamType.AUDIO_LIVE_STREAM) { + if (isWatchHistoryEnabled && !StreamTypeUtil.isLiveStream(infoItem.getStreamType())) { addEntry(StreamDialogDefaultEntry.MARK_AS_WATCHED); } return this; @@ -323,6 +322,7 @@ public final class InfoItemDialog { */ public Builder addDefaultEndEntries() { addAllEntries( + StreamDialogDefaultEntry.DOWNLOAD, StreamDialogDefaultEntry.APPEND_PLAYLIST, StreamDialogDefaultEntry.SHARE, StreamDialogDefaultEntry.OPEN_IN_BROWSER diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java index 7e87318ee..948a8274c 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java @@ -2,6 +2,7 @@ package org.schabi.newpipe.info_list.dialog; import static org.schabi.newpipe.util.NavigationHelper.openChannelFragment; import static org.schabi.newpipe.util.SparseItemUtil.fetchItemInfoIfSparse; +import static org.schabi.newpipe.util.SparseItemUtil.fetchStreamInfoAndSaveToDatabase; import static org.schabi.newpipe.util.SparseItemUtil.fetchUploaderUrlIfSparse; import android.net.Uri; @@ -11,6 +12,7 @@ import androidx.annotation.StringRes; import org.schabi.newpipe.R; import org.schabi.newpipe.database.stream.model.StreamEntity; +import org.schabi.newpipe.download.DownloadDialog; import org.schabi.newpipe.local.dialog.PlaylistAppendDialog; import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.local.history.HistoryRecordManager; @@ -18,7 +20,7 @@ import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.external_communication.ShareUtils; -import java.util.Collections; +import java.util.List; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; @@ -87,7 +89,7 @@ public enum StreamDialogDefaultEntry { APPEND_PLAYLIST(R.string.add_to_playlist, (fragment, item) -> PlaylistDialog.createCorrespondingDialog( fragment.getContext(), - Collections.singletonList(new StreamEntity(item)), + List.of(new StreamEntity(item)), dialog -> dialog.show( fragment.getParentFragmentManager(), "StreamDialogEntry@" @@ -97,18 +99,28 @@ public enum StreamDialogDefaultEntry { ) ), - PLAY_WITH_KODI(R.string.play_with_kodi_title, (fragment, item) -> { - final Uri videoUrl = Uri.parse(item.getUrl()); - try { - NavigationHelper.playWithKore(fragment.requireContext(), videoUrl); - } catch (final Exception e) { - KoreUtils.showInstallKoreDialog(fragment.requireActivity()); - } - }), + PLAY_WITH_KODI(R.string.play_with_kodi_title, (fragment, item) -> + KoreUtils.playWithKore(fragment.requireContext(), Uri.parse(item.getUrl()))), SHARE(R.string.share, (fragment, item) -> ShareUtils.shareText(fragment.requireContext(), item.getName(), item.getUrl(), - item.getThumbnailUrl())), + item.getThumbnails())), + + /** + * Opens a {@link DownloadDialog} after fetching some stream info. + * If the user quits the current fragment, it will not open a DownloadDialog. + */ + DOWNLOAD(R.string.download, (fragment, item) -> + fetchStreamInfoAndSaveToDatabase(fragment.requireContext(), item.getServiceId(), + item.getUrl(), info -> { + if (fragment.getContext() != null) { + final DownloadDialog downloadDialog = + new DownloadDialog(fragment.requireContext(), info); + downloadDialog.show(fragment.getChildFragmentManager(), + "downloadDialog"); + } + }) + ), OPEN_IN_BROWSER(R.string.open_in_browser, (fragment, item) -> ShareUtils.openUrlInBrowser(fragment.requireContext(), item.getUrl())), diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelCardInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelCardInfoItemHolder.java new file mode 100644 index 000000000..29fc50be0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelCardInfoItemHolder.java @@ -0,0 +1,22 @@ +package org.schabi.newpipe.info_list.holder; + +import android.view.ViewGroup; + +import androidx.annotation.Nullable; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.info_list.InfoItemBuilder; + +public class ChannelCardInfoItemHolder extends ChannelMiniInfoItemHolder { + public ChannelCardInfoItemHolder(final InfoItemBuilder infoItemBuilder, + final ViewGroup parent) { + super(infoItemBuilder, R.layout.list_channel_card_item, parent); + } + + @Override + protected int getDescriptionMaxLineCount(@Nullable final String content) { + // Based on `list_channel_card_item` left side content (thumbnail 100dp + // + additional details), Right side description can grow up to 8 lines. + return 8; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelInfoItemHolder.java index cf1ed255b..f8133d3de 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelInfoItemHolder.java @@ -1,14 +1,9 @@ package org.schabi.newpipe.info_list.holder; import android.view.ViewGroup; -import android.widget.TextView; import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.info_list.InfoItemBuilder; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.Localization; /* * Created by Christian Schabesberger on 12.02.17. @@ -31,40 +26,7 @@ import org.schabi.newpipe.util.Localization; */ public class ChannelInfoItemHolder extends ChannelMiniInfoItemHolder { - private final TextView itemChannelDescriptionView; - public ChannelInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, R.layout.list_channel_item, parent); - itemChannelDescriptionView = itemView.findViewById(R.id.itemChannelDescriptionView); - } - - @Override - public void updateFromItem(final InfoItem infoItem, - final HistoryRecordManager historyRecordManager) { - super.updateFromItem(infoItem, historyRecordManager); - - if (!(infoItem instanceof ChannelInfoItem)) { - return; - } - final ChannelInfoItem item = (ChannelInfoItem) infoItem; - - itemChannelDescriptionView.setText(item.getDescription()); - } - - @Override - protected String getDetailLine(final ChannelInfoItem item) { - String details = super.getDetailLine(item); - - if (item.getStreamCount() >= 0) { - final String formattedVideoAmount = Localization.localizeStreamCount( - itemBuilder.getContext(), item.getStreamCount()); - - if (!details.isEmpty()) { - details += " • " + formattedVideoAmount; - } else { - details = formattedVideoAmount; - } - } - return details; } } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java index aa4f4c9f0..7afc05c6c 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/ChannelMiniInfoItemHolder.java @@ -1,21 +1,26 @@ package org.schabi.newpipe.info_list.holder; +import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.Nullable; + import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.channel.ChannelInfoItem; +import org.schabi.newpipe.extractor.utils.Utils; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.Localization; public class ChannelMiniInfoItemHolder extends InfoItemHolder { - public final ImageView itemThumbnailView; - public final TextView itemTitleView; + private final ImageView itemThumbnailView; + private final TextView itemTitleView; private final TextView itemAdditionalDetailView; + private final TextView itemChannelDescriptionView; ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, final ViewGroup parent) { @@ -24,6 +29,7 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder { itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); itemTitleView = itemView.findViewById(R.id.itemTitleView); itemAdditionalDetailView = itemView.findViewById(R.id.itemAdditionalDetails); + itemChannelDescriptionView = itemView.findViewById(R.id.itemChannelDescriptionView); } public ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, @@ -40,9 +46,17 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder { final ChannelInfoItem item = (ChannelInfoItem) infoItem; itemTitleView.setText(item.getName()); - itemAdditionalDetailView.setText(getDetailLine(item)); + itemTitleView.setSelected(true); - PicassoHelper.loadThumbnail(item.getThumbnailUrl()).into(itemThumbnailView); + final String detailLine = getDetailLine(item); + if (detailLine == null) { + itemAdditionalDetailView.setVisibility(View.GONE); + } else { + itemAdditionalDetailView.setVisibility(View.VISIBLE); + itemAdditionalDetailView.setText(getDetailLine(item)); + } + + PicassoHelper.loadAvatar(item.getThumbnails()).into(itemThumbnailView); itemView.setOnClickListener(view -> { if (itemBuilder.getOnChannelSelectedListener() != null) { @@ -56,14 +70,48 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder { } return true; }); + + if (itemChannelDescriptionView != null) { + // itemChannelDescriptionView will be null in the mini variant + if (Utils.isBlank(item.getDescription())) { + itemChannelDescriptionView.setVisibility(View.GONE); + } else { + itemChannelDescriptionView.setVisibility(View.VISIBLE); + itemChannelDescriptionView.setText(item.getDescription()); + // setMaxLines utilize the line space for description if the additional details + // (sub / video count) are not present. + // Case1: 2 lines of description + 1 line additional details + // Case2: 3 lines of description (additionalDetails is GONE) + itemChannelDescriptionView.setMaxLines(getDescriptionMaxLineCount(detailLine)); + } + } } - protected String getDetailLine(final ChannelInfoItem item) { - String details = ""; - if (item.getSubscriberCount() >= 0) { - details += Localization.shortSubscriberCount(itemBuilder.getContext(), + /** + * Returns max number of allowed lines for the description field. + * @param content additional detail content (video / sub count) + * @return max line count + */ + protected int getDescriptionMaxLineCount(@Nullable final String content) { + return content == null ? 3 : 2; + } + + @Nullable + private String getDetailLine(final ChannelInfoItem item) { + if (item.getStreamCount() >= 0 && item.getSubscriberCount() >= 0) { + return Localization.concatenateStrings( + Localization.shortSubscriberCount(itemBuilder.getContext(), + item.getSubscriberCount()), + Localization.localizeStreamCount(itemBuilder.getContext(), + item.getStreamCount())); + } else if (item.getStreamCount() >= 0) { + return Localization.localizeStreamCount(itemBuilder.getContext(), + item.getStreamCount()); + } else if (item.getSubscriberCount() >= 0) { + return Localization.shortSubscriberCount(itemBuilder.getContext(), item.getSubscriberCount()); + } else { + return null; } - return details; } } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java new file mode 100644 index 000000000..a3f0384ad --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentInfoItemHolder.java @@ -0,0 +1,188 @@ +package org.schabi.newpipe.info_list.holder; + +import static org.schabi.newpipe.util.ServiceHelper.getServiceById; + +import android.text.method.LinkMovementMethod; +import android.text.style.URLSpan; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentActivity; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.InfoItem; +import org.schabi.newpipe.extractor.comments.CommentsInfoItem; +import org.schabi.newpipe.info_list.InfoItemBuilder; +import org.schabi.newpipe.local.history.HistoryRecordManager; +import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.external_communication.ShareUtils; +import org.schabi.newpipe.util.image.ImageStrategy; +import org.schabi.newpipe.util.image.PicassoHelper; +import org.schabi.newpipe.util.text.CommentTextOnTouchListener; +import org.schabi.newpipe.util.text.TextEllipsizer; + +public class CommentInfoItemHolder extends InfoItemHolder { + + private static final int COMMENT_DEFAULT_LINES = 2; + private final int commentHorizontalPadding; + private final int commentVerticalPadding; + + private final RelativeLayout itemRoot; + private final ImageView itemThumbnailView; + private final TextView itemContentView; + private final ImageView itemThumbsUpView; + private final TextView itemLikesCountView; + private final TextView itemTitleView; + private final ImageView itemHeartView; + private final ImageView itemPinnedView; + private final Button repliesButton; + + @NonNull + private final TextEllipsizer textEllipsizer; + + public CommentInfoItemHolder(final InfoItemBuilder infoItemBuilder, + final ViewGroup parent) { + super(infoItemBuilder, R.layout.list_comment_item, parent); + + itemRoot = itemView.findViewById(R.id.itemRoot); + itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); + itemContentView = itemView.findViewById(R.id.itemCommentContentView); + itemThumbsUpView = itemView.findViewById(R.id.detail_thumbs_up_img_view); + itemLikesCountView = itemView.findViewById(R.id.detail_thumbs_up_count_view); + itemTitleView = itemView.findViewById(R.id.itemTitleView); + itemHeartView = itemView.findViewById(R.id.detail_heart_image_view); + itemPinnedView = itemView.findViewById(R.id.detail_pinned_view); + repliesButton = itemView.findViewById(R.id.replies_button); + + commentHorizontalPadding = (int) infoItemBuilder.getContext() + .getResources().getDimension(R.dimen.comments_horizontal_padding); + commentVerticalPadding = (int) infoItemBuilder.getContext() + .getResources().getDimension(R.dimen.comments_vertical_padding); + + textEllipsizer = new TextEllipsizer(itemContentView, COMMENT_DEFAULT_LINES, null); + textEllipsizer.setStateChangeListener(isEllipsized -> { + if (Boolean.TRUE.equals(isEllipsized)) { + denyLinkFocus(); + } else { + determineMovementMethod(); + } + }); + } + + @Override + public void updateFromItem(final InfoItem infoItem, + final HistoryRecordManager historyRecordManager) { + if (!(infoItem instanceof CommentsInfoItem)) { + return; + } + final CommentsInfoItem item = (CommentsInfoItem) infoItem; + + + // load the author avatar + PicassoHelper.loadAvatar(item.getUploaderAvatars()).into(itemThumbnailView); + if (ImageStrategy.shouldLoadImages()) { + itemThumbnailView.setVisibility(View.VISIBLE); + itemRoot.setPadding(commentVerticalPadding, commentVerticalPadding, + commentVerticalPadding, commentVerticalPadding); + } else { + itemThumbnailView.setVisibility(View.GONE); + itemRoot.setPadding(commentHorizontalPadding, commentVerticalPadding, + commentHorizontalPadding, commentVerticalPadding); + } + itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item)); + + + // setup the top row, with pinned icon, author name and comment date + itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE); + itemTitleView.setText(Localization.concatenateStrings(item.getUploaderName(), + Localization.relativeTimeOrTextual(itemBuilder.getContext(), item.getUploadDate(), + item.getTextualUploadDate()))); + + + // setup bottom row, with likes, heart and replies button + itemLikesCountView.setText( + Localization.likeCount(itemBuilder.getContext(), item.getLikeCount())); + + itemHeartView.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE); + + final boolean hasReplies = item.getReplies() != null; + repliesButton.setOnClickListener(hasReplies ? v -> openCommentReplies(item) : null); + repliesButton.setVisibility(hasReplies ? View.VISIBLE : View.GONE); + repliesButton.setText(hasReplies + ? Localization.replyCount(itemBuilder.getContext(), item.getReplyCount()) : ""); + ((RelativeLayout.LayoutParams) itemThumbsUpView.getLayoutParams()).topMargin = + hasReplies ? 0 : DeviceUtils.dpToPx(6, itemBuilder.getContext()); + + + // setup comment content and click listeners to expand/ellipsize it + textEllipsizer.setStreamingService(getServiceById(item.getServiceId())); + textEllipsizer.setStreamUrl(item.getUrl()); + textEllipsizer.setContent(item.getCommentText()); + textEllipsizer.ellipsize(); + + //noinspection ClickableViewAccessibility + itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE); + + itemView.setOnClickListener(view -> { + textEllipsizer.toggle(); + if (itemBuilder.getOnCommentsSelectedListener() != null) { + itemBuilder.getOnCommentsSelectedListener().selected(item); + } + }); + + itemView.setOnLongClickListener(view -> { + if (DeviceUtils.isTv(itemBuilder.getContext())) { + openCommentAuthor(item); + } else { + final CharSequence text = itemContentView.getText(); + if (text != null) { + ShareUtils.copyToClipboard(itemBuilder.getContext(), text.toString()); + } + } + return true; + }); + } + + private void openCommentAuthor(@NonNull final CommentsInfoItem item) { + NavigationHelper.openCommentAuthorIfPresent((FragmentActivity) itemBuilder.getContext(), + item); + } + + private void openCommentReplies(@NonNull final CommentsInfoItem item) { + NavigationHelper.openCommentRepliesFragment((FragmentActivity) itemBuilder.getContext(), + item); + } + + private void allowLinkFocus() { + itemContentView.setMovementMethod(LinkMovementMethod.getInstance()); + } + + private void denyLinkFocus() { + itemContentView.setMovementMethod(null); + } + + private boolean shouldFocusLinks() { + if (itemView.isInTouchMode()) { + return false; + } + + final URLSpan[] urls = itemContentView.getUrls(); + + return urls != null && urls.length != 0; + } + + private void determineMovementMethod() { + if (shouldFocusLinks()) { + allowLinkFocus(); + } else { + denyLinkFocus(); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsInfoItemHolder.java deleted file mode 100644 index 4fc2d9f84..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsInfoItemHolder.java +++ /dev/null @@ -1,63 +0,0 @@ -package org.schabi.newpipe.info_list.holder; - -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.TextView; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.comments.CommentsInfoItem; -import org.schabi.newpipe.info_list.InfoItemBuilder; -import org.schabi.newpipe.local.history.HistoryRecordManager; - -/* - * Created by Christian Schabesberger on 12.02.17. - * - * Copyright (C) Christian Schabesberger 2016 - * ChannelInfoItemHolder .java is part of NewPipe. - * - * NewPipe is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * NewPipe is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with NewPipe. If not, see . - */ - -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 - public void updateFromItem(final InfoItem infoItem, - final HistoryRecordManager historyRecordManager) { - super.updateFromItem(infoItem, historyRecordManager); - - if (!(infoItem instanceof CommentsInfoItem)) { - return; - } - final CommentsInfoItem item = (CommentsInfoItem) infoItem; - - itemTitleView.setText(item.getUploaderName()); - - itemHeartView.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE); - - itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java deleted file mode 100644 index 6e4773c09..000000000 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/CommentsMiniInfoItemHolder.java +++ /dev/null @@ -1,255 +0,0 @@ -package org.schabi.newpipe.info_list.holder; - -import android.text.TextUtils; -import android.text.method.LinkMovementMethod; -import android.text.style.URLSpan; -import android.text.util.Linkify; -import android.util.Log; -import android.view.View; -import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.RelativeLayout; -import android.widget.TextView; - -import androidx.appcompat.app.AppCompatActivity; - -import org.schabi.newpipe.R; -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; -import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.CommentTextOnTouchListener; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.external_communication.TimestampExtractor; -import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.PicassoHelper; - -import java.util.regex.Matcher; - -public class CommentsMiniInfoItemHolder extends InfoItemHolder { - private static final String TAG = "CommentsMiniIIHolder"; - - private static final int COMMENT_DEFAULT_LINES = 2; - private static final int COMMENT_EXPANDED_LINES = 1000; - - private final int commentHorizontalPadding; - private final int commentVerticalPadding; - - private final RelativeLayout itemRoot; - public final ImageView itemThumbnailView; - private final TextView itemContentView; - private final TextView itemLikesCountView; - private final TextView itemPublishedTime; - - private String commentText; - private String streamUrl; - - private final Linkify.TransformFilter timestampLink = new Linkify.TransformFilter() { - @Override - public String transformUrl(final Matcher match, final String url) { - try { - final TimestampExtractor.TimestampMatchDTO timestampMatchDTO = - TimestampExtractor.getTimestampFromMatcher(match, commentText); - - if (timestampMatchDTO == null) { - return url; - } - - return streamUrl + url.replace( - match.group(0), - "#timestamp=" + timestampMatchDTO.seconds()); - } catch (final Exception ex) { - Log.e(TAG, "Unable to process url='" + url + "' as timestampLink", ex); - return url; - } - } - }; - - CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, - final ViewGroup parent) { - super(infoItemBuilder, layoutId, parent); - - itemRoot = itemView.findViewById(R.id.itemRoot); - itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); - itemLikesCountView = itemView.findViewById(R.id.detail_thumbs_up_count_view); - itemPublishedTime = itemView.findViewById(R.id.itemPublishedTime); - itemContentView = itemView.findViewById(R.id.itemCommentContentView); - - commentHorizontalPadding = (int) infoItemBuilder.getContext() - .getResources().getDimension(R.dimen.comments_horizontal_padding); - commentVerticalPadding = (int) infoItemBuilder.getContext() - .getResources().getDimension(R.dimen.comments_vertical_padding); - } - - public CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, - final ViewGroup parent) { - this(infoItemBuilder, R.layout.list_comments_mini_item, parent); - } - - @Override - public void updateFromItem(final InfoItem infoItem, - final HistoryRecordManager historyRecordManager) { - if (!(infoItem instanceof CommentsInfoItem)) { - return; - } - final CommentsInfoItem item = (CommentsInfoItem) infoItem; - - PicassoHelper.loadAvatar(item.getUploaderAvatarUrl()).into(itemThumbnailView); - if (PicassoHelper.getShouldLoadImages()) { - itemThumbnailView.setVisibility(View.VISIBLE); - itemRoot.setPadding(commentVerticalPadding, commentVerticalPadding, - commentVerticalPadding, commentVerticalPadding); - } else { - itemThumbnailView.setVisibility(View.GONE); - itemRoot.setPadding(commentHorizontalPadding, commentVerticalPadding, - commentHorizontalPadding, commentVerticalPadding); - } - - - itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item)); - - streamUrl = item.getUrl(); - - itemContentView.setLines(COMMENT_DEFAULT_LINES); - commentText = item.getCommentText(); - itemContentView.setText(commentText); - itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE); - - if (itemContentView.getLineCount() == 0) { - itemContentView.post(this::ellipsize); - } else { - ellipsize(); - } - - if (item.getLikeCount() >= 0) { - itemLikesCountView.setText( - Localization.shortCount( - itemBuilder.getContext(), - item.getLikeCount())); - } else { - itemLikesCountView.setText("-"); - } - - if (item.getUploadDate() != null) { - itemPublishedTime.setText(Localization.relativeTime(item.getUploadDate() - .offsetDateTime())); - } else { - itemPublishedTime.setText(item.getTextualUploadDate()); - } - - itemView.setOnClickListener(view -> { - toggleEllipsize(); - if (itemBuilder.getOnCommentsSelectedListener() != null) { - itemBuilder.getOnCommentsSelectedListener().selected(item); - } - }); - - - itemView.setOnLongClickListener(view -> { - if (DeviceUtils.isTv(itemBuilder.getContext())) { - openCommentAuthor(item); - } else { - ShareUtils.copyToClipboard(itemBuilder.getContext(), commentText); - } - return true; - }); - } - - private void openCommentAuthor(final CommentsInfoItem item) { - if (TextUtils.isEmpty(item.getUploaderUrl())) { - return; - } - final AppCompatActivity activity = (AppCompatActivity) itemBuilder.getContext(); - try { - NavigationHelper.openChannelFragment( - activity.getSupportFragmentManager(), - item.getServiceId(), - item.getUploaderUrl(), - item.getUploaderName()); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(activity, "Opening channel fragment", e); - } - } - - private void allowLinkFocus() { - itemContentView.setMovementMethod(LinkMovementMethod.getInstance()); - } - - private void denyLinkFocus() { - itemContentView.setMovementMethod(null); - } - - private boolean shouldFocusLinks() { - if (itemView.isInTouchMode()) { - return false; - } - - final URLSpan[] urls = itemContentView.getUrls(); - - return urls != null && urls.length != 0; - } - - private void determineLinkFocus() { - if (shouldFocusLinks()) { - allowLinkFocus(); - } else { - denyLinkFocus(); - } - } - - private void ellipsize() { - boolean hasEllipsis = false; - - if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) { - final int endOfLastLine - = itemContentView.getLayout().getLineEnd(COMMENT_DEFAULT_LINES - 1); - int end = itemContentView.getText().toString().lastIndexOf(' ', endOfLastLine - 2); - if (end == -1) { - end = Math.max(endOfLastLine - 2, 0); - } - final String newVal = itemContentView.getText().subSequence(0, end) + " …"; - itemContentView.setText(newVal); - hasEllipsis = true; - } - - linkify(); - - if (hasEllipsis) { - denyLinkFocus(); - } else { - determineLinkFocus(); - } - } - - private void toggleEllipsize() { - if (itemContentView.getText().toString().equals(commentText)) { - if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) { - ellipsize(); - } - } else { - expand(); - } - } - - private void expand() { - itemContentView.setMaxLines(COMMENT_EXPANDED_LINES); - itemContentView.setText(commentText); - linkify(); - determineLinkFocus(); - } - - private void linkify() { - Linkify.addLinks( - itemContentView, - Linkify.WEB_URLS); - Linkify.addLinks( - itemContentView, - TimestampExtractor.TIMESTAMPS_PATTERN, - null, - null, - timestampLink); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistCardInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistCardInfoItemHolder.java new file mode 100644 index 000000000..f1682b4e4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistCardInfoItemHolder.java @@ -0,0 +1,17 @@ +package org.schabi.newpipe.info_list.holder; + +import android.view.ViewGroup; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.info_list.InfoItemBuilder; + +/** + * Playlist card layout. + */ +public class PlaylistCardInfoItemHolder extends PlaylistMiniInfoItemHolder { + + public PlaylistCardInfoItemHolder(final InfoItemBuilder infoItemBuilder, + final ViewGroup parent) { + super(infoItemBuilder, R.layout.list_playlist_card_item, parent); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java index bf5f57db3..c9216d9a9 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/PlaylistMiniInfoItemHolder.java @@ -9,7 +9,7 @@ import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.Localization; public class PlaylistMiniInfoItemHolder extends InfoItemHolder { @@ -46,7 +46,7 @@ public class PlaylistMiniInfoItemHolder extends InfoItemHolder { .localizeStreamCountMini(itemStreamCountView.getContext(), item.getStreamCount())); itemUploaderView.setText(item.getUploaderName()); - PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView); + PicassoHelper.loadPlaylistThumbnail(item.getThumbnails()).into(itemThumbnailView); itemView.setOnClickListener(view -> { if (itemBuilder.getOnPlaylistSelectedListener() != null) { diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamCardInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamCardInfoItemHolder.java new file mode 100644 index 000000000..807bad6e0 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamCardInfoItemHolder.java @@ -0,0 +1,16 @@ +package org.schabi.newpipe.info_list.holder; + +import android.view.ViewGroup; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.info_list.InfoItemBuilder; + +/** + * Card layout for stream. + */ +public class StreamCardInfoItemHolder extends StreamInfoItemHolder { + + public StreamCardInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) { + super(infoItemBuilder, R.layout.list_stream_card_item, parent); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java index a84c98404..80f62eed3 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamInfoItemHolder.java @@ -12,10 +12,6 @@ import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.util.Localization; -import androidx.preference.PreferenceManager; - -import static org.schabi.newpipe.MainActivity.DEBUG; - /* * Created by Christian Schabesberger on 01.08.16. *

@@ -81,7 +77,9 @@ public class StreamInfoItemHolder extends StreamMiniInfoItemHolder { } } - final String uploadDate = getFormattedRelativeUploadDate(infoItem); + final String uploadDate = Localization.relativeTimeOrTextual(itemBuilder.getContext(), + infoItem.getUploadDate(), + infoItem.getTextualUploadDate()); if (!TextUtils.isEmpty(uploadDate)) { if (viewsAndDate.isEmpty()) { return uploadDate; @@ -92,20 +90,4 @@ public class StreamInfoItemHolder extends StreamMiniInfoItemHolder { return viewsAndDate; } - - private String getFormattedRelativeUploadDate(final StreamInfoItem infoItem) { - if (infoItem.getUploadDate() != null) { - String formattedRelativeTime = Localization - .relativeTime(infoItem.getUploadDate().offsetDateTime()); - - if (DEBUG && PreferenceManager.getDefaultSharedPreferences(itemBuilder.getContext()) - .getBoolean(itemBuilder.getContext() - .getString(R.string.show_original_time_ago_key), false)) { - formattedRelativeTime += " (" + infoItem.getTextualUploadDate() + ")"; - } - return formattedRelativeTime; - } else { - return infoItem.getTextualUploadDate(); - } - } } diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java index 79772a6a3..01f3be6b3 100644 --- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java @@ -11,12 +11,13 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.ktx.ViewUtils; import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.image.PicassoHelper; +import org.schabi.newpipe.util.StreamTypeUtil; import org.schabi.newpipe.views.AnimatedProgressBar; import java.util.concurrent.TimeUnit; @@ -60,8 +61,12 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { R.color.duration_background_color)); itemDurationView.setVisibility(View.VISIBLE); - final StreamStateEntity state2 = historyRecordManager.loadStreamState(infoItem) - .blockingGet()[0]; + StreamStateEntity state2 = null; + if (DependentPreferenceHelper + .getPositionsInListsEnabled(itemProgressView.getContext())) { + state2 = historyRecordManager.loadStreamState(infoItem) + .blockingGet()[0]; + } if (state2 != null) { itemProgressView.setVisibility(View.VISIBLE); itemProgressView.setMax((int) item.getDuration()); @@ -70,8 +75,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { } else { itemProgressView.setVisibility(View.GONE); } - } else if (item.getStreamType() == StreamType.LIVE_STREAM - || item.getStreamType() == StreamType.AUDIO_LIVE_STREAM) { + } else if (StreamTypeUtil.isLiveStream(item.getStreamType())) { itemDurationView.setText(R.string.duration_live); itemDurationView.setBackgroundColor(ContextCompat.getColor(itemBuilder.getContext(), R.color.live_duration_background_color)); @@ -83,7 +87,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { } // Default thumbnail is shown on error, while loading and if the url is empty - PicassoHelper.loadThumbnail(item.getThumbnailUrl()).into(itemThumbnailView); + PicassoHelper.loadThumbnail(item.getThumbnails()).into(itemThumbnailView); itemView.setOnClickListener(view -> { if (itemBuilder.getOnStreamSelectedListener() != null) { @@ -96,9 +100,10 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { case VIDEO_STREAM: case LIVE_STREAM: case AUDIO_LIVE_STREAM: + case POST_LIVE_STREAM: + case POST_LIVE_AUDIO_STREAM: enableLongClick(item); break; - case FILE: case NONE: default: disableLongClick(); @@ -111,10 +116,14 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { final HistoryRecordManager historyRecordManager) { final StreamInfoItem item = (StreamInfoItem) infoItem; - final StreamStateEntity state - = historyRecordManager.loadStreamState(infoItem).blockingGet()[0]; + StreamStateEntity state = null; + if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())) { + state = historyRecordManager + .loadStreamState(infoItem) + .blockingGet()[0]; + } if (state != null && item.getDuration() > 0 - && item.getStreamType() != StreamType.LIVE_STREAM) { + && !StreamTypeUtil.isLiveStream(item.getStreamType())) { itemProgressView.setMax((int) item.getDuration()); if (itemProgressView.getVisibility() == View.VISIBLE) { itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt new file mode 100644 index 000000000..61721d546 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt @@ -0,0 +1,9 @@ +package org.schabi.newpipe.ktx + +import android.os.Bundle +import android.os.Parcelable +import androidx.core.os.BundleCompat + +inline fun Bundle.parcelableArrayList(key: String?): ArrayList? { + return BundleCompat.getParcelableArrayList(this, key, T::class.java) +} diff --git a/app/src/main/java/org/schabi/newpipe/ktx/View.kt b/app/src/main/java/org/schabi/newpipe/ktx/View.kt index 8dcc9d85c..bf0dcb201 100644 --- a/app/src/main/java/org/schabi/newpipe/ktx/View.kt +++ b/app/src/main/java/org/schabi/newpipe/ktx/View.kt @@ -21,10 +21,6 @@ import org.schabi.newpipe.MainActivity private const val TAG = "ViewUtils" -inline var View.backgroundTintListCompat: ColorStateList? - get() = ViewCompat.getBackgroundTintList(this) - set(value) = ViewCompat.setBackgroundTintList(this, value) - /** * Animate the view. * @@ -96,62 +92,43 @@ fun View.animateBackgroundColor(duration: Long, @ColorInt colorStart: Int, @Colo if (MainActivity.DEBUG) { Log.d( TAG, - "animateBackgroundColor() called with: " + - "view = [" + this + "], duration = [" + duration + "], " + - "colorStart = [" + colorStart + "], colorEnd = [" + colorEnd + "]" + "animateBackgroundColor() called with: view = [$this], duration = [$duration], " + + "colorStart = [$colorStart], colorEnd = [$colorEnd]" ) } - val empty = arrayOf(IntArray(0)) val viewPropertyAnimator = ValueAnimator.ofObject(ArgbEvaluator(), colorStart, colorEnd) viewPropertyAnimator.interpolator = FastOutSlowInInterpolator() viewPropertyAnimator.duration = duration - viewPropertyAnimator.addUpdateListener { animation: ValueAnimator -> - backgroundTintListCompat = ColorStateList(empty, intArrayOf(animation.animatedValue as Int)) + + fun listenerAction(color: Int) { + ViewCompat.setBackgroundTintList(this, ColorStateList.valueOf(color)) } - viewPropertyAnimator.addListener( - onCancel = { backgroundTintListCompat = ColorStateList(empty, intArrayOf(colorEnd)) }, - onEnd = { backgroundTintListCompat = ColorStateList(empty, intArrayOf(colorEnd)) } - ) + viewPropertyAnimator.addUpdateListener { listenerAction(it.animatedValue as Int) } + viewPropertyAnimator.addListener(onCancel = { listenerAction(colorEnd) }, onEnd = { listenerAction(colorEnd) }) viewPropertyAnimator.start() } fun View.animateHeight(duration: Long, targetHeight: Int): ValueAnimator { if (MainActivity.DEBUG) { - Log.d( - TAG, - "animateHeight: duration = [" + duration + "], " + - "from " + height + " to → " + targetHeight + " in: " + this - ) + Log.d(TAG, "animateHeight: duration = [$duration], from $height to → $targetHeight in: $this") } val animator = ValueAnimator.ofFloat(height.toFloat(), targetHeight.toFloat()) animator.interpolator = FastOutSlowInInterpolator() animator.duration = duration - animator.addUpdateListener { animation: ValueAnimator -> - val value = animation.animatedValue as Float - layoutParams.height = value.toInt() + + fun listenerAction(value: Int) { + layoutParams.height = value requestLayout() } - animator.addListener( - onCancel = { - layoutParams.height = targetHeight - requestLayout() - }, - onEnd = { - layoutParams.height = targetHeight - requestLayout() - } - ) + animator.addUpdateListener { listenerAction((it.animatedValue as Float).toInt()) } + animator.addListener(onCancel = { listenerAction(targetHeight) }, onEnd = { listenerAction(targetHeight) }) animator.start() return animator } fun View.animateRotation(duration: Long, targetRotation: Int) { if (MainActivity.DEBUG) { - Log.d( - TAG, - "animateRotation: duration = [" + duration + "], " + - "from " + rotation + " to → " + targetRotation + " in: " + this - ) + Log.d(TAG, "animateRotation: duration = [$duration], from $rotation to → $targetRotation in: $this") } animate().setListener(null).cancel() animate() @@ -172,20 +149,13 @@ private fun View.animateAlpha(enterOrExit: Boolean, duration: Long, delay: Long, if (enterOrExit) { animate().setInterpolator(FastOutSlowInInterpolator()).alpha(1f) .setDuration(duration).setStartDelay(delay) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - execOnEnd?.run() - } - }).start() + .setListener(ExecOnEndListener(execOnEnd)) + .start() } else { animate().setInterpolator(FastOutSlowInInterpolator()).alpha(0f) .setDuration(duration).setStartDelay(delay) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - isGone = true - execOnEnd?.run() - } - }).start() + .setListener(HideAndExecOnEndListener(this, execOnEnd)) + .start() } } @@ -197,11 +167,8 @@ private fun View.animateScaleAndAlpha(enterOrExit: Boolean, duration: Long, dela .setInterpolator(FastOutSlowInInterpolator()) .alpha(1f).scaleX(1f).scaleY(1f) .setDuration(duration).setStartDelay(delay) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - execOnEnd?.run() - } - }).start() + .setListener(ExecOnEndListener(execOnEnd)) + .start() } else { scaleX = 1f scaleY = 1f @@ -209,12 +176,8 @@ private fun View.animateScaleAndAlpha(enterOrExit: Boolean, duration: Long, dela .setInterpolator(FastOutSlowInInterpolator()) .alpha(0f).scaleX(.8f).scaleY(.8f) .setDuration(duration).setStartDelay(delay) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - isGone = true - execOnEnd?.run() - } - }).start() + .setListener(HideAndExecOnEndListener(this, execOnEnd)) + .start() } } @@ -227,11 +190,8 @@ private fun View.animateLightScaleAndAlpha(enterOrExit: Boolean, duration: Long, .setInterpolator(FastOutSlowInInterpolator()) .alpha(1f).scaleX(1f).scaleY(1f) .setDuration(duration).setStartDelay(delay) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - execOnEnd?.run() - } - }).start() + .setListener(ExecOnEndListener(execOnEnd)) + .start() } else { alpha = 1f scaleX = 1f @@ -240,12 +200,8 @@ private fun View.animateLightScaleAndAlpha(enterOrExit: Boolean, duration: Long, .setInterpolator(FastOutSlowInInterpolator()) .alpha(0f).scaleX(.95f).scaleY(.95f) .setDuration(duration).setStartDelay(delay) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - isGone = true - execOnEnd?.run() - } - }).start() + .setListener(HideAndExecOnEndListener(this, execOnEnd)) + .start() } } @@ -256,22 +212,15 @@ private fun View.animateSlideAndAlpha(enterOrExit: Boolean, duration: Long, dela animate() .setInterpolator(FastOutSlowInInterpolator()).alpha(1f).translationY(0f) .setDuration(duration).setStartDelay(delay) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - execOnEnd?.run() - } - }).start() + .setListener(ExecOnEndListener(execOnEnd)) + .start() } else { animate() .setInterpolator(FastOutSlowInInterpolator()) .alpha(0f).translationY(-height.toFloat()) .setDuration(duration).setStartDelay(delay) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - isGone = true - execOnEnd?.run() - } - }).start() + .setListener(HideAndExecOnEndListener(this, execOnEnd)) + .start() } } @@ -282,32 +231,18 @@ private fun View.animateLightSlideAndAlpha(enterOrExit: Boolean, duration: Long, animate() .setInterpolator(FastOutSlowInInterpolator()).alpha(1f).translationY(0f) .setDuration(duration).setStartDelay(delay) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - execOnEnd?.run() - } - }).start() + .setListener(ExecOnEndListener(execOnEnd)) + .start() } else { animate().setInterpolator(FastOutSlowInInterpolator()) .alpha(0f).translationY(-height / 2.0f) .setDuration(duration).setStartDelay(delay) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - isGone = true - execOnEnd?.run() - } - }).start() + .setListener(HideAndExecOnEndListener(this, execOnEnd)) + .start() } } -fun View.slideUp( - duration: Long, - delay: Long, - @FloatRange(from = 0.0, to = 1.0) translationPercent: Float -) { - slideUp(duration, delay, translationPercent, null) -} - +@JvmOverloads fun View.slideUp( duration: Long, delay: Long = 0L, @@ -325,11 +260,7 @@ fun View.slideUp( .setStartDelay(delay) .setDuration(duration) .setInterpolator(FastOutSlowInInterpolator()) - .setListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - execOnEnd?.run() - } - }) + .setListener(ExecOnEndListener(execOnEnd)) .start() } @@ -343,6 +274,20 @@ fun View.animateHideRecyclerViewAllowingScrolling() { animate().alpha(0.0f).setDuration(200).start() } +private open class ExecOnEndListener(private val execOnEnd: Runnable?) : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + execOnEnd?.run() + } +} + +private class HideAndExecOnEndListener(private val view: View, execOnEnd: Runnable?) : + ExecOnEndListener(execOnEnd) { + override fun onAnimationEnd(animation: Animator) { + view.isGone = true + super.onAnimationEnd(animation) + } +} + enum class AnimationType { ALPHA, SCALE_AND_ALPHA, LIGHT_SCALE_AND_ALPHA, SLIDE_AND_ALPHA, LIGHT_SLIDE_AND_ALPHA } diff --git a/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java b/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java index 8790c3059..53fe1677b 100644 --- a/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java @@ -22,10 +22,11 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.PignateFooterBinding; import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.list.ListViewContract; +import org.schabi.newpipe.info_list.ItemViewMode; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; -import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout; +import static org.schabi.newpipe.util.ThemeHelper.getItemViewMode; /** * This fragment is design to be used with persistent data such as @@ -77,16 +78,23 @@ public abstract class BaseLocalListFragment extends BaseStateFragment super.onResume(); if (updateFlags != 0) { if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) { - final boolean useGrid = shouldUseGridLayout(requireContext()); - itemsList.setLayoutManager( - useGrid ? getGridLayoutManager() : getListLayoutManager()); - itemListAdapter.setUseGridVariant(useGrid); - itemListAdapter.notifyDataSetChanged(); + refreshItemViewMode(); } updateFlags = 0; } } + /** + * Updates the item view mode based on user preference. + */ + private void refreshItemViewMode() { + final ItemViewMode itemViewMode = getItemViewMode(requireContext()); + itemsList.setLayoutManager((itemViewMode == ItemViewMode.GRID) + ? getGridLayoutManager() : getListLayoutManager()); + itemListAdapter.setItemViewMode(itemViewMode); + itemListAdapter.notifyDataSetChanged(); + } + /*////////////////////////////////////////////////////////////////////////// // Lifecycle - View //////////////////////////////////////////////////////////////////////////*/ @@ -104,8 +112,7 @@ public abstract class BaseLocalListFragment extends BaseStateFragment final Resources resources = activity.getResources(); int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width); width += (24 * resources.getDisplayMetrics().density); - final int spanCount = (int) Math.floor(resources.getDisplayMetrics().widthPixels - / (double) width); + final int spanCount = Math.floorDiv(resources.getDisplayMetrics().widthPixels, width); final GridLayoutManager lm = new GridLayoutManager(activity, spanCount); lm.setSpanSizeLookup(itemListAdapter.getSpanSizeLookup(spanCount)); return lm; @@ -121,11 +128,9 @@ public abstract class BaseLocalListFragment extends BaseStateFragment itemListAdapter = new LocalItemListAdapter(activity); - final boolean useGrid = shouldUseGridLayout(requireContext()); itemsList = rootView.findViewById(R.id.items_list); - itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager()); + refreshItemViewMode(); - itemListAdapter.setUseGridVariant(useGrid); headerRootBinding = getListHeader(); if (headerRootBinding != null) { itemListAdapter.setHeader(headerRootBinding.getRoot()); @@ -256,7 +261,7 @@ public abstract class BaseLocalListFragment extends BaseStateFragment @Override public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, final String key) { - if (key.equals(getString(R.string.list_view_mode_key))) { + if (getString(R.string.list_view_mode_key).equals(key)) { updateFlags |= LIST_MODE_UPDATE_FLAG; } } diff --git a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java index 5c22cee24..b33619dea 100644 --- a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java @@ -12,16 +12,21 @@ import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.stream.model.StreamStateEntity; +import org.schabi.newpipe.info_list.ItemViewMode; import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder; import org.schabi.newpipe.local.holder.LocalItemHolder; +import org.schabi.newpipe.local.holder.LocalPlaylistCardItemHolder; import org.schabi.newpipe.local.holder.LocalPlaylistGridItemHolder; import org.schabi.newpipe.local.holder.LocalPlaylistItemHolder; +import org.schabi.newpipe.local.holder.LocalPlaylistStreamCardItemHolder; import org.schabi.newpipe.local.holder.LocalPlaylistStreamGridItemHolder; import org.schabi.newpipe.local.holder.LocalPlaylistStreamItemHolder; +import org.schabi.newpipe.local.holder.LocalStatisticStreamCardItemHolder; import org.schabi.newpipe.local.holder.LocalStatisticStreamGridItemHolder; import org.schabi.newpipe.local.holder.LocalStatisticStreamItemHolder; import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder; +import org.schabi.newpipe.local.holder.RemotePlaylistCardItemHolder; import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder; import org.schabi.newpipe.local.holder.RemotePlaylistItemHolder; import org.schabi.newpipe.util.FallbackViewHolder; @@ -63,13 +68,19 @@ public class LocalItemListAdapter extends RecyclerView.Adapter localItems; @@ -77,10 +88,10 @@ public class LocalItemListAdapter extends RecyclerView.Adapter() { + itemListAdapter.setSelectedListener(new OnClickGesture<>() { @Override public void selected(final LocalItem selectedItem) { final FragmentManager fragmentManager = getFM(); @@ -518,24 +519,53 @@ public final class BookmarkFragment extends BaseLocalListFragment items = new ArrayList<>(); + items.add(rename); + items.add(delete); + if (isThumbnailPermanent) { + items.add(unsetThumbnail); + } + + final DialogInterface.OnClickListener action = (d, index) -> { + if (items.get(index).equals(rename)) { + showRenameDialog(selectedItem); + } else if (items.get(index).equals(delete)) { + showDeleteDialog(selectedItem.name, selectedItem); + } else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) { + final long thumbnailStreamId = localPlaylistManager + .getAutomaticPlaylistThumbnailStreamId(selectedItem.getUid()); + localPlaylistManager + .changePlaylistThumbnail(selectedItem.getUid(), thumbnailStreamId, false) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(); + } + }; + + new AlertDialog.Builder(activity) + .setItems(items.toArray(new String[0]), action) + .show(); + } + + private void showRenameDialog(final PlaylistMetadataEntry selectedItem) { + final DialogEditTextBinding dialogBinding = + DialogEditTextBinding.inflate(getLayoutInflater()); dialogBinding.dialogEditText.setHint(R.string.name); dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT); dialogBinding.dialogEditText.setText(selectedItem.name); - final AlertDialog.Builder builder = new AlertDialog.Builder(activity); - builder.setView(dialogBinding.getRoot()) + new AlertDialog.Builder(activity) + .setView(dialogBinding.getRoot()) .setPositiveButton(R.string.rename_playlist, (dialog, which) -> changeLocalPlaylistName( selectedItem.getUid(), dialogBinding.dialogEditText.getText().toString())) .setNegativeButton(R.string.cancel, null) - .setNeutralButton(R.string.delete, (dialog, which) -> { - showDeleteDialog(selectedItem.name, selectedItem); - dialog.dismiss(); - }) - .create() .show(); } diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java index a778e6578..e7f73079f 100644 --- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java +++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java @@ -4,6 +4,7 @@ import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; @@ -13,12 +14,11 @@ import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; -import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; +import org.schabi.newpipe.database.playlist.model.PlaylistEntity; +import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry; import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.local.LocalItemListAdapter; import org.schabi.newpipe.local.playlist.LocalPlaylistManager; -import org.schabi.newpipe.util.OnClickGesture; import java.util.List; @@ -30,6 +30,7 @@ public final class PlaylistAppendDialog extends PlaylistDialog { private RecyclerView playlistRecyclerView; private LocalItemListAdapter playlistAdapter; + private TextView playlistDuplicateIndicator; private final CompositeDisposable playlistDisposables = new CompositeDisposable(); @@ -63,18 +64,11 @@ public final class PlaylistAppendDialog extends PlaylistDialog { new LocalPlaylistManager(NewPipeDatabase.getInstance(requireContext())); playlistAdapter = new LocalItemListAdapter(getActivity()); - playlistAdapter.setSelectedListener(new OnClickGesture() { - @Override - public void selected(final LocalItem selectedItem) { - if (!(selectedItem instanceof PlaylistMetadataEntry) - || getStreamEntities() == null) { - return; - } - onPlaylistSelected( - playlistManager, - (PlaylistMetadataEntry) selectedItem, - getStreamEntities() - ); + playlistAdapter.setSelectedListener(selectedItem -> { + final List entities = getStreamEntities(); + if (selectedItem instanceof PlaylistDuplicatesEntry && entities != null) { + onPlaylistSelected(playlistManager, + (PlaylistDuplicatesEntry) selectedItem, entities); } }); @@ -82,10 +76,13 @@ public final class PlaylistAppendDialog extends PlaylistDialog { playlistRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); playlistRecyclerView.setAdapter(playlistAdapter); + playlistDuplicateIndicator = view.findViewById(R.id.playlist_duplicate); + final View newPlaylistButton = view.findViewById(R.id.newPlaylist); newPlaylistButton.setOnClickListener(ignored -> openCreatePlaylistDialog()); - playlistDisposables.add(playlistManager.getDisplayIndexOrderedPlaylists() + playlistDisposables.add(playlistManager + .getPlaylistDuplicates(getStreamEntities().get(0).getUrl()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(this::onPlaylistsReceived)); } @@ -127,34 +124,50 @@ public final class PlaylistAppendDialog extends PlaylistDialog { requireDialog().dismiss(); } - private void onPlaylistsReceived(@NonNull final List playlists) { - if (playlistAdapter != null && playlistRecyclerView != null) { + private void onPlaylistsReceived(@NonNull final List playlists) { + if (playlistAdapter != null + && playlistRecyclerView != null + && playlistDuplicateIndicator != null) { playlistAdapter.clearStreamItemList(); playlistAdapter.addItems(playlists); playlistRecyclerView.setVisibility(View.VISIBLE); + playlistDuplicateIndicator.setVisibility( + anyPlaylistContainsDuplicates(playlists) ? View.VISIBLE : View.GONE); } } + private boolean anyPlaylistContainsDuplicates(final List playlists) { + return playlists.stream() + .anyMatch(playlist -> playlist.timesStreamIsContained > 0); + } + private void onPlaylistSelected(@NonNull final LocalPlaylistManager manager, - @NonNull final PlaylistMetadataEntry playlist, + @NonNull final PlaylistDuplicatesEntry playlist, @NonNull final List streams) { - if (getStreamEntities() == null) { - return; + + final String toastText; + if (playlist.timesStreamIsContained > 0) { + toastText = getString(R.string.playlist_add_stream_success_duplicate, + playlist.timesStreamIsContained); + } else { + toastText = getString(R.string.playlist_add_stream_success); } - final Toast successToast = Toast.makeText(getContext(), - R.string.playlist_add_stream_success, Toast.LENGTH_SHORT); - - if (playlist.thumbnailUrl.equals("drawable://" + R.drawable.dummy_thumbnail_playlist)) { - playlistDisposables.add(manager - .changePlaylistThumbnail(playlist.getUid(), streams.get(0).getThumbnailUrl()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(ignored -> successToast.show())); - } + final Toast successToast = Toast.makeText(getContext(), toastText, Toast.LENGTH_SHORT); playlistDisposables.add(manager.appendToPlaylist(playlist.getUid(), streams) .observeOn(AndroidSchedulers.mainThread()) - .subscribe(ignored -> successToast.show())); + .subscribe(ignored -> { + successToast.show(); + + if (playlist.thumbnailUrl.equals(PlaylistEntity.DEFAULT_THUMBNAIL)) { + playlistDisposables.add(manager + .changePlaylistThumbnail(playlist.getUid(), streams.get(0).getUid(), + false) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(ignore -> successToast.show())); + } + })); requireDialog().dismiss(); } diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java index 0c09f3f0d..0d5cfac23 100644 --- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java +++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistCreationDialog.java @@ -45,8 +45,8 @@ public final class PlaylistCreationDialog extends PlaylistDialog { return super.onCreateDialog(savedInstanceState); } - final DialogEditTextBinding dialogBinding - = DialogEditTextBinding.inflate(getLayoutInflater()); + final DialogEditTextBinding dialogBinding = + DialogEditTextBinding.inflate(getLayoutInflater()); dialogBinding.getRoot().getContext().setTheme(ThemeHelper.getDialogTheme(requireContext())); dialogBinding.dialogEditText.setHint(R.string.name); dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT); diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java index f568ef81a..612c38181 100644 --- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java +++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistDialog.java @@ -9,15 +9,20 @@ import android.view.Window; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.DialogFragment; +import androidx.fragment.app.FragmentManager; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.local.playlist.LocalPlaylistManager; +import org.schabi.newpipe.player.Player; import org.schabi.newpipe.util.StateSaver; import java.util.List; +import java.util.Objects; import java.util.Queue; import java.util.function.Consumer; +import java.util.stream.Collectors; +import java.util.stream.Stream; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.Disposable; @@ -131,13 +136,13 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave * @param context context used for accessing the database * @param streamEntities used for crating the dialog * @param onExec execution that should occur after a dialog got created, e.g. showing it - * @return Disposable + * @return the disposable that was created */ public static Disposable createCorrespondingDialog( final Context context, final List streamEntities, - final Consumer onExec - ) { + final Consumer onExec) { + return new LocalPlaylistManager(NewPipeDatabase.getInstance(context)) .hasPlaylists() .observeOn(AndroidSchedulers.mainThread()) @@ -147,4 +152,30 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave : PlaylistCreationDialog.newInstance(streamEntities)) ); } + + /** + * Creates a {@link PlaylistAppendDialog} when playlists exists, + * otherwise a {@link PlaylistCreationDialog}. If the player's play queue is null or empty, no + * dialog will be created. + * + * @param player the player from which to extract the context and the play queue + * @param fragmentManager the fragment manager to use to show the dialog + * @return the disposable that was created + */ + public static Disposable showForPlayQueue( + final Player player, + @NonNull final FragmentManager fragmentManager) { + + final List streamEntities = Stream.of(player.getPlayQueue()) + .filter(Objects::nonNull) + .flatMap(playQueue -> playQueue.getStreams().stream()) + .map(StreamEntity::new) + .collect(Collectors.toList()); + if (streamEntities.isEmpty()) { + return Disposable.empty(); + } + + return PlaylistDialog.createCorrespondingDialog(player.getContext(), streamEntities, + dialog -> dialog.show(fragmentManager, "PlaylistDialog")); + } } diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt index 7a8723ceb..ed65d4048 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt @@ -41,19 +41,17 @@ class FeedDatabaseManager(context: Context) { fun database() = database fun getStreams( - groupId: Long = FeedGroupEntity.GROUP_ALL_ID, - getPlayedStreams: Boolean = true + groupId: Long, + includePlayedStreams: Boolean, + includePartiallyPlayedStreams: Boolean, + includeFutureStreams: Boolean ): Maybe> { - return when (groupId) { - FeedGroupEntity.GROUP_ALL_ID -> { - if (getPlayedStreams) feedTable.getAllStreams() - else feedTable.getLiveOrNotPlayedStreams() - } - else -> { - if (getPlayedStreams) feedTable.getAllStreamsForGroup(groupId) - else feedTable.getLiveOrNotPlayedStreamsForGroup(groupId) - } - } + return feedTable.getStreams( + groupId, + includePlayedStreams, + includePartiallyPlayedStreams, + if (includeFutureStreams) null else OffsetDateTime.now() + ) } fun outdatedSubscriptions(outdatedThreshold: OffsetDateTime) = feedTable.getAllOutdated(outdatedThreshold) diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt index e97629f31..0b61f45fb 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt @@ -25,7 +25,6 @@ import android.content.Context import android.content.Intent import android.content.SharedPreferences import android.graphics.Typeface -import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable import android.os.Bundle import android.os.Parcelable @@ -37,10 +36,7 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.Button -import androidx.annotation.AttrRes -import androidx.annotation.Nullable import androidx.appcompat.app.AlertDialog -import androidx.appcompat.content.res.AppCompatResources import androidx.core.content.edit import androidx.core.os.bundleOf import androidx.core.view.isVisible @@ -63,12 +59,14 @@ import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.databinding.FragmentFeedBinding import org.schabi.newpipe.error.ErrorInfo +import org.schabi.newpipe.error.ErrorUtil import org.schabi.newpipe.error.UserAction import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty import org.schabi.newpipe.fragments.BaseStateFragment +import org.schabi.newpipe.info_list.ItemViewMode import org.schabi.newpipe.info_list.dialog.InfoItemDialog import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling @@ -80,6 +78,8 @@ import org.schabi.newpipe.util.DeviceUtils import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams +import org.schabi.newpipe.util.ThemeHelper.getItemViewMode +import org.schabi.newpipe.util.ThemeHelper.resolveDrawable import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout import java.time.OffsetDateTime import java.util.function.Consumer @@ -98,7 +98,6 @@ class FeedFragment : BaseStateFragment() { private var oldestSubscriptionUpdate: OffsetDateTime? = null private lateinit var groupAdapter: GroupieAdapter - @State @JvmField var showPlayedItems: Boolean = true private var onSettingsChangeListener: SharedPreferences.OnSharedPreferenceChangeListener? = null private var updateListViewModeOnResume = false @@ -118,7 +117,7 @@ class FeedFragment : BaseStateFragment() { groupName = arguments?.getString(KEY_GROUP_NAME) ?: "" onSettingsChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - if (key.equals(getString(R.string.list_view_mode_key))) { + if (getString(R.string.list_view_mode_key).equals(key)) { updateListViewModeOnResume = true } } @@ -135,9 +134,8 @@ class FeedFragment : BaseStateFragment() { _feedBinding = FragmentFeedBinding.bind(rootView) super.onViewCreated(rootView, savedInstanceState) - val factory = FeedViewModel.Factory(requireContext(), groupId) - viewModel = ViewModelProvider(this, factory).get(FeedViewModel::class.java) - showPlayedItems = viewModel.getShowPlayedItemsFromPreferences() + val factory = FeedViewModel.getFactory(requireContext(), groupId) + viewModel = ViewModelProvider(this, factory)[FeedViewModel::class.java] viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) } groupAdapter = GroupieAdapter().apply { @@ -212,7 +210,6 @@ class FeedFragment : BaseStateFragment() { activity.supportActionBar?.subtitle = groupName inflater.inflate(R.menu.menu_feed_fragment, menu) - updateTogglePlayedItemsButton(menu.findItem(R.id.menu_item_feed_toggle_played_items)) } override fun onOptionsItemSelected(item: MenuItem): Boolean { @@ -234,19 +231,42 @@ class FeedFragment : BaseStateFragment() { } } .setPositiveButton(resources.getString(R.string.ok), null) - .create() .show() return true } else if (item.itemId == R.id.menu_item_feed_toggle_played_items) { - showPlayedItems = !item.isChecked - updateTogglePlayedItemsButton(item) - viewModel.togglePlayedItems(showPlayedItems) - viewModel.saveShowPlayedItemsToPreferences(showPlayedItems) + showStreamVisibilityDialog() } return super.onOptionsItemSelected(item) } + private fun showStreamVisibilityDialog() { + val dialogItems = arrayOf( + getString(R.string.feed_show_watched), + getString(R.string.feed_show_partially_watched), + getString(R.string.feed_show_upcoming) + ) + + val checkedDialogItems = booleanArrayOf( + viewModel.getShowPlayedItemsFromPreferences(), + viewModel.getShowPartiallyPlayedItemsFromPreferences(), + viewModel.getShowFutureItemsFromPreferences() + ) + + AlertDialog.Builder(context!!) + .setTitle(R.string.feed_hide_streams_title) + .setMultiChoiceItems(dialogItems, checkedDialogItems) { _, which, isChecked -> + checkedDialogItems[which] = isChecked + } + .setPositiveButton(R.string.ok) { _, _ -> + viewModel.setSaveShowPlayedItems(checkedDialogItems[0]) + viewModel.setSaveShowPartiallyPlayedItems(checkedDialogItems[1]) + viewModel.setSaveShowFutureItems(checkedDialogItems[2]) + } + .setNegativeButton(R.string.cancel, null) + .show() + } + override fun onDestroyOptionsMenu() { super.onDestroyOptionsMenu() activity?.supportActionBar?.subtitle = null @@ -273,14 +293,6 @@ class FeedFragment : BaseStateFragment() { super.onDestroyView() } - private fun updateTogglePlayedItemsButton(menuItem: MenuItem) { - menuItem.isChecked = showPlayedItems - menuItem.icon = AppCompatResources.getDrawable( - requireContext(), - if (showPlayedItems) R.drawable.ic_visibility_on else R.drawable.ic_visibility_off - ) - } - // ////////////////////////////////////////////////////////////////////////// // Handling // ////////////////////////////////////////////////////////////////////////// @@ -381,11 +393,10 @@ class FeedFragment : BaseStateFragment() { @SuppressLint("StringFormatMatches") private fun handleLoadedState(loadedState: FeedState.LoadedState) { - - val itemVersion = if (shouldUseGridLayout(context)) { - StreamItem.ItemVersion.GRID - } else { - StreamItem.ItemVersion.NORMAL + val itemVersion = when (getItemViewMode(requireContext())) { + ItemViewMode.GRID -> StreamItem.ItemVersion.GRID + ItemViewMode.CARD -> StreamItem.ItemVersion.CARD + else -> StreamItem.ItemVersion.NORMAL } loadedState.items.forEach { it.itemVersion = itemVersion } @@ -442,29 +453,38 @@ class FeedFragment : BaseStateFragment() { if (t is FeedLoadService.RequestException && t.cause is ContentNotAvailableException ) { - Single.fromCallable { - NewPipeDatabase.getInstance(requireContext()).subscriptionDAO() - .getSubscription(t.subscriptionId) - }.subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - { subscriptionEntity -> - handleFeedNotAvailable( - subscriptionEntity, - t.cause, - errors.subList(i + 1, errors.size) - ) - }, - { throwable -> Log.e(TAG, "Unable to process", throwable) } - ) - return // this will be called on the remaining errors by handleFeedNotAvailable() + disposables.add( + Single.fromCallable { + NewPipeDatabase.getInstance(requireContext()).subscriptionDAO() + .getSubscription(t.subscriptionId) + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + { subscriptionEntity -> + handleFeedNotAvailable( + subscriptionEntity, + t.cause, + errors.subList(i + 1, errors.size) + ) + }, + { throwable -> Log.e(TAG, "Unable to process", throwable) } + ) + ) + // this will be called on the remaining errors by handleFeedNotAvailable() + return@handleItemsErrors } } + + if (errors.isNotEmpty()) { + // if no error was a ContentNotAvailableException, show a general error snackbar + ErrorUtil.showSnackbar(this, ErrorInfo(errors, UserAction.REQUESTED_FEED, "")) + } } private fun handleFeedNotAvailable( subscriptionEntity: SubscriptionEntity, - @Nullable cause: Throwable?, + cause: Throwable?, nextItemsErrors: List ) { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) @@ -474,15 +494,13 @@ class FeedFragment : BaseStateFragment() { val builder = AlertDialog.Builder(requireContext()) .setTitle(R.string.feed_load_error) - .setPositiveButton( - R.string.unsubscribe - ) { _, _ -> - SubscriptionManager(requireContext()).deleteSubscription( - subscriptionEntity.serviceId, subscriptionEntity.url - ).subscribe() + .setPositiveButton(R.string.unsubscribe) { _, _ -> + SubscriptionManager(requireContext()) + .deleteSubscription(subscriptionEntity.serviceId, subscriptionEntity.url) + .subscribe() handleItemsErrors(nextItemsErrors) } - .setNegativeButton(R.string.cancel) { _, _ -> } + .setNegativeButton(R.string.cancel, null) var message = getString(R.string.feed_load_error_account_info, subscriptionEntity.name) if (cause is AccountTerminatedException) { @@ -499,7 +517,8 @@ class FeedFragment : BaseStateFragment() { message += "\n" + cause.message } } - builder.setMessage(message).create().show() + builder.setMessage(message) + .show() } private fun updateRelativeTimeViews() { @@ -569,7 +588,7 @@ class FeedFragment : BaseStateFragment() { // state until the user scrolls them out of the visible area which causes a update/bind-call groupAdapter.notifyItemRangeChanged( 0, - minOf(groupAdapter.itemCount, maxOf(highlightCount, lastNewItemsCount)) + highlightCount.coerceIn(lastNewItemsCount, groupAdapter.itemCount) ) if (highlightCount > 0) { @@ -579,19 +598,6 @@ class FeedFragment : BaseStateFragment() { lastNewItemsCount = highlightCount } - private fun resolveDrawable(context: Context, @AttrRes attrResId: Int): Drawable? { - return androidx.core.content.ContextCompat.getDrawable( - context, - android.util.TypedValue().apply { - context.theme.resolveAttribute( - attrResId, - this, - true - ) - }.resourceId - ) - } - private fun showNewItemsLoaded() { tryGetNewItemsLoadedButton()?.clearAnimation() tryGetNewItemsLoadedButton() @@ -601,9 +607,13 @@ class FeedFragment : BaseStateFragment() { execOnEnd = { // Disabled animations would result in immediately hiding the button // after it showed up - if (DeviceUtils.hasAnimationsAnimatorDurationEnabled(context)) { - // Hide the new items-"popup" after 10s - hideNewItemsLoaded(true, 10000) + // Context can be null in some cases, so we have to make sure it is not null in + // order to avoid a NullPointerException + context?.let { + if (DeviceUtils.hasAnimationsAnimatorDurationEnabled(it)) { + // Hide the new items button after 10s + hideNewItemsLoaded(true, 10000) + } } } ) diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt index 27613e83e..665ebbe43 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedState.kt @@ -13,9 +13,9 @@ sealed class FeedState { data class LoadedState( val items: List, - val oldestUpdate: OffsetDateTime? = null, + val oldestUpdate: OffsetDateTime?, val notLoadedCount: Long, - val itemsErrors: List = emptyList() + val itemsErrors: List ) : FeedState() data class ErrorState( diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt index e21963c16..728570b17 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt @@ -1,17 +1,20 @@ package org.schabi.newpipe.local.feed +import android.app.Application import android.content.Context import androidx.core.content.edit import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory import androidx.preference.PreferenceManager import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Flowable -import io.reactivex.rxjava3.functions.Function4 +import io.reactivex.rxjava3.functions.Function6 import io.reactivex.rxjava3.processors.BehaviorProcessor import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.App import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.stream.StreamWithState @@ -26,39 +29,53 @@ import java.time.OffsetDateTime import java.util.concurrent.TimeUnit class FeedViewModel( - private val applicationContext: Context, + private val application: Application, groupId: Long = FeedGroupEntity.GROUP_ALL_ID, - initialShowPlayedItems: Boolean = true + initialShowPlayedItems: Boolean, + initialShowPartiallyPlayedItems: Boolean, + initialShowFutureItems: Boolean ) : ViewModel() { - private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext) + private val feedDatabaseManager = FeedDatabaseManager(application) - private val toggleShowPlayedItems = BehaviorProcessor.create() - private val toggleShowPlayedItemsFlowable = toggleShowPlayedItems + private val showPlayedItems = BehaviorProcessor.create() + private val showPlayedItemsFlowable = showPlayedItems .startWithItem(initialShowPlayedItems) .distinctUntilChanged() + private val showPartiallyPlayedItems = BehaviorProcessor.create() + private val showPartiallyPlayedItemsFlowable = showPartiallyPlayedItems + .startWithItem(initialShowPartiallyPlayedItems) + .distinctUntilChanged() + + private val showFutureItems = BehaviorProcessor.create() + private val showFutureItemsFlowable = showFutureItems + .startWithItem(initialShowFutureItems) + .distinctUntilChanged() + private val mutableStateLiveData = MutableLiveData() val stateLiveData: LiveData = mutableStateLiveData private var combineDisposable = Flowable .combineLatest( FeedEventManager.events(), - toggleShowPlayedItemsFlowable, + showPlayedItemsFlowable, + showPartiallyPlayedItemsFlowable, + showFutureItemsFlowable, feedDatabaseManager.notLoadedCount(groupId), feedDatabaseManager.oldestSubscriptionUpdate(groupId), - Function4 { t1: FeedEventManager.Event, t2: Boolean, - t3: Long, t4: List -> - return@Function4 CombineResultEventHolder(t1, t2, t3, t4.firstOrNull()) + Function6 { t1: FeedEventManager.Event, t2: Boolean, t3: Boolean, t4: Boolean, + t5: Long, t6: List -> + return@Function6 CombineResultEventHolder(t1, t2, t3, t4, t5, t6.firstOrNull()) } ) .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS) .subscribeOn(Schedulers.io()) .observeOn(Schedulers.io()) - .map { (event, showPlayedItems, notLoadedCount, oldestUpdate) -> + .map { (event, showPlayedItems, showPartiallyPlayedItems, showFutureItems, notLoadedCount, oldestUpdate) -> val streamItems = if (event is SuccessResultEvent || event is IdleEvent) feedDatabaseManager - .getStreams(groupId, showPlayedItems) + .getStreams(groupId, showPlayedItems, showPartiallyPlayedItems, showFutureItems) .blockingGet(arrayListOf()) else arrayListOf() @@ -69,7 +86,7 @@ class FeedViewModel( .subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) -> mutableStateLiveData.postValue( when (event) { - is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount) + is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, listOf()) is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage) is SuccessResultEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, event.itemsErrors) is ErrorResultEvent -> FeedState.ErrorState(event.error) @@ -89,8 +106,10 @@ class FeedViewModel( private data class CombineResultEventHolder( val t1: FeedEventManager.Event, val t2: Boolean, - val t3: Long, - val t4: OffsetDateTime? + val t3: Boolean, + val t4: Boolean, + val t5: Long, + val t6: OffsetDateTime? ) private data class CombineResultDataHolder( @@ -100,36 +119,60 @@ class FeedViewModel( val t4: OffsetDateTime? ) - fun togglePlayedItems(showPlayedItems: Boolean) { - toggleShowPlayedItems.onNext(showPlayedItems) - } - - fun saveShowPlayedItemsToPreferences(showPlayedItems: Boolean) = - PreferenceManager.getDefaultSharedPreferences(applicationContext).edit { - this.putBoolean(applicationContext.getString(R.string.feed_show_played_items_key), showPlayedItems) + fun setSaveShowPlayedItems(showPlayedItems: Boolean) { + this.showPlayedItems.onNext(showPlayedItems) + PreferenceManager.getDefaultSharedPreferences(application).edit { + this.putBoolean(application.getString(R.string.feed_show_watched_items_key), showPlayedItems) this.apply() } + } - fun getShowPlayedItemsFromPreferences() = getShowPlayedItemsFromPreferences(applicationContext) + fun getShowPlayedItemsFromPreferences() = getShowPlayedItemsFromPreferences(application) + + fun setSaveShowPartiallyPlayedItems(showPartiallyPlayedItems: Boolean) { + this.showPartiallyPlayedItems.onNext(showPartiallyPlayedItems) + PreferenceManager.getDefaultSharedPreferences(application).edit { + this.putBoolean(application.getString(R.string.feed_show_partially_watched_items_key), showPartiallyPlayedItems) + this.apply() + } + } + + fun getShowPartiallyPlayedItemsFromPreferences() = getShowPartiallyPlayedItemsFromPreferences(application) + + fun setSaveShowFutureItems(showFutureItems: Boolean) { + this.showFutureItems.onNext(showFutureItems) + PreferenceManager.getDefaultSharedPreferences(application).edit { + this.putBoolean(application.getString(R.string.feed_show_future_items_key), showFutureItems) + this.apply() + } + } + + fun getShowFutureItemsFromPreferences() = getShowFutureItemsFromPreferences(application) companion object { private fun getShowPlayedItemsFromPreferences(context: Context) = PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean(context.getString(R.string.feed_show_played_items_key), true) - } + .getBoolean(context.getString(R.string.feed_show_watched_items_key), true) - class Factory( - private val context: Context, - private val groupId: Long = FeedGroupEntity.GROUP_ALL_ID - ) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return FeedViewModel( - context.applicationContext, - groupId, - // Read initial value from preferences - getShowPlayedItemsFromPreferences(context.applicationContext) - ) as T + private fun getShowPartiallyPlayedItemsFromPreferences(context: Context) = + PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.feed_show_partially_watched_items_key), true) + + private fun getShowFutureItemsFromPreferences(context: Context) = + PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.feed_show_future_items_key), true) + + fun getFactory(context: Context, groupId: Long) = viewModelFactory { + initializer { + FeedViewModel( + App.getApp(), + groupId, + // Read initial value from preferences + getShowPlayedItemsFromPreferences(context.applicationContext), + getShowPartiallyPlayedItemsFromPreferences(context.applicationContext), + getShowFutureItemsFromPreferences(context.applicationContext) + ) + } } } } diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt index 217e3f3e3..4a071d6df 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt @@ -14,10 +14,12 @@ import org.schabi.newpipe.databinding.ListStreamItemBinding import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM import org.schabi.newpipe.extractor.stream.StreamType.AUDIO_STREAM import org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM +import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_AUDIO_STREAM +import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_STREAM import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM import org.schabi.newpipe.util.Localization -import org.schabi.newpipe.util.PicassoHelper import org.schabi.newpipe.util.StreamTypeUtil +import org.schabi.newpipe.util.image.PicassoHelper import java.util.concurrent.TimeUnit import java.util.function.Consumer @@ -40,12 +42,13 @@ data class StreamItem( override fun getId(): Long = stream.uid - enum class ItemVersion { NORMAL, MINI, GRID } + enum class ItemVersion { NORMAL, MINI, GRID, CARD } override fun getLayout(): Int = when (itemVersion) { ItemVersion.NORMAL -> R.layout.list_stream_item ItemVersion.MINI -> R.layout.list_stream_mini_item ItemVersion.GRID -> R.layout.list_stream_grid_item + ItemVersion.CARD -> R.layout.list_stream_card_item } override fun initializeViewBinding(view: View) = ListStreamItemBinding.bind(view) @@ -109,7 +112,7 @@ data class StreamItem( } override fun isLongClickable() = when (stream.streamType) { - AUDIO_STREAM, VIDEO_STREAM, LIVE_STREAM, AUDIO_LIVE_STREAM -> true + AUDIO_STREAM, VIDEO_STREAM, LIVE_STREAM, AUDIO_LIVE_STREAM, POST_LIVE_STREAM, POST_LIVE_AUDIO_STREAM -> true else -> false } diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt index 3a08b3e4a..8ea89368d 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationHelper.kt @@ -1,51 +1,54 @@ package org.schabi.newpipe.local.feed.notifications +import android.app.Notification import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.graphics.Bitmap +import android.graphics.drawable.Drawable import android.net.Uri import android.os.Build import android.provider.Settings import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService import androidx.preference.PreferenceManager +import com.squareup.picasso.Picasso +import com.squareup.picasso.Target import org.schabi.newpipe.R import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.local.feed.service.FeedUpdateInfo -import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.NavigationHelper -import org.schabi.newpipe.util.PicassoHelper +import org.schabi.newpipe.util.image.PicassoHelper /** * Helper for everything related to show notifications about new streams to the user. */ class NotificationHelper(val context: Context) { - - private val manager = context.getSystemService( - Context.NOTIFICATION_SERVICE - ) as NotificationManager + private val manager = NotificationManagerCompat.from(context) + private val iconLoadingTargets = ArrayList() /** - * Show a notification about new streams from a single channel. - * Opening the notification will open the corresponding channel page. + * Show notifications for new streams from a single channel. The individual notifications are + * expandable on Android 7.0 and later. + * + * Opening the summary notification will open the corresponding channel page. Opening the + * individual notifications will open the corresponding video. */ - fun displayNewStreamsNotification(data: FeedUpdateInfo) { - val newStreams: List = data.newStreams + fun displayNewStreamsNotifications(data: FeedUpdateInfo) { + val newStreams = data.newStreams val summary = context.resources.getQuantityString( R.plurals.new_streams, newStreams.size, newStreams.size ) - val builder = NotificationCompat.Builder( + val summaryBuilder = NotificationCompat.Builder( context, context.getString(R.string.streams_notification_channel_id) ) - .setContentTitle(Localization.concatenateStrings(data.name, summary)) - .setContentText( - data.listInfo.relatedItems.joinToString( - context.getString(R.string.enumeration_comma) - ) { x -> x.name } - ) + .setContentTitle(data.name) + .setContentText(summary) .setNumber(newStreams.size) .setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE) .setPriority(NotificationCompat.PRIORITY_DEFAULT) @@ -54,33 +57,105 @@ class NotificationHelper(val context: Context) { .setColorized(true) .setAutoCancel(true) .setCategory(NotificationCompat.CATEGORY_SOCIAL) + .setGroupSummary(true) + .setGroup(data.url) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) - // Build style + // Build a summary notification for Android versions < 7.0 val style = NotificationCompat.InboxStyle() + .setBigContentTitle(data.name) newStreams.forEach { style.addLine(it.name) } - style.setSummaryText(summary) - style.setBigContentTitle(data.name) - builder.setStyle(style) + summaryBuilder.setStyle(style) - // open the channel page when clicking on the notification - builder.setContentIntent( - PendingIntent.getActivity( + // open the channel page when clicking on the summary notification + summaryBuilder.setContentIntent( + PendingIntentCompat.getActivity( context, data.pseudoId, NavigationHelper - .getChannelIntent(context, data.listInfo.serviceId, data.listInfo.url) + .getChannelIntent(context, data.serviceId, data.url) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) - PendingIntent.FLAG_IMMUTABLE - else - 0 + 0, + false ) ) - PicassoHelper.loadNotificationIcon(data.avatarUrl) { bitmap -> - bitmap?.let { builder.setLargeIcon(it) } // set only if != null - manager.notify(data.pseudoId, builder.build()) + // a Target is like a listener for image loading events + val target = object : Target { + override fun onBitmapLoaded(bitmap: Bitmap, from: Picasso.LoadedFrom) { + // set channel icon only if there is actually one (for Android versions < 7.0) + summaryBuilder.setLargeIcon(bitmap) + + // Show individual stream notifications, set channel icon only if there is actually + // one + showStreamNotifications(newStreams, data.serviceId, bitmap) + // Show summary notification + manager.notify(data.pseudoId, summaryBuilder.build()) + + iconLoadingTargets.remove(this) // allow it to be garbage-collected + } + + override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) { + // Show individual stream notifications + showStreamNotifications(newStreams, data.serviceId, null) + // Show summary notification + manager.notify(data.pseudoId, summaryBuilder.build()) + iconLoadingTargets.remove(this) // allow it to be garbage-collected + } + + override fun onPrepareLoad(placeHolderDrawable: Drawable) { + // Nothing to do + } } + + // add the target to the list to hold a strong reference and prevent it from being garbage + // collected, since Picasso only holds weak references to targets + iconLoadingTargets.add(target) + + PicassoHelper.loadNotificationIcon(data.avatarUrl).into(target) + } + + private fun showStreamNotifications( + newStreams: List, + serviceId: Int, + channelIcon: Bitmap? + ) { + for (stream in newStreams) { + val notification = createStreamNotification(stream, serviceId, channelIcon) + manager.notify(stream.url.hashCode(), notification) + } + } + + private fun createStreamNotification( + item: StreamInfoItem, + serviceId: Int, + channelIcon: Bitmap? + ): Notification { + return NotificationCompat.Builder( + context, + context.getString(R.string.streams_notification_channel_id) + ) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setLargeIcon(channelIcon) + .setContentTitle(item.name) + .setContentText(item.uploaderName) + .setGroup(item.uploaderUrl) + .setColor(ContextCompat.getColor(context, R.color.ic_launcher_background)) + .setColorized(true) + .setAutoCancel(true) + .setCategory(NotificationCompat.CATEGORY_SOCIAL) + .setContentIntent( + // Open the stream link in the player when clicking on the notification. + PendingIntentCompat.getActivity( + context, + item.url.hashCode(), + NavigationHelper.getStreamIntent(context, serviceId, item.url, item.name), + PendingIntent.FLAG_UPDATE_CURRENT, + false + ) + ) + .setSilent(true) // Avoid creating noise for individual stream notifications. + .build() } companion object { @@ -101,9 +176,7 @@ class NotificationHelper(val context: Context) { fun areNotificationsEnabledOnDevice(context: Context): Boolean { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channelId = context.getString(R.string.streams_notification_channel_id) - val manager = context.getSystemService( - Context.NOTIFICATION_SERVICE - ) as NotificationManager + val manager = context.getSystemService()!! val enabled = manager.areNotificationsEnabled() val channel = manager.getNotificationChannel(channelId) val importance = channel?.importance diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt index 6b9580802..a40bf35dc 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt @@ -55,7 +55,7 @@ class NotificationWorker( .map { feedUpdateInfoList -> // display notifications for each feedUpdateInfo (i.e. channel) feedUpdateInfoList.forEach { feedUpdateInfo -> - notificationHelper.displayNewStreamsNotification(feedUpdateInfo) + notificationHelper.displayNewStreamsNotifications(feedUpdateInfo) } return@map Result.success() } @@ -137,7 +137,7 @@ class NotificationWorker( .enqueueUniquePeriodicWork( WORK_TAG, if (force) { - ExistingPeriodicWorkPolicy.REPLACE + ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE } else { ExistingPeriodicWorkPolicy.KEEP }, diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt index 3d19de9c6..1c2826e7a 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedEventManager.kt @@ -26,7 +26,7 @@ object FeedEventManager { } sealed class Event { - object IdleEvent : Event() + data object IdleEvent : Event() data class ProgressEvent(val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0) : Event() { constructor(@StringRes progressMessage: Int) : this(-1, -1, progressMessage) } diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt index fec50a579..0b6a8068c 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadManager.kt @@ -1,6 +1,7 @@ package org.schabi.newpipe.local.feed.service import android.content.Context +import android.content.SharedPreferences import androidx.preference.PreferenceManager import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Completable @@ -13,11 +14,17 @@ import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.subscription.NotificationMode -import org.schabi.newpipe.extractor.ListInfo +import org.schabi.newpipe.database.subscription.SubscriptionEntity +import org.schabi.newpipe.extractor.Info +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.feed.FeedInfo import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.local.feed.FeedDatabaseManager import org.schabi.newpipe.local.subscription.SubscriptionManager -import org.schabi.newpipe.util.ExtractorHelper +import org.schabi.newpipe.util.ChannelTabHelper +import org.schabi.newpipe.util.ExtractorHelper.getChannelInfo +import org.schabi.newpipe.util.ExtractorHelper.getChannelTab +import org.schabi.newpipe.util.ExtractorHelper.getMoreChannelTabItems import java.time.OffsetDateTime import java.time.ZoneOffset import java.util.concurrent.atomic.AtomicBoolean @@ -75,7 +82,9 @@ class FeedLoadManager(private val context: Context) { * subscriptions which have not been updated within the feed updated threshold */ val outdatedSubscriptions = when (groupId) { - FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold) + FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions( + outdatedThreshold + ) GROUP_NOTIFICATION_ENABLED -> feedDatabaseManager.outdatedSubscriptionsWithNotificationMode( outdatedThreshold, NotificationMode.ENABLED ) @@ -101,52 +110,7 @@ class FeedLoadManager(private val context: Context) { .runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2) .filter { !cancelSignal.get() } .map { subscriptionEntity -> - var error: Throwable? = null - try { - // check for and load new streams - // either by using the dedicated feed method or by getting the channel info - val listInfo = if (useFeedExtractor) { - ExtractorHelper - .getFeedInfoFallbackToChannelInfo( - subscriptionEntity.serviceId, - subscriptionEntity.url - ) - .onErrorReturn { - error = it // store error, otherwise wrapped into RuntimeException - throw it - } - .blockingGet() - } else { - ExtractorHelper - .getChannelInfo( - subscriptionEntity.serviceId, - subscriptionEntity.url, - true - ) - .onErrorReturn { - error = it // store error, otherwise wrapped into RuntimeException - throw it - } - .blockingGet() - } as ListInfo - - return@map Notification.createOnNext( - FeedUpdateInfo( - subscriptionEntity, - listInfo - ) - ) - } catch (e: Throwable) { - if (error == null) { - // do this to prevent blockingGet() from wrapping into RuntimeException - error = e - } - - val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}" - val wrapper = - FeedLoadService.RequestException(subscriptionEntity.uid, request, error!!) - return@map Notification.createOnError(wrapper) - } + loadStreams(subscriptionEntity, useFeedExtractor, defaultSharedPreferences) } .sequential() .observeOn(AndroidSchedulers.mainThread()) @@ -164,7 +128,112 @@ class FeedLoadManager(private val context: Context) { } private fun broadcastProgress() { - FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(currentProgress.get(), maxProgress.get())) + FeedEventManager.postEvent( + FeedEventManager.Event.ProgressEvent( + currentProgress.get(), + maxProgress.get() + ) + ) + } + + private fun loadStreams( + subscriptionEntity: SubscriptionEntity, + useFeedExtractor: Boolean, + defaultSharedPreferences: SharedPreferences + ): Notification { + var error: Throwable? = null + val storeOriginalErrorAndRethrow = { e: Throwable -> + // keep original to prevent blockingGet() from wrapping it into RuntimeException + error = e + throw e + } + + try { + // check for and load new streams + // either by using the dedicated feed method or by getting the channel info + var originalInfo: Info? = null + var streams: List? = null + val errors = ArrayList() + + if (useFeedExtractor) { + NewPipe.getService(subscriptionEntity.serviceId) + .getFeedExtractor(subscriptionEntity.url) + ?.also { feedExtractor -> + // the user wants to use a feed extractor and there is one, use it + val feedInfo = FeedInfo.getInfo(feedExtractor) + errors.addAll(feedInfo.errors) + originalInfo = feedInfo + streams = feedInfo.relatedItems + } + } + + if (originalInfo == null) { + // use the normal channel tabs extractor if either the user wants it, or + // the current service does not have a dedicated feed extractor + + val channelInfo = getChannelInfo( + subscriptionEntity.serviceId, + subscriptionEntity.url, true + ) + .onErrorReturn(storeOriginalErrorAndRethrow) + .blockingGet() + errors.addAll(channelInfo.errors) + originalInfo = channelInfo + + streams = channelInfo.tabs + .filter { tab -> + ChannelTabHelper.fetchFeedChannelTab( + context, + defaultSharedPreferences, + tab + ) + } + .map { + Pair( + getChannelTab(subscriptionEntity.serviceId, it, true) + .onErrorReturn(storeOriginalErrorAndRethrow) + .blockingGet(), + it + ) + } + .flatMap { (channelTabInfo, linkHandler) -> + errors.addAll(channelTabInfo.errors) + if (channelTabInfo.relatedItems.isEmpty() && + channelTabInfo.nextPage != null + ) { + val infoItemsPage = getMoreChannelTabItems( + subscriptionEntity.serviceId, + linkHandler, channelTabInfo.nextPage + ) + .blockingGet() + + errors.addAll(infoItemsPage.errors) + return@flatMap infoItemsPage.items + } else { + return@flatMap channelTabInfo.relatedItems + } + } + .filterIsInstance() + } + + return Notification.createOnNext( + FeedUpdateInfo( + subscriptionEntity, + originalInfo!!, + streams!!, + errors, + ) + ) + } catch (e: Throwable) { + val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}" + val wrapper = FeedLoadService.RequestException( + subscriptionEntity.uid, + request, + // do this to prevent blockingGet() from wrapping into RuntimeException + error ?: e + ) + return Notification.createOnError(wrapper) + } } /** @@ -203,24 +272,24 @@ class FeedLoadManager(private val context: Context) { for (notification in list) { when { notification.isOnNext -> { - val subscriptionId = notification.value!!.uid - val info = notification.value!!.listInfo + val info = notification.value!! - notification.value!!.newStreams = filterNewStreams( - notification.value!!.listInfo.relatedItems - ) + notification.value!!.newStreams = filterNewStreams(info.streams) - feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems) - subscriptionManager.updateFromInfo(subscriptionId, info) + feedDatabaseManager.upsertAll(info.uid, info.streams) + subscriptionManager.updateFromInfo(info) if (info.errors.isNotEmpty()) { feedResultsHolder.addErrors( - FeedLoadService.RequestException.wrapList( - subscriptionId, - info - ) + info.errors.map { + FeedLoadService.RequestException( + info.uid, + "${info.serviceId}:${info.url}", + it + ) + } ) - feedDatabaseManager.markAsOutdated(subscriptionId) + feedDatabaseManager.markAsOutdated(info.uid) } } notification.isOnError -> { diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt index f2ea40416..f960040de 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt @@ -19,7 +19,6 @@ package org.schabi.newpipe.local.feed.service -import android.app.PendingIntent import android.app.Service import android.content.BroadcastReceiver import android.content.Context @@ -30,6 +29,7 @@ import android.os.IBinder import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat import androidx.core.app.ServiceCompat import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Flowable @@ -39,8 +39,6 @@ import org.schabi.newpipe.App import org.schabi.newpipe.MainActivity.DEBUG import org.schabi.newpipe.R import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.extractor.ListInfo -import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent import java.util.concurrent.TimeUnit @@ -95,13 +93,7 @@ class FeedLoadService : Service() { .doOnSubscribe { startForeground(NOTIFICATION_ID, notificationBuilder.build()) } - .subscribe { _, error -> - // There seems to be a bug in the kotlin plugin as it tells you when - // building that this can't be null: - // "Condition 'error != null' is always 'true'" - // However it can indeed be null - // The suppression may be removed in further versions - @Suppress("SENSELESS_COMPARISON") + .subscribe { _, error: Throwable? -> // explicitly mark error as nullable if (error != null) { Log.e(TAG, "Error while storing result", error) handleError(error) @@ -132,17 +124,7 @@ class FeedLoadService : Service() { // Loading & Handling // ///////////////////////////////////////////////////////////////////////// - class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) { - companion object { - fun wrapList(subscriptionId: Long, info: ListInfo): List { - val toReturn = ArrayList(info.errors.size) - info.errors.mapTo(toReturn) { - RequestException(subscriptionId, info.serviceId.toString() + ":" + info.url, it) - } - return toReturn - } - } - } + class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) // ///////////////////////////////////////////////////////////////////////// // Notification @@ -152,12 +134,8 @@ class FeedLoadService : Service() { private lateinit var notificationBuilder: NotificationCompat.Builder private fun createNotification(): NotificationCompat.Builder { - val cancelActionIntent = PendingIntent.getBroadcast( - this, - NOTIFICATION_ID, - Intent(ACTION_CANCEL), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 - ) + val cancelActionIntent = PendingIntentCompat + .getBroadcast(this, NOTIFICATION_ID, Intent(ACTION_CANCEL), 0, false) return NotificationCompat.Builder(this, getString(R.string.notification_channel_id)) .setOngoing(true) diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt index 5f72a6b84..84cd8ed59 100644 --- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt +++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedUpdateInfo.kt @@ -2,33 +2,58 @@ package org.schabi.newpipe.local.feed.service import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.database.subscription.SubscriptionEntity -import org.schabi.newpipe.extractor.ListInfo +import org.schabi.newpipe.extractor.Info +import org.schabi.newpipe.extractor.channel.ChannelInfo import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.util.image.ImageStrategy +/** + * Instances of this class might stay around in memory for some time while fetching the feed, + * because of [FeedLoadManager.BUFFER_COUNT_BEFORE_INSERT]. Therefore this class should contain + * as little data as possible to avoid out of memory errors. In particular, avoid storing whole + * [ChannelInfo] objects, as they might contain raw JSON info in ready channel tabs link handlers. + */ data class FeedUpdateInfo( val uid: Long, @NotificationMode val notificationMode: Int, val name: String, val avatarUrl: String, - val listInfo: ListInfo, + val url: String, + val serviceId: Int, + // description and subscriberCount are null if the constructor info is from the fast feed method + val description: String?, + val subscriberCount: Long?, + val streams: List, + val errors: List, ) { constructor( subscription: SubscriptionEntity, - listInfo: ListInfo, + info: Info, + streams: List, + errors: List, ) : this( uid = subscription.uid, notificationMode = subscription.notificationMode, - name = subscription.name, - avatarUrl = subscription.avatarUrl, - listInfo = listInfo, + name = info.name, + avatarUrl = (info as? ChannelInfo)?.avatars?.let { + // if the newly fetched info is not from fast feed, then it contains updated avatars + ImageStrategy.imageListToDbUrl(it) + } ?: subscription.avatarUrl, + url = info.url, + serviceId = info.serviceId, + // there is no description and subscriberCount in the fast feed + description = (info as? ChannelInfo)?.description, + subscriberCount = (info as? ChannelInfo)?.subscriberCount, + streams = streams, + errors = errors, ) /** * Integer id, can be used as notification id, etc. */ val pseudoId: Int - get() = listInfo.url.hashCode() + get() = url.hashCode() lateinit var newStreams: List } diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java index 45445cf58..ed3cf548f 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java @@ -28,7 +28,6 @@ import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.database.AppDatabase; import org.schabi.newpipe.database.LocalItem; -import org.schabi.newpipe.database.feed.dao.FeedDAO; import org.schabi.newpipe.database.history.dao.SearchHistoryDAO; import org.schabi.newpipe.database.history.dao.StreamHistoryDAO; import org.schabi.newpipe.database.history.model.SearchHistoryEntry; @@ -51,7 +50,6 @@ import org.schabi.newpipe.util.ExtractorHelper; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.ArrayList; -import java.util.Collection; import java.util.List; import io.reactivex.rxjava3.core.Completable; @@ -89,8 +87,7 @@ public class HistoryRecordManager { * Marks a stream item as watched such that it is hidden from the feed if watched videos are * hidden. Adds a history entry and updates the stream progress to 100%. * - * @see FeedDAO#getLiveOrNotPlayedStreams - * @see FeedViewModel#togglePlayedItems + * @see FeedViewModel#setSaveShowPlayedItems * @param info the item to mark as watched * @return a Maybe containing the ID of the item if successful */ @@ -128,13 +125,11 @@ public class HistoryRecordManager { // Add a history entry final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId); - if (latestEntry != null) { - streamHistoryTable.delete(latestEntry); - latestEntry.setAccessDate(currentTime); - latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1); - return streamHistoryTable.insert(latestEntry); + if (latestEntry == null) { + // never actually viewed: add history entry but with 0 views + return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 0)); } else { - return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime)); + return 0L; } })).subscribeOn(Schedulers.io()); } @@ -155,7 +150,8 @@ public class HistoryRecordManager { latestEntry.setRepeatCount(latestEntry.getRepeatCount() + 1); return streamHistoryTable.insert(latestEntry); } else { - return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime)); + // just viewed for the first time: set 1 view + return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 1)); } })).subscribeOn(Schedulers.io()); } @@ -177,10 +173,6 @@ public class HistoryRecordManager { .subscribeOn(Schedulers.io()); } - public Flowable> getStreamHistory() { - return streamHistoryTable.getHistory().subscribeOn(Schedulers.io()); - } - public Flowable> getStreamHistorySortedById() { return streamHistoryTable.getHistorySortedById().subscribeOn(Schedulers.io()); } @@ -189,24 +181,6 @@ public class HistoryRecordManager { return streamHistoryTable.getStatistics().subscribeOn(Schedulers.io()); } - public Single> insertStreamHistory(final Collection entries) { - final List entities = new ArrayList<>(entries.size()); - for (final StreamHistoryEntry entry : entries) { - entities.add(entry.toStreamHistoryEntity()); - } - return Single.fromCallable(() -> streamHistoryTable.insertAll(entities)) - .subscribeOn(Schedulers.io()); - } - - public Single deleteStreamHistory(final Collection entries) { - final List entities = new ArrayList<>(entries.size()); - for (final StreamHistoryEntry entry : entries) { - entities.add(entry.toStreamHistoryEntity()); - } - return Single.fromCallable(() -> streamHistoryTable.delete(entities)) - .subscribeOn(Schedulers.io()); - } - private boolean isStreamHistoryEnabled() { return sharedPreferences.getBoolean(streamHistoryKey, false); } @@ -260,13 +234,6 @@ public class HistoryRecordManager { // Stream State History /////////////////////////////////////////////////////// - public Maybe getStreamHistory(final StreamInfo info) { - return Maybe.fromCallable(() -> { - final long streamId = streamTable.upsert(new StreamEntity(info)); - return streamHistoryTable.getLatestEntry(streamId); - }).subscribeOn(Schedulers.io()); - } - public Maybe loadStreamState(final PlayQueueItem queueItem) { return queueItem.getStream() .map(info -> streamTable.upsert(new StreamEntity(info))) @@ -312,28 +279,6 @@ public class HistoryRecordManager { }).subscribeOn(Schedulers.io()); } - public Single> loadStreamStateBatch(final List infos) { - return Single.fromCallable(() -> { - final List result = new ArrayList<>(infos.size()); - for (final InfoItem info : infos) { - final List entities = streamTable - .getStream(info.getServiceId(), info.getUrl()).blockingFirst(); - if (entities.isEmpty()) { - result.add(null); - continue; - } - final List states = streamStateTable - .getState(entities.get(0).getUid()).blockingFirst(); - if (states.isEmpty()) { - result.add(null); - } else { - result.add(states.get(0)); - } - } - return result; - }).subscribeOn(Schedulers.io()); - } - public Single> loadLocalStreamStateBatch( final List items) { return Single.fromCallable(() -> { diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java index 01df34292..1fea7e155 100644 --- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java @@ -28,14 +28,16 @@ import org.schabi.newpipe.databinding.StatisticPlaylistControlBinding; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; import org.schabi.newpipe.info_list.dialog.InfoItemDialog; +import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.settings.HistorySettingsFragment; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; -import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; +import org.schabi.newpipe.util.PlayButtonHelper; import java.util.ArrayList; import java.util.Collections; @@ -49,7 +51,8 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; public class StatisticsPlaylistFragment - extends BaseLocalListFragment, Void> { + extends BaseLocalListFragment, Void> + implements PlaylistControlViewHolder { private final CompositeDisposable disposables = new CompositeDisposable(); @State Parcelable itemsListState; @@ -135,7 +138,7 @@ public class StatisticsPlaylistFragment protected void initListeners() { super.initListeners(); - itemListAdapter.setSelectedListener(new OnClickGesture() { + itemListAdapter.setSelectedListener(new OnClickGesture<>() { @Override public void selected(final LocalItem selectedItem) { if (selectedItem instanceof StreamStatisticsEntry) { @@ -195,14 +198,9 @@ public class StatisticsPlaylistFragment if (itemListAdapter != null) { itemListAdapter.unsetSelectedListener(); } - if (playlistControlBinding != null) { - playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(null); - playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(null); - playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(null); - headerBinding = null; - playlistControlBinding = null; - } + headerBinding = null; + playlistControlBinding = null; if (databaseSubscription != null) { databaseSubscription.cancel(); @@ -276,12 +274,8 @@ public class StatisticsPlaylistFragment itemsListState = null; } - playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view -> - NavigationHelper.playOnMainPlayer(activity, getPlayQueue())); - playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view -> - NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false)); - playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view -> - NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false)); + PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this); + headerBinding.sortButton.setOnClickListener(view -> toggleSortMode()); hideLoading(); @@ -374,7 +368,7 @@ public class StatisticsPlaylistFragment } } - private PlayQueue getPlayQueue() { + public PlayQueue getPlayQueue() { return getPlayQueue(0); } diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistCardItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistCardItemHolder.java new file mode 100644 index 000000000..33418ec98 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistCardItemHolder.java @@ -0,0 +1,17 @@ +package org.schabi.newpipe.local.holder; + +import android.view.ViewGroup; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.local.LocalItemBuilder; + +/** + * Playlist card layout. + */ +public class LocalPlaylistCardItemHolder extends LocalPlaylistItemHolder { + + public LocalPlaylistCardItemHolder(final LocalItemBuilder infoItemBuilder, + final ViewGroup parent) { + super(infoItemBuilder, R.layout.list_playlist_card_item, parent); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java index 50bbcd566..336f5cfe3 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java @@ -4,16 +4,19 @@ import android.view.View; import android.view.ViewGroup; import org.schabi.newpipe.database.LocalItem; +import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry; import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.Localization; import java.time.format.DateTimeFormatter; public class LocalPlaylistItemHolder extends PlaylistItemHolder { + private static final float GRAYED_OUT_ALPHA = 0.6f; + public LocalPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) { super(infoItemBuilder, parent); } @@ -39,6 +42,13 @@ public class LocalPlaylistItemHolder extends PlaylistItemHolder { PicassoHelper.loadPlaylistThumbnail(item.thumbnailUrl).into(itemThumbnailView); + if (item instanceof PlaylistDuplicatesEntry + && ((PlaylistDuplicatesEntry) item).timesStreamIsContained > 0) { + itemView.setAlpha(GRAYED_OUT_ALPHA); + } else { + itemView.setAlpha(1.0f); + } + super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter); } } diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamCardItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamCardItemHolder.java new file mode 100644 index 000000000..7f81a527f --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamCardItemHolder.java @@ -0,0 +1,17 @@ +package org.schabi.newpipe.local.holder; + +import android.view.ViewGroup; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.local.LocalItemBuilder; + +/** + * Local playlist stream UI. This also includes a handle to rearrange the videos. + */ +public class LocalPlaylistStreamCardItemHolder extends LocalPlaylistStreamItemHolder { + + public LocalPlaylistStreamCardItemHolder(final LocalItemBuilder infoItemBuilder, + final ViewGroup parent) { + super(infoItemBuilder, R.layout.list_stream_playlist_card_item, parent); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java index 561cde560..89a714fd7 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistStreamItemHolder.java @@ -11,12 +11,13 @@ import androidx.core.content.ContextCompat; import org.schabi.newpipe.R; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; -import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.ktx.ViewUtils; import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.image.PicassoHelper; +import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.views.AnimatedProgressBar; import java.time.format.DateTimeFormatter; @@ -59,7 +60,7 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder { itemVideoTitleView.setText(item.getStreamEntity().getTitle()); itemAdditionalDetailsView.setText(Localization .concatenateStrings(item.getStreamEntity().getUploader(), - NewPipe.getNameOfService(item.getStreamEntity().getServiceId()))); + ServiceHelper.getNameOfServiceById(item.getStreamEntity().getServiceId()))); if (item.getStreamEntity().getDuration() > 0) { itemDurationView.setText(Localization @@ -68,7 +69,8 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder { R.color.duration_background_color)); itemDurationView.setVisibility(View.VISIBLE); - if (item.getProgressMillis() > 0) { + if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext()) + && item.getProgressMillis() > 0) { itemProgressView.setVisibility(View.VISIBLE); itemProgressView.setMax((int) item.getStreamEntity().getDuration()); itemProgressView.setProgress((int) TimeUnit.MILLISECONDS @@ -109,7 +111,8 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder { } final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem; - if (item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) { + if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext()) + && item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) { itemProgressView.setMax((int) item.getStreamEntity().getDuration()); if (itemProgressView.getVisibility() == View.VISIBLE) { itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamCardItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamCardItemHolder.java new file mode 100644 index 000000000..4e03d5fb1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamCardItemHolder.java @@ -0,0 +1,13 @@ +package org.schabi.newpipe.local.holder; + +import android.view.ViewGroup; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.local.LocalItemBuilder; + +public class LocalStatisticStreamCardItemHolder extends LocalStatisticStreamItemHolder { + public LocalStatisticStreamCardItemHolder(final LocalItemBuilder infoItemBuilder, + final ViewGroup parent) { + super(infoItemBuilder, R.layout.list_stream_card_item, parent); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java index d2fe8b40f..150a35eb5 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalStatisticStreamItemHolder.java @@ -11,12 +11,13 @@ import androidx.core.content.ContextCompat; import org.schabi.newpipe.R; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.stream.StreamStatisticsEntry; -import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.ktx.ViewUtils; import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.image.PicassoHelper; +import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.views.AnimatedProgressBar; import java.time.format.DateTimeFormatter; @@ -70,11 +71,12 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder { private String getStreamInfoDetailLine(final StreamStatisticsEntry entry, final DateTimeFormatter dateTimeFormatter) { - final String watchCount = Localization - .shortViewCount(itemBuilder.getContext(), entry.getWatchCount()); - final String uploadDate = dateTimeFormatter.format(entry.getLatestAccessDate()); - final String serviceName = NewPipe.getNameOfService(entry.getStreamEntity().getServiceId()); - return Localization.concatenateStrings(watchCount, uploadDate, serviceName); + return Localization.concatenateStrings( + // watchCount + Localization.shortViewCount(itemBuilder.getContext(), entry.getWatchCount()), + dateTimeFormatter.format(entry.getLatestAccessDate()), + // serviceName + ServiceHelper.getNameOfServiceById(entry.getStreamEntity().getServiceId())); } @Override @@ -96,7 +98,8 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder { R.color.duration_background_color)); itemDurationView.setVisibility(View.VISIBLE); - if (item.getProgressMillis() > 0) { + if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext()) + && item.getProgressMillis() > 0) { itemProgressView.setVisibility(View.VISIBLE); itemProgressView.setMax((int) item.getStreamEntity().getDuration()); itemProgressView.setProgress((int) TimeUnit.MILLISECONDS @@ -140,7 +143,8 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder { } final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem; - if (item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) { + if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext()) + && item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) { itemProgressView.setMax((int) item.getStreamEntity().getDuration()); if (itemProgressView.getVisibility() == View.VISIBLE) { itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistCardItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistCardItemHolder.java new file mode 100644 index 000000000..74a67c3db --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistCardItemHolder.java @@ -0,0 +1,17 @@ +package org.schabi.newpipe.local.holder; + +import android.view.ViewGroup; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.local.LocalItemBuilder; + +/** + * Playlist card UI for list item. + */ +public class RemotePlaylistCardItemHolder extends RemotePlaylistItemHolder { + + public RemotePlaylistCardItemHolder(final LocalItemBuilder infoItemBuilder, + final ViewGroup parent) { + super(infoItemBuilder, R.layout.list_playlist_card_item, parent); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java index 865b2c4f7..765732063 100644 --- a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java +++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java @@ -5,11 +5,11 @@ import android.view.ViewGroup; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity; -import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.image.PicassoHelper; +import org.schabi.newpipe.util.ServiceHelper; import java.time.format.DateTimeFormatter; @@ -40,9 +40,9 @@ public class RemotePlaylistItemHolder extends PlaylistItemHolder { // Here is where the uploader name is set in the bookmarked playlists library if (!TextUtils.isEmpty(item.getUploader())) { itemUploaderView.setText(Localization.concatenateStrings(item.getUploader(), - NewPipe.getNameOfService(item.getServiceId()))); + ServiceHelper.getNameOfServiceById(item.getServiceId()))); } else { - itemUploaderView.setText(NewPipe.getNameOfService(item.getServiceId())); + itemUploaderView.setText(ServiceHelper.getNameOfServiceById(item.getServiceId())); } PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView); diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java index 5ec511660..e2d0f5986 100644 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java @@ -1,15 +1,16 @@ package org.schabi.newpipe.local.playlist; +import static org.schabi.newpipe.error.ErrorUtil.showUiErrorSnackbar; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout; import android.content.Context; -import android.content.DialogInterface; import android.os.Bundle; import android.os.Parcelable; import android.text.InputType; import android.text.TextUtils; import android.util.Log; +import android.util.Pair; import android.view.LayoutInflater; import android.view.Menu; import android.view.MenuInflater; @@ -32,18 +33,20 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.history.model.StreamHistoryEntry; import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; +import org.schabi.newpipe.database.playlist.model.PlaylistEntity; import org.schabi.newpipe.database.stream.model.StreamEntity; -import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.databinding.DialogEditTextBinding; import org.schabi.newpipe.databinding.LocalPlaylistHeaderBinding; import org.schabi.newpipe.databinding.PlaylistControlBinding; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.fragments.MainFragment; +import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; import org.schabi.newpipe.info_list.dialog.InfoItemDialog; +import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.local.history.HistoryRecordManager; -import org.schabi.newpipe.player.MainPlayer.PlayerType; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.util.debounce.DebounceSavable; @@ -51,23 +54,25 @@ import org.schabi.newpipe.util.debounce.DebounceSaver; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; -import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; +import org.schabi.newpipe.util.PlayButtonHelper; +import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.ArrayList; import java.util.Collections; -import java.util.Iterator; import java.util.List; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Collectors; import icepick.State; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Flowable; +import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; public class LocalPlaylistFragment extends BaseLocalListFragment, Void> - implements DebounceSavable { + implements PlaylistControlViewHolder, DebounceSavable { + private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12; @State protected Long playlistId; @@ -86,13 +91,19 @@ public class LocalPlaylistFragment extends BaseLocalListFragment() { + itemListAdapter.setSelectedListener(new OnClickGesture<>() { @Override public void selected(final LocalItem selectedItem) { if (selectedItem instanceof PlaylistStreamEntry) { @@ -263,14 +274,10 @@ public class LocalPlaylistFragment extends BaseLocalListFragment> getPlaylistObserver() { - return new Subscriber>() { + return new Subscriber<>() { @Override public void onSubscribe(final Subscription s) { showLoading(); @@ -345,117 +355,148 @@ public class LocalPlaylistFragment extends BaseLocalListFragment removeWatchedStreams(false)) + .setPositiveButton(R.string.ok, (d, id) -> + removeWatchedStreams(false)) .setNeutralButton( R.string.remove_watched_popup_yes_and_partially_watched_videos, - (DialogInterface d, int id) -> removeWatchedStreams(true)) + (d, id) -> removeWatchedStreams(true)) .setNegativeButton(R.string.cancel, - (DialogInterface d, int id) -> d.cancel()) - .create() + (d, id) -> d.cancel()) .show(); } - } else if (item.getItemId() == R.id.menu_item_rename_playlist) { - createRenameDialog(); + } else if (item.getItemId() == R.id.menu_item_remove_duplicates) { + if (!isRewritingPlaylist) { + openRemoveDuplicatesDialog(); + } } else { return super.onOptionsItemSelected(item); } return true; } - public void removeWatchedStreams(final boolean removePartiallyWatched) { - if (isRemovingWatched) { - return; - } - isRemovingWatched = true; - showLoading(); + /** + * Shares the playlist as a list of stream URLs if {@code shouldSharePlaylistDetails} is + * set to {@code false}. Shares the playlist name along with a list of video titles and URLs + * if {@code shouldSharePlaylistDetails} is set to {@code true}. + * + * @param shouldSharePlaylistDetails Whether the playlist details should be included in the + * shared content. + */ + private void sharePlaylist(final boolean shouldSharePlaylistDetails) { + final Context context = requireContext(); disposables.add(playlistManager.getPlaylistStreams(playlistId) - .subscribeOn(Schedulers.io()) - .map((List playlist) -> { - // Playlist data - final Iterator playlistIter = playlist.iterator(); + .flatMapSingle(playlist -> Single.just(playlist.stream() + .map(PlaylistStreamEntry::getStreamEntity) + .map(streamEntity -> { + if (shouldSharePlaylistDetails) { + return context.getString(R.string.video_details_list_item, + streamEntity.getTitle(), streamEntity.getUrl()); + } else { + return streamEntity.getUrl(); + } + }) + .collect(Collectors.joining("\n")))) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(urlsText -> ShareUtils.shareText( + context, name, shouldSharePlaylistDetails + ? context.getString(R.string.share_playlist_content_details, + name, urlsText) : urlsText), + throwable -> showUiErrorSnackbar(this, "Sharing playlist", throwable))); + } - // History data - final HistoryRecordManager recordManager - = new HistoryRecordManager(getContext()); - final Iterator historyIter = recordManager - .getStreamHistorySortedById().blockingFirst().iterator(); + public void removeWatchedStreams(final boolean removePartiallyWatched) { + if (isRewritingPlaylist) { + return; + } + isRewritingPlaylist = true; + showLoading(); + final var recordManager = new HistoryRecordManager(getContext()); + final var historyIdsMaybe = recordManager.getStreamHistorySortedById() + .firstElement() + // already sorted by ^ getStreamHistorySortedById(), binary search can be used + .map(historyList -> historyList.stream().map(StreamHistoryEntry::getStreamId) + .collect(Collectors.toList())); + final var streamsMaybe = playlistManager.getPlaylistStreams(playlistId) + .firstElement() + .zipWith(historyIdsMaybe, (playlist, historyStreamIds) -> { // Remove Watched, Functionality data - final List notWatchedItems = new ArrayList<>(); + final List itemsToKeep = new ArrayList<>(); + final boolean isThumbnailPermanent = playlistManager + .getIsPlaylistThumbnailPermanent(playlistId); boolean thumbnailVideoRemoved = false; - // already sorted by ^ getStreamHistorySortedById(), binary search can be used - final ArrayList historyStreamIds = new ArrayList<>(); - while (historyIter.hasNext()) { - historyStreamIds.add(historyIter.next().getStreamId()); - } - if (removePartiallyWatched) { - while (playlistIter.hasNext()) { - final PlaylistStreamEntry playlistItem = playlistIter.next(); + for (final var playlistItem : playlist) { final int indexInHistory = Collections.binarySearch(historyStreamIds, playlistItem.getStreamId()); if (indexInHistory < 0) { - notWatchedItems.add(playlistItem); - } else if (!thumbnailVideoRemoved - && playlistManager.getPlaylistThumbnail(playlistId) - .equals(playlistItem.getStreamEntity().getThumbnailUrl())) { + itemsToKeep.add(playlistItem); + } else if (!isThumbnailPermanent && !thumbnailVideoRemoved + && playlistManager.getPlaylistThumbnailStreamId(playlistId) + == playlistItem.getStreamEntity().getUid()) { thumbnailVideoRemoved = true; } } } else { - final Iterator streamStatesIter = recordManager - .loadLocalStreamStateBatch(playlist).blockingGet().iterator(); + final var streamStates = recordManager + .loadLocalStreamStateBatch(playlist).blockingGet(); + + for (int i = 0; i < playlist.size(); i++) { + final var playlistItem = playlist.get(i); + final var streamStateEntity = streamStates.get(i); - while (playlistIter.hasNext()) { - final PlaylistStreamEntry playlistItem = playlistIter.next(); final int indexInHistory = Collections.binarySearch(historyStreamIds, playlistItem.getStreamId()); + final long duration = playlistItem.toStreamInfoItem().getDuration(); - final boolean hasState = streamStatesIter.next() != null; - if (indexInHistory < 0 || hasState) { - notWatchedItems.add(playlistItem); - } else if (!thumbnailVideoRemoved - && playlistManager.getPlaylistThumbnail(playlistId) - .equals(playlistItem.getStreamEntity().getThumbnailUrl())) { + if (indexInHistory < 0 || (streamStateEntity != null + && !streamStateEntity.isFinished(duration))) { + itemsToKeep.add(playlistItem); + } else if (!isThumbnailPermanent && !thumbnailVideoRemoved + && playlistManager.getPlaylistThumbnailStreamId(playlistId) + == playlistItem.getStreamEntity().getUid()) { thumbnailVideoRemoved = true; } } } - return Flowable.just(notWatchedItems, thumbnailVideoRemoved); - }) + return new Pair<>(itemsToKeep, thumbnailVideoRemoved); + }); + + disposables.add(streamsMaybe.subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(flow -> { - final List notWatchedItems = - (List) flow.blockingFirst(); - final boolean thumbnailVideoRemoved = (Boolean) flow.blockingLast(); + final List itemsToKeep = flow.first; + final boolean thumbnailVideoRemoved = flow.second; itemListAdapter.clearStreamItemList(); - itemListAdapter.addItems(notWatchedItems); + itemListAdapter.addItems(itemsToKeep); debounceSaver.setHasChangesToSave(); - if (thumbnailVideoRemoved) { updateThumbnailUrl(); } final long videoCount = itemListAdapter.getItemsList().size(); - setVideoCount(videoCount); + setStreamCountAndOverallDuration(itemListAdapter.getItemsList()); if (videoCount == 0) { showEmptyState(); } hideLoading(); - isRemovingWatched = false; + isRewritingPlaylist = false; }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, "Removing watched videos, partially watched=" + removePartiallyWatched)))); } @@ -479,24 +520,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment - NavigationHelper.playOnMainPlayer(activity, getPlayQueue())); - playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view -> - NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false)); - playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view -> - NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false)); - - playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> { - NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.POPUP); - return true; - }); - - playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> { - NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.AUDIO); - return true; - }); + PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this); hideLoading(); } @@ -522,22 +548,21 @@ public class LocalPlaylistFragment extends BaseLocalListFragment - changePlaylistName(dialogBinding.dialogEditText.getText().toString())); - - dialogBuilder.show(); + changePlaylistName(dialogBinding.dialogEditText.getText().toString())) + .show(); } private void changePlaylistName(final String title) { @@ -561,8 +586,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment successToast.show(), throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, @@ -585,16 +611,55 @@ public class LocalPlaylistFragment extends BaseLocalListFragment + removeDuplicatesInPlaylist()) + .setNeutralButton(R.string.cancel, null) + .show(); + } + + private void removeDuplicatesInPlaylist() { + if (isRewritingPlaylist) { + return; + } + isRewritingPlaylist = true; + showLoading(); + + final var streamsMaybe = playlistManager + .getDistinctPlaylistStreams(playlistId).firstElement(); + + + disposables.add(streamsMaybe.subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(itemsToKeep -> { + itemListAdapter.clearStreamItemList(); + itemListAdapter.addItems(itemsToKeep); + setStreamCountAndOverallDuration(itemListAdapter.getItemsList()); + debounceSaver.setHasChangesToSave(); + + hideLoading(); + isRewritingPlaylist = false; + }, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK, + "Removing duplicated streams")))); } private void deleteItem(final PlaylistStreamEntry item) { @@ -603,15 +668,19 @@ public class LocalPlaylistFragment extends BaseLocalListFragmentCommit changes immediately if the playlist has been modified.

+ * Delete operations and other modifications will be committed to ensure that the database + * is up to date, e.g. when the user adds the just deleted stream from another fragment. + */ @Override public void saveImmediate() { if (playlistManager == null || itemListAdapter == null) { @@ -739,7 +808,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment - changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl())) + changeThumbnailStreamId(item.getStreamEntity().getUid(), + true)) .setAction( StreamDialogDefaultEntry.DELETE, (f, i) -> deleteItem(item)) @@ -755,14 +825,24 @@ public class LocalPlaylistFragment extends BaseLocalListFragment itemsList) { if (activity != null && headerBinding != null) { - headerBinding.playlistStreamCount.setText(Localization - .localizeStreamCount(activity, count)); + final long streamCount = itemsList.size(); + final long playlistOverallDurationSeconds = itemsList.stream() + .filter(PlaylistStreamEntry.class::isInstance) + .map(PlaylistStreamEntry.class::cast) + .map(PlaylistStreamEntry::getStreamEntity) + .mapToLong(StreamEntity::getDuration) + .sum(); + headerBinding.playlistStreamCount.setText( + Localization.concatenateStrings( + Localization.localizeStreamCount(activity, streamCount), + Localization.getDurationString(playlistOverallDurationSeconds)) + ); } } - private PlayQueue getPlayQueue() { + public PlayQueue getPlayQueue() { return getPlayQueue(0); } @@ -780,5 +860,29 @@ public class LocalPlaylistFragment extends BaseLocalListFragment + sharePlaylist(/* shouldSharePlaylistDetails= */ true) + ) + .setNegativeButton(R.string.share_playlist_with_list, (dialog, which) -> + sharePlaylist(/* shouldSharePlaylistDetails= */ false) + ) + .show(); + } + + public void setTabsPagerAdapter( + @Nullable final MainFragment.SelectedTabsPagerAdapter tabsPagerAdapter) { + this.tabsPagerAdapter = tabsPagerAdapter; + } } diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java index c68a22b01..e153f0a10 100644 --- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java +++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java @@ -3,6 +3,7 @@ package org.schabi.newpipe.local.playlist; import androidx.annotation.Nullable; import org.schabi.newpipe.database.AppDatabase; +import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry; import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; import org.schabi.newpipe.database.playlist.dao.PlaylistDAO; @@ -22,6 +23,8 @@ import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.schedulers.Schedulers; public class LocalPlaylistManager { + private static final long THUMBNAIL_ID_LEAVE_UNCHANGED = -2; + private final AppDatabase database; private final StreamDAO streamTable; private final PlaylistDAO playlistTable; @@ -39,34 +42,37 @@ public class LocalPlaylistManager { if (streams.isEmpty()) { return Maybe.empty(); } - final StreamEntity defaultStream = streams.get(0); // Save to the database directly. // Make sure the new playlist is always on the top of bookmark. // The index will be reassigned to non-negative number in BookmarkFragment. - final PlaylistEntity newPlaylist = - new PlaylistEntity(name, defaultStream.getThumbnailUrl(), -1); + return Maybe.fromCallable(() -> database.runInTransaction(() -> { + final List streamIds = streamTable.upsertAll(streams); + final PlaylistEntity newPlaylist = new PlaylistEntity(name, false, + streamIds.get(0), -1); - return Maybe.fromCallable(() -> database.runInTransaction(() -> - upsertStreams(playlistTable.insert(newPlaylist), streams, 0)) - ).subscribeOn(Schedulers.io()); + return insertJoinEntities(playlistTable.insert(newPlaylist), + streamIds, 0); + } + )).subscribeOn(Schedulers.io()); } public Maybe> appendToPlaylist(final long playlistId, final List streams) { return playlistStreamTable.getMaximumIndexOf(playlistId) .firstElement() - .map(maxJoinIndex -> database.runInTransaction(() -> - upsertStreams(playlistId, streams, maxJoinIndex + 1)) - ).subscribeOn(Schedulers.io()); + .map(maxJoinIndex -> database.runInTransaction(() -> { + final List streamIds = streamTable.upsertAll(streams); + return insertJoinEntities(playlistId, streamIds, maxJoinIndex + 1); + } + )).subscribeOn(Schedulers.io()); } - private List upsertStreams(final long playlistId, - final List streams, - final int indexOffset) { + private List insertJoinEntities(final long playlistId, final List streamIds, + final int indexOffset) { + + final List joinEntities = new ArrayList<>(streamIds.size()); - final List joinEntities = new ArrayList<>(streams.size()); - final List streamIds = streamTable.upsertAll(streams); for (int index = 0; index < streamIds.size(); index++) { joinEntities.add(new PlaylistStreamEntity(playlistId, streamIds.get(index), index + indexOffset)); @@ -93,10 +99,10 @@ public class LocalPlaylistManager { items.add(new PlaylistEntity(item)); } return Completable.fromRunnable(() -> database.runInTransaction(() -> { - for (final Long uid: deletedItems) { + for (final Long uid : deletedItems) { playlistTable.deletePlaylist(uid); } - for (final PlaylistEntity item: items) { + for (final PlaylistEntity item : items) { playlistTable.upsertPlaylist(item); } })).subscribeOn(Schedulers.io()); @@ -106,6 +112,23 @@ public class LocalPlaylistManager { return playlistStreamTable.getPlaylistMetadata().subscribeOn(Schedulers.io()); } + public Flowable> getDistinctPlaylistStreams(final long playlistId) { + return playlistStreamTable + .getStreamsWithoutDuplicates(playlistId).subscribeOn(Schedulers.io()); + } + + /** + * Get playlists with attached information about how many times the provided stream is already + * contained in each playlist. + * + * @param streamUrl the stream url for which to check for duplicates + * @return a list of {@link PlaylistDuplicatesEntry} + */ + public Flowable> getPlaylistDuplicates(final String streamUrl) { + return playlistStreamTable.getPlaylistDuplicatesMetadata(streamUrl) + .subscribeOn(Schedulers.io()); + } + public Flowable> getDisplayIndexOrderedPlaylists() { return playlistStreamTable.getDisplayIndexOrderedPlaylistMetadata() .subscribeOn(Schedulers.io()); @@ -121,26 +144,42 @@ public class LocalPlaylistManager { } public Maybe renamePlaylist(final long playlistId, final String name) { - return modifyPlaylist(playlistId, name, null, -1); + return modifyPlaylist(playlistId, name, THUMBNAIL_ID_LEAVE_UNCHANGED, false, -1); } public Maybe changePlaylistThumbnail(final long playlistId, - final String thumbnailUrl) { - return modifyPlaylist(playlistId, null, thumbnailUrl, -1); + final long thumbnailStreamId, + final boolean isPermanent) { + return modifyPlaylist(playlistId, null, thumbnailStreamId, isPermanent, -1); } public Maybe updatePlaylistDisplayIndex(final long playlistId, final long displayIndex) { - return modifyPlaylist(playlistId, null, null, displayIndex); + return modifyPlaylist(playlistId, null, THUMBNAIL_ID_LEAVE_UNCHANGED, false, displayIndex); } - public String getPlaylistThumbnail(final long playlistId) { - return playlistTable.getPlaylist(playlistId).blockingFirst().get(0).getThumbnailUrl(); + public long getPlaylistThumbnailStreamId(final long playlistId) { + return playlistTable.getPlaylist(playlistId).blockingFirst().get(0).getThumbnailStreamId(); + } + + public boolean getIsPlaylistThumbnailPermanent(final long playlistId) { + return playlistTable.getPlaylist(playlistId).blockingFirst().get(0) + .getIsThumbnailPermanent(); + } + + public long getAutomaticPlaylistThumbnailStreamId(final long playlistId) { + final long streamId = playlistStreamTable.getAutomaticThumbnailStreamId(playlistId) + .blockingFirst(); + if (streamId < 0) { + return PlaylistEntity.DEFAULT_THUMBNAIL_ID; + } + return streamId; } private Maybe modifyPlaylist(final long playlistId, @Nullable final String name, - @Nullable final String thumbnailUrl, + final long thumbnailStreamId, + final boolean isPermanent, final long displayIndex) { return playlistTable.getPlaylist(playlistId) .firstElement() @@ -150,8 +189,9 @@ public class LocalPlaylistManager { if (name != null) { playlist.setName(name); } - if (thumbnailUrl != null) { - playlist.setThumbnailUrl(thumbnailUrl); + if (thumbnailStreamId != THUMBNAIL_ID_LEAVE_UNCHANGED) { + playlist.setThumbnailStreamId(thumbnailStreamId); + playlist.setIsThumbnailPermanent(isPermanent); } if (displayIndex != -1) { playlist.setDisplayIndex(displayIndex); diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt index ac7197b48..1fa70e4d8 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/FeedGroupIcon.kt @@ -51,7 +51,8 @@ enum class FeedGroupIcon( WORLD(34, R.drawable.ic_public), STAR(35, R.drawable.ic_stars), SUN(36, R.drawable.ic_wb_sunny), - RSS(37, R.drawable.ic_rss_feed); + RSS(37, R.drawable.ic_rss_feed), + WHATS_NEW(38, R.drawable.ic_subscriptions); @DrawableRes fun getDrawableRes(): Int { diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt index 008228083..fe2321059 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionFragment.kt @@ -1,39 +1,39 @@ package org.schabi.newpipe.local.subscription import android.app.Activity -import android.content.BroadcastReceiver import android.content.Context import android.content.DialogInterface import android.content.Intent -import android.content.IntentFilter import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater +import android.view.MenuItem +import android.view.SubMenu import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.ActivityResult import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult +import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.lifecycle.ViewModelProvider -import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.recyclerview.widget.GridLayoutManager import com.xwray.groupie.Group import com.xwray.groupie.GroupAdapter -import com.xwray.groupie.Item import com.xwray.groupie.Section import com.xwray.groupie.viewbinding.GroupieViewHolder import icepick.State import io.reactivex.rxjava3.disposables.CompositeDisposable import org.schabi.newpipe.R -import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.GROUP_ALL_ID import org.schabi.newpipe.databinding.DialogTitleBinding import org.schabi.newpipe.databinding.FeedItemCarouselBinding import org.schabi.newpipe.databinding.FragmentSubscriptionBinding import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.UserAction +import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.channel.ChannelInfoItem import org.schabi.newpipe.fragments.BaseStateFragment import org.schabi.newpipe.ktx.animate @@ -41,17 +41,16 @@ import org.schabi.newpipe.local.subscription.SubscriptionViewModel.SubscriptionS import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialog import org.schabi.newpipe.local.subscription.item.ChannelItem -import org.schabi.newpipe.local.subscription.item.EmptyPlaceholderItem -import org.schabi.newpipe.local.subscription.item.FeedGroupAddItem +import org.schabi.newpipe.local.subscription.item.FeedGroupAddNewGridItem +import org.schabi.newpipe.local.subscription.item.FeedGroupAddNewItem +import org.schabi.newpipe.local.subscription.item.FeedGroupCardGridItem import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem -import org.schabi.newpipe.local.subscription.item.FeedImportExportItem -import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem -import org.schabi.newpipe.local.subscription.item.HeaderWithMenuItem.Companion.PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM +import org.schabi.newpipe.local.subscription.item.GroupsHeader +import org.schabi.newpipe.local.subscription.item.Header +import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService -import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.EXPORT_COMPLETE_ACTION import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService -import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.IMPORT_COMPLETE_ACTION 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 @@ -59,8 +58,8 @@ 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 +import org.schabi.newpipe.util.ServiceHelper import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountChannels -import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout import org.schabi.newpipe.util.external_communication.ShareUtils import java.text.SimpleDateFormat import java.util.Date @@ -74,13 +73,10 @@ class SubscriptionFragment : BaseStateFragment() { private lateinit var subscriptionManager: SubscriptionManager private val disposables: CompositeDisposable = CompositeDisposable() - private var subscriptionBroadcastReceiver: BroadcastReceiver? = null - private val groupAdapter = GroupAdapter>() - private val feedGroupsSection = Section() - private var feedGroupsCarousel: FeedGroupCarouselItem? = null - private lateinit var importExportItem: FeedImportExportItem - private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem + private lateinit var carouselAdapter: GroupAdapter> + private lateinit var feedGroupsCarousel: FeedGroupCarouselItem + private lateinit var feedGroupsSortMenuItem: GroupsHeader private val subscriptionsSection = Section() private val requestExportLauncher = @@ -91,12 +87,10 @@ class SubscriptionFragment : BaseStateFragment() { @State @JvmField var itemsListState: Parcelable? = null + @State @JvmField - var feedGroupsListState: Parcelable? = null - @State - @JvmField - var importExportItemExpandedState: Boolean? = null + var feedGroupsCarouselState: Parcelable? = null init { setHasOptionsMenu(true) @@ -106,11 +100,6 @@ class SubscriptionFragment : BaseStateFragment() { // Fragment LifeCycle // ///////////////////////////////////////////////////////////////////////// - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setupInitialLayout() - } - override fun onAttach(context: Context) { super.onAttach(context) subscriptionManager = SubscriptionManager(requireContext()) @@ -120,20 +109,15 @@ class SubscriptionFragment : BaseStateFragment() { return inflater.inflate(R.layout.fragment_subscription, container, false) } - override fun onResume() { - super.onResume() - setupBroadcastReceiver() - } - override fun onPause() { super.onPause() itemsListState = binding.itemsList.layoutManager?.onSaveInstanceState() - feedGroupsListState = feedGroupsCarousel?.onSaveInstanceState() - importExportItemExpandedState = importExportItem.isExpanded + feedGroupsCarouselState = feedGroupsCarousel.onSaveInstanceState() + } - if (subscriptionBroadcastReceiver != null && activity != null) { - LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!) - } + override fun onDestroyView() { + super.onDestroyView() + _binding = null } override fun onDestroy() { @@ -150,28 +134,61 @@ class SubscriptionFragment : BaseStateFragment() { activity.supportActionBar?.setDisplayShowTitleEnabled(true) activity.supportActionBar?.setTitle(R.string.tab_subscriptions) + + buildImportExportMenu(menu) } - private fun setupBroadcastReceiver() { - if (activity == null) return + private fun buildImportExportMenu(menu: Menu) { + // -- Import -- + val importSubMenu = menu.addSubMenu(R.string.import_from) - if (subscriptionBroadcastReceiver != null) { - LocalBroadcastManager.getInstance(activity).unregisterReceiver(subscriptionBroadcastReceiver!!) - } + addMenuItemToSubmenu(importSubMenu, R.string.previous_export) { onImportPreviousSelected() } + .setIcon(R.drawable.ic_backup) - val filters = IntentFilter() - filters.addAction(EXPORT_COMPLETE_ACTION) - filters.addAction(IMPORT_COMPLETE_ACTION) - subscriptionBroadcastReceiver = object : BroadcastReceiver() { - override fun onReceive(context: Context, intent: Intent) { - _binding?.itemsList?.post { - importExportItem.isExpanded = false - importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS) - } + for (service in ServiceList.all()) { + val subscriptionExtractor = service.subscriptionExtractor ?: continue + + val supportedSources = subscriptionExtractor.supportedSources + if (supportedSources.isEmpty()) continue + + addMenuItemToSubmenu(importSubMenu, service.serviceInfo.name) { + onImportFromServiceSelected(service.serviceId) } + .setIcon(ServiceHelper.getIcon(service.serviceId)) } - LocalBroadcastManager.getInstance(activity).registerReceiver(subscriptionBroadcastReceiver!!, filters) + // -- Export -- + val exportSubMenu = menu.addSubMenu(R.string.export_to) + + addMenuItemToSubmenu(exportSubMenu, R.string.file) { onExportSelected() } + .setIcon(R.drawable.ic_save) + } + + private fun addMenuItemToSubmenu( + subMenu: SubMenu, + @StringRes title: Int, + onClick: Runnable + ): MenuItem { + return setClickListenerToMenuItem(subMenu.add(title), onClick) + } + + private fun addMenuItemToSubmenu( + subMenu: SubMenu, + title: String, + onClick: Runnable + ): MenuItem { + return setClickListenerToMenuItem(subMenu.add(title), onClick) + } + + private fun setClickListenerToMenuItem( + menuItem: MenuItem, + onClick: Runnable + ): MenuItem { + menuItem.setOnMenuItemClickListener { + onClick.run() + true + } + return menuItem } private fun onImportFromServiceSelected(serviceId: Int) { @@ -228,63 +245,90 @@ class SubscriptionFragment : BaseStateFragment() { // Fragment Views // //////////////////////////////////////////////////////////////////////// - private fun setupInitialLayout() { - Section().apply { - val carouselAdapter = GroupAdapter>() - - carouselAdapter.add(FeedGroupCardItem(-1, getString(R.string.all), FeedGroupIcon.RSS)) - carouselAdapter.add(feedGroupsSection) - carouselAdapter.add(FeedGroupAddItem()) - - carouselAdapter.setOnItemClickListener { item, _ -> - listenerFeedGroups.selected(item) - } - carouselAdapter.setOnItemLongClickListener { item, _ -> - if (item is FeedGroupCardItem) { - if (item.groupId == FeedGroupEntity.GROUP_ALL_ID) { - return@setOnItemLongClickListener false - } - } - listenerFeedGroups.held(item) - return@setOnItemLongClickListener true - } - - feedGroupsCarousel = FeedGroupCarouselItem(requireContext(), carouselAdapter) - feedGroupsSortMenuItem = HeaderWithMenuItem( - getString(R.string.feed_groups_header_title), - R.drawable.ic_sort, - menuItemOnClickListener = ::openReorderDialog - ) - add(Section(feedGroupsSortMenuItem, listOf(feedGroupsCarousel))) - - groupAdapter.add(this) - } - - subscriptionsSection.setPlaceholder(EmptyPlaceholderItem()) - subscriptionsSection.setHideWhenEmpty(true) - - importExportItem = FeedImportExportItem( - { onImportPreviousSelected() }, - { onImportFromServiceSelected(it) }, - { onExportSelected() }, - importExportItemExpandedState ?: false - ) - groupAdapter.add(Section(importExportItem, listOf(subscriptionsSection))) - } - override fun initViews(rootView: View, savedInstanceState: Bundle?) { super.initViews(rootView, savedInstanceState) _binding = FragmentSubscriptionBinding.bind(rootView) - groupAdapter.spanCount = if (shouldUseGridLayout(context)) getGridSpanCountChannels(context) else 1 + groupAdapter.spanCount = if (SubscriptionViewModel.shouldUseGridForSubscription(requireContext())) getGridSpanCountChannels(context) else 1 binding.itemsList.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply { spanSizeLookup = groupAdapter.spanSizeLookup } binding.itemsList.adapter = groupAdapter + binding.itemsList.itemAnimator = null - viewModel = ViewModelProvider(this).get(SubscriptionViewModel::class.java) + viewModel = ViewModelProvider(this)[SubscriptionViewModel::class.java] viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(this::handleResult) } - viewModel.feedGroupsLiveData.observe(viewLifecycleOwner) { it?.let(this::handleFeedGroups) } + viewModel.feedGroupsLiveData.observe(viewLifecycleOwner) { + it?.let { (groups, listViewMode) -> + handleFeedGroups(groups, listViewMode) + } + } + + setupInitialLayout() + } + + private fun setupInitialLayout() { + Section().apply { + carouselAdapter = GroupAdapter>() + + carouselAdapter.setOnItemClickListener { item, _ -> + when (item) { + is FeedGroupCardItem -> + NavigationHelper.openFeedFragment(fm, item.groupId, item.name) + is FeedGroupCardGridItem -> + NavigationHelper.openFeedFragment(fm, item.groupId, item.name) + is FeedGroupAddNewItem -> + FeedGroupDialog.newInstance().show(fm, null) + is FeedGroupAddNewGridItem -> + FeedGroupDialog.newInstance().show(fm, null) + } + } + carouselAdapter.setOnItemLongClickListener { item, _ -> + if ((item is FeedGroupCardItem && item.groupId == GROUP_ALL_ID) || + (item is FeedGroupCardGridItem && item.groupId == GROUP_ALL_ID) + ) { + return@setOnItemLongClickListener false + } + + when (item) { + is FeedGroupCardItem -> + FeedGroupDialog.newInstance(item.groupId).show(fm, null) + is FeedGroupCardGridItem -> + FeedGroupDialog.newInstance(item.groupId).show(fm, null) + } + return@setOnItemLongClickListener true + } + + feedGroupsCarousel = FeedGroupCarouselItem( + carouselAdapter = carouselAdapter, + listViewMode = viewModel.getListViewMode() + ) + + feedGroupsSortMenuItem = GroupsHeader( + title = getString(R.string.feed_groups_header_title), + onSortClicked = ::openReorderDialog, + onToggleListViewModeClicked = ::toggleListViewMode, + listViewMode = viewModel.getListViewMode(), + ) + + add(Section(feedGroupsSortMenuItem, listOf(feedGroupsCarousel))) + groupAdapter.clear() + groupAdapter.add(this) + } + + subscriptionsSection.setPlaceholder(ImportSubscriptionsHintPlaceholderItem()) + subscriptionsSection.setHideWhenEmpty(true) + + groupAdapter.add( + Section( + Header(getString(R.string.tab_subscriptions)), + listOf(subscriptionsSection) + ) + ) + } + + private fun toggleListViewMode() { + viewModel.setListViewMode(!viewModel.getListViewMode()) } private fun showLongTapDialog(selectedItem: ChannelInfoItem) { @@ -297,8 +341,7 @@ class SubscriptionFragment : BaseStateFragment() { val actions = DialogInterface.OnClickListener { _, i -> when (i) { 0 -> ShareUtils.shareText( - requireContext(), selectedItem.name, selectedItem.url, - selectedItem.thumbnailUrl + requireContext(), selectedItem.name, selectedItem.url, selectedItem.thumbnails ) 1 -> ShareUtils.openUrlInBrowser(requireContext(), selectedItem.url) 2 -> deleteChannel(selectedItem) @@ -313,7 +356,6 @@ class SubscriptionFragment : BaseStateFragment() { AlertDialog.Builder(requireContext()) .setCustomTitle(dialogTitleBinding.root) .setItems(commands, actions) - .create() .show() } @@ -328,22 +370,7 @@ class SubscriptionFragment : BaseStateFragment() { override fun doInitialLoadLogic() = Unit override fun startLoading(forceLoad: Boolean) = Unit - private val listenerFeedGroups = object : OnClickGesture>() { - override fun selected(selectedItem: Item<*>?) { - when (selectedItem) { - is FeedGroupCardItem -> NavigationHelper.openFeedFragment(fm, selectedItem.groupId, selectedItem.name) - is FeedGroupAddItem -> FeedGroupDialog.newInstance().show(fm, null) - } - } - - override fun held(selectedItem: Item<*>?) { - when (selectedItem) { - is FeedGroupCardItem -> FeedGroupDialog.newInstance(selectedItem.groupId).show(fm, null) - } - } - } - - private val listenerChannelItem = object : OnClickGesture() { + private val listenerChannelItem = object : OnClickGesture { override fun selected(selectedItem: ChannelInfoItem) = NavigationHelper.openChannelFragment( fm, selectedItem.serviceId, selectedItem.url, selectedItem.name @@ -355,15 +382,15 @@ class SubscriptionFragment : BaseStateFragment() { override fun handleResult(result: SubscriptionState) { super.handleResult(result) - val shouldUseGridLayout = shouldUseGridLayout(context) when (result) { is SubscriptionState.LoadedState -> { result.subscriptions.forEach { if (it is ChannelItem) { it.gesturesListener = listenerChannelItem - it.itemVersion = when { - shouldUseGridLayout -> ChannelItem.ItemVersion.GRID - else -> ChannelItem.ItemVersion.MINI + it.itemVersion = if (SubscriptionViewModel.shouldUseGridForSubscription(requireContext())) { + ChannelItem.ItemVersion.GRID + } else { + ChannelItem.ItemVersion.MINI } } } @@ -371,13 +398,6 @@ class SubscriptionFragment : BaseStateFragment() { subscriptionsSection.update(result.subscriptions) subscriptionsSection.setHideWhenEmpty(false) - if (result.subscriptions.isEmpty() && importExportItemExpandedState == null) { - binding.itemsList.post { - importExportItem.isExpanded = true - importExportItem.notifyChanged(FeedImportExportItem.REFRESH_EXPANDED_STATUS) - } - } - if (itemsListState != null) { binding.itemsList.layoutManager?.onRestoreInstanceState(itemsListState) itemsListState = null @@ -391,16 +411,38 @@ class SubscriptionFragment : BaseStateFragment() { } } - private fun handleFeedGroups(groups: List) { - feedGroupsSection.update(groups) - - if (feedGroupsListState != null) { - feedGroupsCarousel?.onRestoreInstanceState(feedGroupsListState) - feedGroupsListState = null + private fun handleFeedGroups(groups: List, listViewMode: Boolean) { + if (feedGroupsCarouselState != null) { + feedGroupsCarousel.onRestoreInstanceState(feedGroupsCarouselState) + feedGroupsCarouselState = null } - feedGroupsSortMenuItem.showMenuItem = groups.size > 1 - binding.itemsList.post { feedGroupsSortMenuItem.notifyChanged(PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM) } + binding.itemsList.post { + if (context == null) { + // since this part was posted to the next UI cycle, the fragment might have been + // removed in the meantime + return@post + } + + feedGroupsCarousel.listViewMode = listViewMode + feedGroupsSortMenuItem.showSortButton = groups.size > 1 + feedGroupsSortMenuItem.listViewMode = listViewMode + feedGroupsCarousel.notifyChanged(FeedGroupCarouselItem.PAYLOAD_UPDATE_LIST_VIEW_MODE) + feedGroupsSortMenuItem.notifyChanged(GroupsHeader.PAYLOAD_UPDATE_ICONS) + + // update items here to prevent flickering + carouselAdapter.apply { + clear() + if (listViewMode) { + add(FeedGroupAddNewItem()) + add(FeedGroupCardItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.WHATS_NEW)) + } else { + add(FeedGroupAddNewGridItem()) + add(FeedGroupCardGridItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.WHATS_NEW)) + } + addAll(groups) + } + } } // ///////////////////////////////////////////////////////////////////////// diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt index b17f49801..488d8b3d2 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt @@ -1,6 +1,7 @@ package org.schabi.newpipe.local.subscription import android.content.Context +import android.util.Pair import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable @@ -11,12 +12,13 @@ import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.database.subscription.SubscriptionDAO import org.schabi.newpipe.database.subscription.SubscriptionEntity -import org.schabi.newpipe.extractor.ListInfo import org.schabi.newpipe.extractor.channel.ChannelInfo -import org.schabi.newpipe.extractor.feed.FeedInfo +import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.local.feed.FeedDatabaseManager +import org.schabi.newpipe.local.feed.service.FeedUpdateInfo import org.schabi.newpipe.util.ExtractorHelper +import org.schabi.newpipe.util.image.ImageStrategy class SubscriptionManager(context: Context) { private val database = NewPipeDatabase.getInstance(context) @@ -46,28 +48,38 @@ class SubscriptionManager(context: Context) { } } - fun upsertAll(infoList: List): List { + fun upsertAll(infoList: List>>): List { val listEntities = subscriptionTable.upsertAll( - infoList.map { SubscriptionEntity.from(it) } + infoList.map { SubscriptionEntity.from(it.first) } ) database.runInTransaction { infoList.forEachIndexed { index, info -> - feedDatabaseManager.upsertAll(listEntities[index].uid, info.relatedItems) + info.second.forEach { + feedDatabaseManager.upsertAll( + listEntities[index].uid, + it.relatedItems.filterIsInstance() + ) + } } } return listEntities } - fun updateChannelInfo(info: ChannelInfo): Completable = subscriptionTable.getSubscription(info.serviceId, info.url) - .flatMapCompletable { - Completable.fromRunnable { - it.setData(info.name, info.avatarUrl, info.description, info.subscriberCount) - subscriptionTable.update(it) - feedDatabaseManager.upsertAll(it.uid, info.relatedItems) + fun updateChannelInfo(info: ChannelInfo): Completable = + subscriptionTable.getSubscription(info.serviceId, info.url) + .flatMapCompletable { + Completable.fromRunnable { + it.setData( + info.name, + ImageStrategy.imageListToDbUrl(info.avatars), + info.description, + info.subscriberCount + ) + subscriptionTable.update(it) + } } - } fun updateNotificationMode(serviceId: Int, url: String, @NotificationMode mode: Int): Completable { return subscriptionTable().getSubscription(serviceId, url) @@ -84,19 +96,15 @@ class SubscriptionManager(context: Context) { } } - fun updateFromInfo(subscriptionId: Long, info: ListInfo) { - val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId) + fun updateFromInfo(info: FeedUpdateInfo) { + val subscriptionEntity = subscriptionTable.getSubscription(info.uid) - if (info is FeedInfo) { - subscriptionEntity.name = info.name - } else if (info is ChannelInfo) { - subscriptionEntity.setData( - info.name, - info.avatarUrl, - info.description, - info.subscriberCount - ) - } + subscriptionEntity.name = info.name + subscriptionEntity.avatarUrl = info.avatarUrl + + // these two fields are null if the feed info was fetched using the fast feed method + info.description?.let { subscriptionEntity.description = it } + info.subscriberCount?.let { subscriptionEntity.subscriberCount = it } subscriptionTable.update(subscriptionEntity) } @@ -107,11 +115,8 @@ class SubscriptionManager(context: Context) { .observeOn(AndroidSchedulers.mainThread()) } - fun insertSubscription(subscriptionEntity: SubscriptionEntity, info: ChannelInfo) { - database.runInTransaction { - val subscriptionId = subscriptionTable.insert(subscriptionEntity) - feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems) - } + fun insertSubscription(subscriptionEntity: SubscriptionEntity) { + subscriptionTable.insert(subscriptionEntity) } fun deleteSubscription(subscriptionEntity: SubscriptionEntity) { @@ -125,7 +130,10 @@ class SubscriptionManager(context: Context) { */ private fun rememberAllStreams(subscription: SubscriptionEntity): Completable { return ExtractorHelper.getChannelInfo(subscription.serviceId, subscription.url, false) - .map { channel -> channel.relatedItems.map { stream -> StreamEntity(stream) } } + .flatMap { info -> + ExtractorHelper.getChannelTab(subscription.serviceId, info.tabs.first(), false) + } + .map { channel -> channel.relatedItems.filterIsInstance().map { stream -> StreamEntity(stream) } } .flatMapCompletable { entities -> Completable.fromAction { database.streamDAO().upsertAll(entities) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt index da009e1a0..dfad60c3f 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionViewModel.kt @@ -1,29 +1,51 @@ package org.schabi.newpipe.local.subscription import android.app.Application +import android.content.Context import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import com.xwray.groupie.Group +import io.reactivex.rxjava3.core.Flowable +import io.reactivex.rxjava3.processors.BehaviorProcessor import io.reactivex.rxjava3.schedulers.Schedulers +import org.schabi.newpipe.info_list.ItemViewMode import org.schabi.newpipe.local.feed.FeedDatabaseManager import org.schabi.newpipe.local.subscription.item.ChannelItem +import org.schabi.newpipe.local.subscription.item.FeedGroupCardGridItem import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT +import org.schabi.newpipe.util.ThemeHelper.getItemViewMode import java.util.concurrent.TimeUnit class SubscriptionViewModel(application: Application) : AndroidViewModel(application) { private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(application) private var subscriptionManager = SubscriptionManager(application) - private val mutableStateLiveData = MutableLiveData() - private val mutableFeedGroupsLiveData = MutableLiveData>() - val stateLiveData: LiveData = mutableStateLiveData - val feedGroupsLiveData: LiveData> = mutableFeedGroupsLiveData + // true -> list view, false -> grid view + private val listViewMode = BehaviorProcessor.createDefault( + !shouldUseGridForSubscription(application) + ) + private val listViewModeFlowable = listViewMode.distinctUntilChanged() - private var feedGroupItemsDisposable = feedDatabaseManager.groups() + private val mutableStateLiveData = MutableLiveData() + private val mutableFeedGroupsLiveData = MutableLiveData, Boolean>>() + val stateLiveData: LiveData = mutableStateLiveData + val feedGroupsLiveData: LiveData, Boolean>> = mutableFeedGroupsLiveData + + private var feedGroupItemsDisposable = Flowable + .combineLatest( + feedDatabaseManager.groups(), + listViewModeFlowable, + ::Pair + ) .throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS) - .map { it.map(::FeedGroupCardItem) } + .map { (feedGroups, listViewMode) -> + Pair( + feedGroups.map(if (listViewMode) ::FeedGroupCardItem else ::FeedGroupCardGridItem), + listViewMode + ) + } .subscribeOn(Schedulers.io()) .subscribe( { mutableFeedGroupsLiveData.postValue(it) }, @@ -45,8 +67,38 @@ class SubscriptionViewModel(application: Application) : AndroidViewModel(applica feedGroupItemsDisposable.dispose() } + fun setListViewMode(newListViewMode: Boolean) { + listViewMode.onNext(newListViewMode) + } + + fun getListViewMode(): Boolean { + return listViewMode.value ?: true + } + sealed class SubscriptionState { data class LoadedState(val subscriptions: List) : SubscriptionState() data class ErrorState(val error: Throwable? = null) : SubscriptionState() } + + companion object { + + /** + * Returns whether to use GridLayout mode for Subscription Fragment. + * + * ### Current mapping: + * + * | ItemViewMode | ItemVersion | Span count | + * |---|---|---| + * | AUTO | MINI | 1 | + * | LIST | MINI | 1 | + * | CARD | GRID | > 1 (ThemeHelper defined) | + * | GRID | GRID | > 1 (ThemeHelper defined) | + * + * @see [SubscriptionViewModel.shouldUseGridForSubscription] to modify Layout Manager + */ + fun shouldUseGridForSubscription(context: Context): Boolean { + val itemViewMode = getItemViewMode(context) + return itemViewMode == ItemViewMode.GRID || itemViewMode == ItemViewMode.CARD + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java index 4737fa14f..56972b60d 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java @@ -1,5 +1,11 @@ package org.schabi.newpipe.local.subscription; +import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL; +import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE; +import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.INPUT_STREAM_MODE; +import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE; +import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE; + import android.app.Activity; import android.content.Intent; import android.os.Bundle; @@ -40,12 +46,6 @@ import java.util.List; import icepick.State; -import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.INPUT_STREAM_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE; -import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE; - public class SubscriptionsImportFragment extends BaseFragment { @State int currentServiceId = Constants.NO_SERVICE_ID; @@ -89,7 +89,7 @@ public class SubscriptionsImportFragment extends BaseFragment { if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) { ErrorUtil.showSnackbar(activity, new ErrorInfo(new String[]{}, UserAction.SUBSCRIPTION_IMPORT_EXPORT, - NewPipe.getNameOfService(currentServiceId), + ServiceHelper.getNameOfServiceById(currentServiceId), "Service does not support importing subscriptions", R.string.general_error)); activity.finish(); diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/decoration/FeedGroupCarouselDecoration.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/decoration/FeedGroupCarouselDecoration.kt deleted file mode 100644 index 7b7490eaa..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/decoration/FeedGroupCarouselDecoration.kt +++ /dev/null @@ -1,35 +0,0 @@ -package org.schabi.newpipe.local.subscription.decoration - -import android.content.Context -import android.graphics.Rect -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import org.schabi.newpipe.R - -class FeedGroupCarouselDecoration(context: Context) : RecyclerView.ItemDecoration() { - - private val marginStartEnd: Int - private val marginTopBottom: Int - private val marginBetweenItems: Int - - init { - with(context.resources) { - marginStartEnd = getDimensionPixelOffset(R.dimen.feed_group_carousel_start_end_margin) - marginTopBottom = getDimensionPixelOffset(R.dimen.feed_group_carousel_top_bottom_margin) - marginBetweenItems = getDimensionPixelOffset(R.dimen.feed_group_carousel_between_items_margin) - } - } - - override fun getItemOffsets(outRect: Rect, child: View, parent: RecyclerView, state: RecyclerView.State) { - val childAdapterPosition = parent.getChildAdapterPosition(child) - val childAdapterCount = parent.adapter?.itemCount ?: 0 - - outRect.set(marginBetweenItems, marginTopBottom, 0, marginTopBottom) - - if (childAdapterPosition == 0) { - outRect.left = marginStartEnd - } else if (childAdapterPosition == childAdapterCount - 1) { - outRect.right = marginStartEnd - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt index 851e84f9f..41761fb01 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt @@ -1,7 +1,6 @@ package org.schabi.newpipe.local.subscription.dialog import android.app.Dialog -import android.content.res.ColorStateList import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater @@ -9,12 +8,10 @@ import android.view.View import android.view.ViewGroup import android.view.inputmethod.InputMethodManager import android.widget.Toast -import androidx.core.content.ContextCompat import androidx.core.content.getSystemService import androidx.core.os.bundleOf import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.core.widget.ImageViewCompat import androidx.core.widget.doOnTextChanged import androidx.fragment.app.DialogFragment import androidx.lifecycle.Observer @@ -38,7 +35,7 @@ import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState. import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.SubscriptionsPickerScreen import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.ProcessingEvent import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.SuccessEvent -import org.schabi.newpipe.local.subscription.item.EmptyPlaceholderItem +import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem import org.schabi.newpipe.local.subscription.item.PickerIconItem import org.schabi.newpipe.local.subscription.item.PickerSubscriptionItem import org.schabi.newpipe.util.DeviceUtils @@ -58,10 +55,10 @@ class FeedGroupDialog : DialogFragment(), BackPressable { private var groupSortOrder: Long = -1 sealed class ScreenState : Serializable { - object InitialScreen : ScreenState() - object IconPickerScreen : ScreenState() - object SubscriptionsPickerScreen : ScreenState() - object DeleteScreen : ScreenState() + data object InitialScreen : ScreenState() + data object IconPickerScreen : ScreenState() + data object SubscriptionsPickerScreen : ScreenState() + data object DeleteScreen : ScreenState() } @State @JvmField var selectedIcon: FeedGroupIcon? = null @@ -125,21 +122,15 @@ class FeedGroupDialog : DialogFragment(), BackPressable { _feedGroupCreateBinding = DialogFeedGroupCreateBinding.bind(view) _searchLayoutBinding = feedGroupCreateBinding.subscriptionsHeaderSearchContainer - if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP) { - // KitKat doesn't apply container's theme to content - val contrastColor = ColorStateList.valueOf(ContextCompat.getColor(requireContext(), R.color.contrastColor)) - searchLayoutBinding.toolbarSearchEditText.setTextColor(contrastColor) - searchLayoutBinding.toolbarSearchEditText.setHintTextColor(contrastColor.withAlpha(128)) - ImageViewCompat.setImageTintList(searchLayoutBinding.toolbarSearchClearIcon, contrastColor) - } - viewModel = ViewModelProvider( this, - FeedGroupDialogViewModel.Factory( + FeedGroupDialogViewModel.getFactory( requireContext(), - groupId, subscriptionsCurrentSearchQuery, subscriptionsShowOnlyUngrouped + groupId, + subscriptionsCurrentSearchQuery, + subscriptionsShowOnlyUngrouped ) - ).get(FeedGroupDialogViewModel::class.java) + )[FeedGroupDialogViewModel::class.java] viewModel.groupLiveData.observe(viewLifecycleOwner, Observer(::handleGroup)) viewModel.subscriptionsLiveData.observe(viewLifecycleOwner) { @@ -347,7 +338,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable { if (subscriptions.isEmpty()) { subscriptionEmptyFooter.clear() - subscriptionEmptyFooter.add(EmptyPlaceholderItem()) + subscriptionEmptyFooter.add(ImportSubscriptionsHintPlaceholderItem()) } else { subscriptionEmptyFooter.clear() } @@ -379,7 +370,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable { private fun setupIconPicker() { val groupAdapter = GroupieAdapter() - groupAdapter.addAll(FeedGroupIcon.values().map { PickerIconItem(it) }) + groupAdapter.addAll(FeedGroupIcon.entries.map { PickerIconItem(it) }) feedGroupCreateBinding.iconSelector.apply { layoutManager = GridLayoutManager(requireContext(), 7, RecyclerView.VERTICAL, false) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt index 54ba1c6dc..292bda394 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialogViewModel.kt @@ -4,7 +4,8 @@ import android.content.Context import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.disposables.Disposable @@ -109,24 +110,24 @@ class FeedGroupDialogViewModel( } sealed class DialogEvent { - object ProcessingEvent : DialogEvent() - object SuccessEvent : DialogEvent() + data object ProcessingEvent : DialogEvent() + data object SuccessEvent : DialogEvent() } data class Filter(val query: String, val showOnlyUngrouped: Boolean) - class Factory( - private val context: Context, - private val groupId: Long = FeedGroupEntity.GROUP_ALL_ID, - private val initialQuery: String = "", - private val initialShowOnlyUngrouped: Boolean = false - ) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return FeedGroupDialogViewModel( - context.applicationContext, - groupId, initialQuery, initialShowOnlyUngrouped - ) as T + companion object { + fun getFactory( + context: Context, + groupId: Long, + initialQuery: String, + initialShowOnlyUngrouped: Boolean + ) = viewModelFactory { + initializer { + FeedGroupDialogViewModel( + context.applicationContext, groupId, initialQuery, initialShowOnlyUngrouped + ) + } } } } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt index a8c05838f..bc39dafe6 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/ChannelItem.kt @@ -9,7 +9,7 @@ import org.schabi.newpipe.R import org.schabi.newpipe.extractor.channel.ChannelInfoItem import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.OnClickGesture -import org.schabi.newpipe.util.PicassoHelper +import org.schabi.newpipe.util.image.PicassoHelper class ChannelItem( private val infoItem: ChannelInfoItem, @@ -39,7 +39,7 @@ class ChannelItem( itemChannelDescriptionView.text = infoItem.description } - PicassoHelper.loadThumbnail(infoItem.thumbnailUrl).into(itemThumbnailView) + PicassoHelper.loadAvatar(infoItem.thumbnails).into(itemThumbnailView) gesturesListener?.run { viewHolder.root.setOnClickListener { selected(infoItem) } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddNewGridItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddNewGridItem.kt new file mode 100644 index 000000000..a2870b849 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddNewGridItem.kt @@ -0,0 +1,14 @@ +package org.schabi.newpipe.local.subscription.item + +import android.view.View +import com.xwray.groupie.viewbinding.BindableItem +import org.schabi.newpipe.R +import org.schabi.newpipe.databinding.FeedGroupAddNewGridItemBinding + +class FeedGroupAddNewGridItem : BindableItem() { + override fun getLayout(): Int = R.layout.feed_group_add_new_grid_item + override fun initializeViewBinding(view: View) = FeedGroupAddNewGridItemBinding.bind(view) + override fun bind(viewBinding: FeedGroupAddNewGridItemBinding, position: Int) { + // this is a static item, nothing to do here + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddNewItem.kt similarity index 75% rename from app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddItem.kt rename to app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddNewItem.kt index 434b4f29a..e06e578f8 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddItem.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupAddNewItem.kt @@ -5,8 +5,10 @@ import com.xwray.groupie.viewbinding.BindableItem import org.schabi.newpipe.R import org.schabi.newpipe.databinding.FeedGroupAddNewItemBinding -class FeedGroupAddItem : BindableItem() { +class FeedGroupAddNewItem : BindableItem() { override fun getLayout(): Int = R.layout.feed_group_add_new_item - override fun bind(viewBinding: FeedGroupAddNewItemBinding, position: Int) {} override fun initializeViewBinding(view: View) = FeedGroupAddNewItemBinding.bind(view) + override fun bind(viewBinding: FeedGroupAddNewItemBinding, position: Int) { + // this is a static item, nothing to do here + } } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardGridItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardGridItem.kt new file mode 100644 index 000000000..5a9d6887b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardGridItem.kt @@ -0,0 +1,32 @@ +package org.schabi.newpipe.local.subscription.item + +import android.view.View +import com.xwray.groupie.viewbinding.BindableItem +import org.schabi.newpipe.R +import org.schabi.newpipe.database.feed.model.FeedGroupEntity +import org.schabi.newpipe.databinding.FeedGroupCardGridItemBinding +import org.schabi.newpipe.local.subscription.FeedGroupIcon + +data class FeedGroupCardGridItem( + val groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + val name: String, + val icon: FeedGroupIcon, +) : BindableItem() { + constructor (feedGroupEntity: FeedGroupEntity) : this(feedGroupEntity.uid, feedGroupEntity.name, feedGroupEntity.icon) + + override fun getId(): Long { + return when (groupId) { + FeedGroupEntity.GROUP_ALL_ID -> super.getId() + else -> groupId + } + } + + override fun getLayout(): Int = R.layout.feed_group_card_grid_item + + override fun bind(viewBinding: FeedGroupCardGridItemBinding, position: Int) { + viewBinding.title.text = name + viewBinding.icon.setImageResource(icon.getDrawableRes()) + } + + override fun initializeViewBinding(view: View) = FeedGroupCardGridItemBinding.bind(view) +} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt index 44af16280..bf9f9072f 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCarouselItem.kt @@ -1,60 +1,82 @@ package org.schabi.newpipe.local.subscription.item -import android.content.Context import android.os.Parcelable import android.view.View +import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import com.xwray.groupie.GroupAdapter import com.xwray.groupie.viewbinding.BindableItem import com.xwray.groupie.viewbinding.GroupieViewHolder import org.schabi.newpipe.R import org.schabi.newpipe.databinding.FeedItemCarouselBinding -import org.schabi.newpipe.local.subscription.decoration.FeedGroupCarouselDecoration +import org.schabi.newpipe.util.DeviceUtils +import org.schabi.newpipe.util.ThemeHelper.getGridSpanCount class FeedGroupCarouselItem( - context: Context, - private val carouselAdapter: GroupAdapter> + private val carouselAdapter: GroupAdapter>, + var listViewMode: Boolean ) : BindableItem() { - private val feedGroupCarouselDecoration = FeedGroupCarouselDecoration(context) + companion object { + const val PAYLOAD_UPDATE_LIST_VIEW_MODE = 2 + } - private var linearLayoutManager: LinearLayoutManager? = null + private var carouselLayoutManager: LinearLayoutManager? = null private var listState: Parcelable? = null override fun getLayout() = R.layout.feed_item_carousel fun onSaveInstanceState(): Parcelable? { - listState = linearLayoutManager?.onSaveInstanceState() + listState = carouselLayoutManager?.onSaveInstanceState() return listState } fun onRestoreInstanceState(state: Parcelable?) { - linearLayoutManager?.onRestoreInstanceState(state) + carouselLayoutManager?.onRestoreInstanceState(state) listState = state } override fun initializeViewBinding(view: View): FeedItemCarouselBinding { - val viewHolder = FeedItemCarouselBinding.bind(view) + val viewBinding = FeedItemCarouselBinding.bind(view) + updateViewMode(viewBinding) + return viewBinding + } - linearLayoutManager = LinearLayoutManager(view.context, RecyclerView.HORIZONTAL, false) - - viewHolder.recyclerView.apply { - layoutManager = linearLayoutManager - adapter = carouselAdapter - addItemDecoration(feedGroupCarouselDecoration) + override fun bind( + viewBinding: FeedItemCarouselBinding, + position: Int, + payloads: MutableList + ) { + if (payloads.contains(PAYLOAD_UPDATE_LIST_VIEW_MODE)) { + updateViewMode(viewBinding) + return } - return viewHolder + super.bind(viewBinding, position, payloads) } override fun bind(viewBinding: FeedItemCarouselBinding, position: Int) { viewBinding.recyclerView.apply { adapter = carouselAdapter } - linearLayoutManager?.onRestoreInstanceState(listState) + carouselLayoutManager?.onRestoreInstanceState(listState) } override fun unbind(viewHolder: GroupieViewHolder) { super.unbind(viewHolder) + listState = carouselLayoutManager?.onSaveInstanceState() + } - listState = linearLayoutManager?.onSaveInstanceState() + private fun updateViewMode(viewBinding: FeedItemCarouselBinding) { + viewBinding.recyclerView.apply { adapter = carouselAdapter } + + val context = viewBinding.root.context + carouselLayoutManager = if (listViewMode) { + LinearLayoutManager(context) + } else { + GridLayoutManager(context, getGridSpanCount(context, DeviceUtils.dpToPx(112, context))) + } + + viewBinding.recyclerView.apply { + layoutManager = carouselLayoutManager + adapter = carouselAdapter + } } } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedImportExportItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedImportExportItem.kt deleted file mode 100644 index aacfc77ad..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedImportExportItem.kt +++ /dev/null @@ -1,122 +0,0 @@ -package org.schabi.newpipe.local.subscription.item - -import android.graphics.Color -import android.graphics.PorterDuff -import android.view.View -import android.view.ViewGroup -import android.widget.ImageView -import android.widget.TextView -import androidx.annotation.DrawableRes -import com.xwray.groupie.viewbinding.BindableItem -import com.xwray.groupie.viewbinding.GroupieViewHolder -import org.schabi.newpipe.R -import org.schabi.newpipe.databinding.FeedImportExportGroupBinding -import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.extractor.exceptions.ExtractionException -import org.schabi.newpipe.ktx.animateRotation -import org.schabi.newpipe.util.ServiceHelper -import org.schabi.newpipe.util.ThemeHelper -import org.schabi.newpipe.views.CollapsibleView - -class FeedImportExportItem( - val onImportPreviousSelected: () -> Unit, - val onImportFromServiceSelected: (Int) -> Unit, - val onExportSelected: () -> Unit, - var isExpanded: Boolean = false -) : BindableItem() { - companion object { - const val REFRESH_EXPANDED_STATUS = 123 - } - - override fun bind(viewBinding: FeedImportExportGroupBinding, position: Int, payloads: MutableList) { - if (payloads.contains(REFRESH_EXPANDED_STATUS)) { - viewBinding.importExportOptions.apply { if (isExpanded) expand() else collapse() } - return - } - - super.bind(viewBinding, position, payloads) - } - - override fun getLayout(): Int = R.layout.feed_import_export_group - - override fun bind(viewBinding: FeedImportExportGroupBinding, position: Int) { - if (viewBinding.importFromOptions.childCount == 0) setupImportFromItems(viewBinding.importFromOptions) - if (viewBinding.exportToOptions.childCount == 0) setupExportToItems(viewBinding.exportToOptions) - - expandIconListener?.let { viewBinding.importExportOptions.removeListener(it) } - expandIconListener = CollapsibleView.StateListener { newState -> - viewBinding.importExportExpandIcon.animateRotation( - 250, if (newState == CollapsibleView.COLLAPSED) 0 else 180 - ) - } - - viewBinding.importExportOptions.currentState = if (isExpanded) CollapsibleView.EXPANDED else CollapsibleView.COLLAPSED - viewBinding.importExportExpandIcon.rotation = if (isExpanded) 180F else 0F - viewBinding.importExportOptions.ready() - - viewBinding.importExportOptions.addListener(expandIconListener) - viewBinding.importExport.setOnClickListener { - viewBinding.importExportOptions.switchState() - isExpanded = viewBinding.importExportOptions.currentState == CollapsibleView.EXPANDED - } - } - - override fun unbind(viewHolder: GroupieViewHolder) { - super.unbind(viewHolder) - expandIconListener?.let { viewHolder.binding.importExportOptions.removeListener(it) } - expandIconListener = null - } - - override fun initializeViewBinding(view: View) = FeedImportExportGroupBinding.bind(view) - - private var expandIconListener: CollapsibleView.StateListener? = null - - private fun addItemView(title: String, @DrawableRes icon: Int, container: ViewGroup): View { - val itemRoot = View.inflate(container.context, R.layout.subscription_import_export_item, null) - val titleView = itemRoot.findViewById(android.R.id.text1) - val iconView = itemRoot.findViewById(android.R.id.icon1) - - titleView.text = title - iconView.setImageResource(icon) - - container.addView(itemRoot) - return itemRoot - } - - private fun setupImportFromItems(listHolder: ViewGroup) { - val previousBackupItem = addItemView( - listHolder.context.getString(R.string.previous_export), - R.drawable.ic_backup, listHolder - ) - previousBackupItem.setOnClickListener { onImportPreviousSelected() } - - val iconColor = if (ThemeHelper.isLightThemeSelected(listHolder.context)) Color.BLACK else Color.WHITE - val services = listHolder.context.resources.getStringArray(R.array.service_list) - for (serviceName in services) { - try { - val service = NewPipe.getService(serviceName) - - val subscriptionExtractor = service.subscriptionExtractor ?: continue - - val supportedSources = subscriptionExtractor.supportedSources - if (supportedSources.isEmpty()) continue - - val itemView = addItemView(serviceName, ServiceHelper.getIcon(service.serviceId), listHolder) - val iconView = itemView.findViewById(android.R.id.icon1) - iconView.setColorFilter(iconColor, PorterDuff.Mode.SRC_IN) - - itemView.setOnClickListener { onImportFromServiceSelected(service.serviceId) } - } catch (e: ExtractionException) { - throw RuntimeException("Services array contains an entry that it's not a valid service name ($serviceName)", e) - } - } - } - - private fun setupExportToItems(listHolder: ViewGroup) { - val previousBackupItem = addItemView( - listHolder.context.getString(R.string.file), - R.drawable.ic_save, listHolder - ) - previousBackupItem.setOnClickListener { onExportSelected() } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/GroupsHeader.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/GroupsHeader.kt new file mode 100644 index 000000000..8d5088890 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/GroupsHeader.kt @@ -0,0 +1,50 @@ +package org.schabi.newpipe.local.subscription.item + +import android.view.View +import androidx.core.view.isVisible +import com.xwray.groupie.viewbinding.BindableItem +import org.schabi.newpipe.R +import org.schabi.newpipe.databinding.SubscriptionGroupsHeaderBinding + +class GroupsHeader( + private val title: String, + private val onSortClicked: () -> Unit, + private val onToggleListViewModeClicked: () -> Unit, + var showSortButton: Boolean = true, + var listViewMode: Boolean = true +) : BindableItem() { + companion object { + const val PAYLOAD_UPDATE_ICONS = 1 + } + + override fun getLayout(): Int = R.layout.subscription_groups_header + + override fun bind( + viewBinding: SubscriptionGroupsHeaderBinding, + position: Int, + payloads: MutableList + ) { + if (payloads.contains(PAYLOAD_UPDATE_ICONS)) { + updateIcons(viewBinding) + return + } + + super.bind(viewBinding, position, payloads) + } + + override fun bind(viewBinding: SubscriptionGroupsHeaderBinding, position: Int) { + viewBinding.headerTitle.text = title + viewBinding.headerSort.setOnClickListener { onSortClicked() } + viewBinding.headerToggleViewMode.setOnClickListener { onToggleListViewModeClicked() } + updateIcons(viewBinding) + } + + override fun initializeViewBinding(view: View) = SubscriptionGroupsHeaderBinding.bind(view) + + private fun updateIcons(viewBinding: SubscriptionGroupsHeaderBinding) { + viewBinding.headerToggleViewMode.setImageResource( + if (listViewMode) R.drawable.ic_apps else R.drawable.ic_list + ) + viewBinding.headerSort.isVisible = showSortButton + } +} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/Header.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/Header.kt new file mode 100644 index 000000000..87a3ac768 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/Header.kt @@ -0,0 +1,17 @@ +package org.schabi.newpipe.local.subscription.item + +import android.view.View +import com.xwray.groupie.viewbinding.BindableItem +import org.schabi.newpipe.R +import org.schabi.newpipe.databinding.SubscriptionHeaderBinding + +class Header(private val title: String) : BindableItem() { + + override fun getLayout(): Int = R.layout.subscription_header + + override fun bind(viewBinding: SubscriptionHeaderBinding, position: Int) { + viewBinding.root.text = title + } + + override fun initializeViewBinding(view: View) = SubscriptionHeaderBinding.bind(view) +} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderWithMenuItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderWithMenuItem.kt deleted file mode 100644 index 79a272178..000000000 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderWithMenuItem.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.schabi.newpipe.local.subscription.item - -import android.view.View -import android.view.View.OnClickListener -import androidx.annotation.DrawableRes -import androidx.core.view.isVisible -import com.xwray.groupie.viewbinding.BindableItem -import org.schabi.newpipe.R -import org.schabi.newpipe.databinding.HeaderWithMenuItemBinding - -class HeaderWithMenuItem( - val title: String, - @DrawableRes val itemIcon: Int = 0, - var showMenuItem: Boolean = true, - private val onClickListener: (() -> Unit)? = null, - private val menuItemOnClickListener: (() -> Unit)? = null -) : BindableItem() { - companion object { - const val PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM = 1 - } - - override fun getLayout(): Int = R.layout.header_with_menu_item - - override fun bind(viewBinding: HeaderWithMenuItemBinding, position: Int, payloads: MutableList) { - if (payloads.contains(PAYLOAD_UPDATE_VISIBILITY_MENU_ITEM)) { - updateMenuItemVisibility(viewBinding) - return - } - - super.bind(viewBinding, position, payloads) - } - - override fun bind(viewBinding: HeaderWithMenuItemBinding, position: Int) { - viewBinding.headerTitle.text = title - viewBinding.headerMenuItem.setImageResource(itemIcon) - - val listener = onClickListener?.let { OnClickListener { onClickListener.invoke() } } - viewBinding.root.setOnClickListener(listener) - - val menuItemListener = menuItemOnClickListener?.let { OnClickListener { menuItemOnClickListener.invoke() } } - viewBinding.headerMenuItem.setOnClickListener(menuItemListener) - updateMenuItemVisibility(viewBinding) - } - - override fun initializeViewBinding(view: View) = HeaderWithMenuItemBinding.bind(view) - - private fun updateMenuItemVisibility(viewBinding: HeaderWithMenuItemBinding) { - viewBinding.headerMenuItem.isVisible = showMenuItem - } -} diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/EmptyPlaceholderItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/ImportSubscriptionsHintPlaceholderItem.kt similarity index 63% rename from app/src/main/java/org/schabi/newpipe/local/subscription/item/EmptyPlaceholderItem.kt rename to app/src/main/java/org/schabi/newpipe/local/subscription/item/ImportSubscriptionsHintPlaceholderItem.kt index 59bef55cf..93b551895 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/EmptyPlaceholderItem.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/ImportSubscriptionsHintPlaceholderItem.kt @@ -5,8 +5,11 @@ import com.xwray.groupie.viewbinding.BindableItem import org.schabi.newpipe.R import org.schabi.newpipe.databinding.ListEmptyViewBinding -class EmptyPlaceholderItem : BindableItem() { - override fun getLayout(): Int = R.layout.list_empty_view +/** + * When there are no subscriptions, show a hint to the user about how to import subscriptions + */ +class ImportSubscriptionsHintPlaceholderItem : BindableItem() { + override fun getLayout(): Int = R.layout.list_empty_view_subscriptions override fun bind(viewBinding: ListEmptyViewBinding, position: Int) {} override fun getSpanSize(spanCount: Int, position: Int): Int = spanCount override fun initializeViewBinding(view: View) = ListEmptyViewBinding.bind(view) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt index aadb2fc73..3a4c6e41b 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerSubscriptionItem.kt @@ -10,7 +10,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.databinding.PickerSubscriptionItemBinding import org.schabi.newpipe.ktx.AnimationType import org.schabi.newpipe.ktx.animate -import org.schabi.newpipe.util.PicassoHelper +import org.schabi.newpipe.util.image.PicassoHelper data class PickerSubscriptionItem( val subscriptionEntity: SubscriptionEntity, diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java index 063103597..54809068a 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsExportService.java @@ -19,10 +19,13 @@ package org.schabi.newpipe.local.subscription.services; +import static org.schabi.newpipe.MainActivity.DEBUG; + import android.content.Intent; import android.net.Uri; import android.util.Log; +import androidx.core.content.IntentCompat; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import org.reactivestreams.Subscriber; @@ -43,8 +46,6 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.functions.Function; import io.reactivex.rxjava3.schedulers.Schedulers; -import static org.schabi.newpipe.MainActivity.DEBUG; - public class SubscriptionsExportService extends BaseImportExportService { public static final String KEY_FILE_PATH = "key_file_path"; @@ -65,7 +66,7 @@ public class SubscriptionsExportService extends BaseImportExportService { return START_NOT_STICKY; } - final Uri path = intent.getParcelableExtra(KEY_FILE_PATH); + final Uri path = IntentCompat.getParcelableExtra(intent, KEY_FILE_PATH, Uri.class); if (path == null) { stopAndReportError(new IllegalStateException( "Exporting to a file, but the path is null"), @@ -109,8 +110,8 @@ public class SubscriptionsExportService extends BaseImportExportService { subscriptionManager.subscriptionTable().getAll().take(1) .map(subscriptionEntities -> { - final List result - = new ArrayList<>(subscriptionEntities.size()); + final List result = + new ArrayList<>(subscriptionEntities.size()); for (final SubscriptionEntity entity : subscriptionEntities) { result.add(new SubscriptionItem(entity.getServiceId(), entity.getUrl(), entity.getName())); diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java index af598b106..442c7fddb 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/services/SubscriptionsImportService.java @@ -26,9 +26,11 @@ import android.content.Intent; import android.net.Uri; import android.text.TextUtils; import android.util.Log; +import android.util.Pair; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.content.IntentCompat; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import org.reactivestreams.Subscriber; @@ -38,6 +40,7 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.channel.ChannelInfo; +import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; import org.schabi.newpipe.extractor.subscription.SubscriptionItem; import org.schabi.newpipe.ktx.ExceptionUtils; import org.schabi.newpipe.streams.io.SharpInputStream; @@ -48,6 +51,7 @@ import org.schabi.newpipe.util.ExtractorHelper; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Objects; @@ -105,7 +109,7 @@ public class SubscriptionsImportService extends BaseImportExportService { if (currentMode == CHANNEL_URL_MODE) { channelUrl = intent.getStringExtra(KEY_VALUE); } else { - final Uri uri = intent.getParcelableExtra(KEY_VALUE); + final Uri uri = IntentCompat.getParcelableExtra(intent, KEY_VALUE, Uri.class); if (uri == null) { stopAndReportError(new IllegalStateException( "Importing from input stream, but file path is null"), @@ -199,12 +203,19 @@ public class SubscriptionsImportService extends BaseImportExportService { .parallel(PARALLEL_EXTRACTIONS) .runOn(Schedulers.io()) - .map((Function>) subscriptionItem -> { + .map((Function>>>) subscriptionItem -> { try { - return Notification.createOnNext(ExtractorHelper + final ChannelInfo channelInfo = ExtractorHelper .getChannelInfo(subscriptionItem.getServiceId(), subscriptionItem.getUrl(), true) - .blockingGet()); + .blockingGet(); + return Notification.createOnNext(new Pair<>(channelInfo, + Collections.singletonList( + ExtractorHelper.getChannelTab( + subscriptionItem.getServiceId(), + channelInfo.getTabs().get(0), true).blockingGet() + ))); } catch (final Throwable e) { return Notification.createOnError(e); } @@ -223,7 +234,7 @@ public class SubscriptionsImportService extends BaseImportExportService { } private Subscriber> getSubscriber() { - return new Subscriber>() { + return new Subscriber<>() { @Override public void onSubscribe(final Subscription s) { subscription = s; @@ -254,10 +265,11 @@ public class SubscriptionsImportService extends BaseImportExportService { }; } - private Consumer> getNotificationsConsumer() { + private Consumer>>> getNotificationsConsumer() { return notification -> { if (notification.isOnNext()) { - final String name = notification.getValue().getName(); + final String name = notification.getValue().first.getName(); eventListener.onItemCompleted(!TextUtils.isEmpty(name) ? name : ""); } else if (notification.isOnError()) { final Throwable error = notification.getError(); @@ -275,10 +287,12 @@ public class SubscriptionsImportService extends BaseImportExportService { }; } - private Function>, List> upsertBatch() { + private Function>>>, + List> upsertBatch() { return notificationList -> { - final List infoList = new ArrayList<>(notificationList.size()); - for (final Notification n : notificationList) { + final List>> infoList = + new ArrayList<>(notificationList.size()); + for (final Notification>> n : notificationList) { if (n.isOnNext()) { infoList.add(n.getValue()); } diff --git a/app/src/main/java/org/schabi/newpipe/player/MainPlayer.java b/app/src/main/java/org/schabi/newpipe/player/MainPlayer.java deleted file mode 100644 index a9b9f4c87..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/MainPlayer.java +++ /dev/null @@ -1,259 +0,0 @@ -/* - * Copyright 2017 Mauricio Colli - * Part of NewPipe - * - * License: GPL-3.0+ - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.schabi.newpipe.player; - -import android.app.Service; -import android.content.Context; -import android.content.Intent; -import android.os.Binder; -import android.os.IBinder; -import android.util.Log; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowManager; - -import androidx.annotation.Nullable; -import androidx.core.content.ContextCompat; - -import org.schabi.newpipe.App; -import org.schabi.newpipe.databinding.PlayerBinding; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.ThemeHelper; - -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - - -/** - * One service for all players. - * - * @author mauriciocolli - */ -public final class MainPlayer extends Service { - private static final String TAG = "MainPlayer"; - private static final boolean DEBUG = Player.DEBUG; - - private Player player; - private WindowManager windowManager; - - private final IBinder mBinder = new MainPlayer.LocalBinder(); - - public enum PlayerType { - VIDEO, - AUDIO, - POPUP - } - - /*////////////////////////////////////////////////////////////////////////// - // Notification - //////////////////////////////////////////////////////////////////////////*/ - - static final String ACTION_CLOSE - = App.PACKAGE_NAME + ".player.MainPlayer.CLOSE"; - static final String ACTION_PLAY_PAUSE - = App.PACKAGE_NAME + ".player.MainPlayer.PLAY_PAUSE"; - static final String ACTION_REPEAT - = App.PACKAGE_NAME + ".player.MainPlayer.REPEAT"; - static final String ACTION_PLAY_NEXT - = App.PACKAGE_NAME + ".player.MainPlayer.ACTION_PLAY_NEXT"; - static final String ACTION_PLAY_PREVIOUS - = App.PACKAGE_NAME + ".player.MainPlayer.ACTION_PLAY_PREVIOUS"; - static final String ACTION_FAST_REWIND - = App.PACKAGE_NAME + ".player.MainPlayer.ACTION_FAST_REWIND"; - static final String ACTION_FAST_FORWARD - = App.PACKAGE_NAME + ".player.MainPlayer.ACTION_FAST_FORWARD"; - static final String ACTION_SHUFFLE - = App.PACKAGE_NAME + ".player.MainPlayer.ACTION_SHUFFLE"; - public static final String ACTION_RECREATE_NOTIFICATION - = App.PACKAGE_NAME + ".player.MainPlayer.ACTION_RECREATE_NOTIFICATION"; - - /*////////////////////////////////////////////////////////////////////////// - // Service's LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onCreate() { - if (DEBUG) { - Log.d(TAG, "onCreate() called"); - } - assureCorrectAppLanguage(this); - windowManager = ContextCompat.getSystemService(this, WindowManager.class); - - ThemeHelper.setTheme(this); - createView(); - } - - private void createView() { - final PlayerBinding binding = PlayerBinding.inflate(LayoutInflater.from(this)); - - player = new Player(this); - player.setupFromView(binding); - - NotificationUtil.getInstance().createNotificationAndStartForeground(player, this); - } - - @Override - public int onStartCommand(final Intent intent, final int flags, final int startId) { - if (DEBUG) { - Log.d(TAG, "onStartCommand() called with: intent = [" + intent - + "], flags = [" + flags + "], startId = [" + startId + "]"); - } - if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction()) - && player.getPlayQueue() == null) { - // Player is not working, no need to process media button's action - return START_NOT_STICKY; - } - - if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction()) - || intent.getStringExtra(Player.PLAY_QUEUE_KEY) != null) { - NotificationUtil.getInstance().createNotificationAndStartForeground(player, this); - } - - player.handleIntent(intent); - if (player.getMediaSessionManager() != null) { - player.getMediaSessionManager().handleMediaButtonIntent(intent); - } - return START_NOT_STICKY; - } - - public void stopForImmediateReusing() { - if (DEBUG) { - Log.d(TAG, "stopForImmediateReusing() called"); - } - - if (!player.exoPlayerIsNull()) { - player.saveWasPlaying(); - - // Releases wifi & cpu, disables keepScreenOn, etc. - // We can't just pause the player here because it will make transition - // from one stream to a new stream not smooth - player.smoothStopPlayer(); - player.setRecovery(); - - // Android TV will handle back button in case controls will be visible - // (one more additional unneeded click while the player is hidden) - player.hideControls(0, 0); - player.closeItemsList(); - - // Notification shows information about old stream but if a user selects - // a stream from backStack it's not actual anymore - // So we should hide the notification at all. - // When autoplay enabled such notification flashing is annoying so skip this case - } - } - - @Override - public void onTaskRemoved(final Intent rootIntent) { - super.onTaskRemoved(rootIntent); - if (!player.videoPlayerSelected()) { - return; - } - onDestroy(); - // Unload from memory completely - Runtime.getRuntime().halt(0); - } - - @Override - public void onDestroy() { - if (DEBUG) { - Log.d(TAG, "destroy() called"); - } - cleanup(); - } - - private void cleanup() { - if (player != null) { - // Exit from fullscreen when user closes the player via notification - if (player.isFullscreen()) { - player.toggleFullscreen(); - } - removeViewFromParent(); - - player.saveStreamProgressState(); - player.setRecovery(); - player.stopActivityBinding(); - player.removePopupFromView(); - player.destroy(); - - player = null; - } - } - - public void stopService() { - NotificationUtil.getInstance().cancelNotificationAndStopForeground(this); - cleanup(); - stopSelf(); - } - - @Override - protected void attachBaseContext(final Context base) { - super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base)); - } - - @Override - public IBinder onBind(final Intent intent) { - return mBinder; - } - - /*////////////////////////////////////////////////////////////////////////// - // Utils - //////////////////////////////////////////////////////////////////////////*/ - - boolean isLandscape() { - // DisplayMetrics from activity context knows about MultiWindow feature - // while DisplayMetrics from app context doesn't - return DeviceUtils.isLandscape(player != null && player.getParentActivity() != null - ? player.getParentActivity() : this); - } - - @Nullable - public View getView() { - if (player == null) { - return null; - } - - return player.getRootView(); - } - - public void removeViewFromParent() { - if (getView() != null && getView().getParent() != null) { - if (player.getParentActivity() != null) { - // This means view was added to fragment - final ViewGroup parent = (ViewGroup) getView().getParent(); - parent.removeView(getView()); - } else { - // This means view was added by windowManager for popup player - windowManager.removeViewImmediate(getView()); - } - } - } - - - public class LocalBinder extends Binder { - - public MainPlayer getService() { - return MainPlayer.this; - } - - public Player getPlayer() { - return MainPlayer.this.player; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/NotificationUtil.java b/app/src/main/java/org/schabi/newpipe/player/NotificationUtil.java deleted file mode 100644 index 948343be2..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/NotificationUtil.java +++ /dev/null @@ -1,381 +0,0 @@ -package org.schabi.newpipe.player; - -import android.annotation.SuppressLint; -import android.app.PendingIntent; -import android.app.Service; -import android.content.Intent; -import android.content.pm.ServiceInfo; -import android.graphics.Bitmap; -import android.graphics.Matrix; -import android.os.Build; -import android.util.Log; - -import androidx.annotation.DrawableRes; -import androidx.annotation.Nullable; -import androidx.annotation.StringRes; -import androidx.core.app.NotificationCompat; -import androidx.core.app.NotificationManagerCompat; -import androidx.core.app.ServiceCompat; -import androidx.core.content.ContextCompat; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.R; -import org.schabi.newpipe.util.NavigationHelper; - -import java.util.List; - -import static android.app.PendingIntent.FLAG_UPDATE_CURRENT; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; -import static org.schabi.newpipe.player.MainPlayer.ACTION_CLOSE; -import static org.schabi.newpipe.player.MainPlayer.ACTION_FAST_FORWARD; -import static org.schabi.newpipe.player.MainPlayer.ACTION_FAST_REWIND; -import static org.schabi.newpipe.player.MainPlayer.ACTION_PLAY_NEXT; -import static org.schabi.newpipe.player.MainPlayer.ACTION_PLAY_PAUSE; -import static org.schabi.newpipe.player.MainPlayer.ACTION_PLAY_PREVIOUS; -import static org.schabi.newpipe.player.MainPlayer.ACTION_REPEAT; -import static org.schabi.newpipe.player.MainPlayer.ACTION_SHUFFLE; - -/** - * This is a utility class for player notifications. - * - * @author cool-student - */ -public final class NotificationUtil { - private static final String TAG = NotificationUtil.class.getSimpleName(); - private static final boolean DEBUG = Player.DEBUG; - private static final int NOTIFICATION_ID = 123789; - - @Nullable private static NotificationUtil instance = null; - - @NotificationConstants.Action - private final int[] notificationSlots = NotificationConstants.SLOT_DEFAULTS.clone(); - - private NotificationManagerCompat notificationManager; - private NotificationCompat.Builder notificationBuilder; - - private NotificationUtil() { - } - - public static NotificationUtil getInstance() { - if (instance == null) { - instance = new NotificationUtil(); - } - return instance; - } - - - ///////////////////////////////////////////////////// - // NOTIFICATION - ///////////////////////////////////////////////////// - - /** - * Creates the notification if it does not exist already and recreates it if forceRecreate is - * true. Updates the notification with the data in the player. - * @param player the player currently open, to take data from - * @param forceRecreate whether to force the recreation of the notification even if it already - * exists - */ - synchronized void createNotificationIfNeededAndUpdate(final Player player, - final boolean forceRecreate) { - if (forceRecreate || notificationBuilder == null) { - notificationBuilder = createNotification(player); - } - updateNotification(player); - notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()); - } - - private synchronized NotificationCompat.Builder createNotification(final Player player) { - if (DEBUG) { - Log.d(TAG, "createNotification()"); - } - notificationManager = NotificationManagerCompat.from(player.getContext()); - final NotificationCompat.Builder builder = - new NotificationCompat.Builder(player.getContext(), - player.getContext().getString(R.string.notification_channel_id)); - - initializeNotificationSlots(player); - - // count the number of real slots, to make sure compact slots indices are not out of bound - int nonNothingSlotCount = 5; - if (notificationSlots[3] == NotificationConstants.NOTHING) { - --nonNothingSlotCount; - } - if (notificationSlots[4] == NotificationConstants.NOTHING) { - --nonNothingSlotCount; - } - - // build the compact slot indices array (need code to convert from Integer... because Java) - final List compactSlotList = NotificationConstants.getCompactSlotsFromPreferences( - player.getContext(), player.getPrefs(), nonNothingSlotCount); - final int[] compactSlots = new int[compactSlotList.size()]; - for (int i = 0; i < compactSlotList.size(); i++) { - compactSlots[i] = compactSlotList.get(i); - } - - builder.setStyle(new androidx.media.app.NotificationCompat.MediaStyle() - .setMediaSession(player.getMediaSessionManager().getSessionToken()) - .setShowActionsInCompactView(compactSlots)) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setCategory(NotificationCompat.CATEGORY_TRANSPORT) - .setShowWhen(false) - .setSmallIcon(R.drawable.ic_newpipe_triangle_white) - .setColor(ContextCompat.getColor(player.getContext(), - R.color.dark_background_color)) - .setColorized(player.getPrefs().getBoolean( - player.getContext().getString(R.string.notification_colorize_key), true)) - .setDeleteIntent(PendingIntent.getBroadcast(player.getContext(), NOTIFICATION_ID, - new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT)); - - return builder; - } - - /** - * Updates the notification builder and the button icons depending on the playback state. - * @param player the player currently open, to take data from - */ - private synchronized void updateNotification(final Player player) { - if (DEBUG) { - Log.d(TAG, "updateNotification()"); - } - - // also update content intent, in case the user switched players - notificationBuilder.setContentIntent(PendingIntent.getActivity(player.getContext(), - NOTIFICATION_ID, getIntentForNotification(player), FLAG_UPDATE_CURRENT)); - notificationBuilder.setContentTitle(player.getVideoTitle()); - notificationBuilder.setContentText(player.getUploaderName()); - notificationBuilder.setTicker(player.getVideoTitle()); - updateActions(notificationBuilder, player); - final boolean showThumbnail = player.getPrefs().getBoolean( - player.getContext().getString(R.string.show_thumbnail_key), true); - if (showThumbnail) { - setLargeIcon(notificationBuilder, player); - } - } - - - @SuppressLint("RestrictedApi") - boolean shouldUpdateBufferingSlot() { - if (notificationBuilder == null) { - // if there is no notification active, there is no point in updating it - return false; - } else if (notificationBuilder.mActions.size() < 3) { - // this should never happen, but let's make sure notification actions are populated - return true; - } - - // only second and third slot could contain PLAY_PAUSE_BUFFERING, update them only if they - // are not already in the buffering state (the only one with a null action intent) - return (notificationSlots[1] == NotificationConstants.PLAY_PAUSE_BUFFERING - && notificationBuilder.mActions.get(1).actionIntent != null) - || (notificationSlots[2] == NotificationConstants.PLAY_PAUSE_BUFFERING - && notificationBuilder.mActions.get(2).actionIntent != null); - } - - - void createNotificationAndStartForeground(final Player player, final Service service) { - if (notificationBuilder == null) { - notificationBuilder = createNotification(player); - } - updateNotification(player); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - service.startForeground(NOTIFICATION_ID, notificationBuilder.build(), - ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK); - } else { - service.startForeground(NOTIFICATION_ID, notificationBuilder.build()); - } - } - - void cancelNotificationAndStopForeground(final Service service) { - ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE); - - if (notificationManager != null) { - notificationManager.cancel(NOTIFICATION_ID); - } - notificationManager = null; - notificationBuilder = null; - } - - - ///////////////////////////////////////////////////// - // ACTIONS - ///////////////////////////////////////////////////// - - private void initializeNotificationSlots(final Player player) { - for (int i = 0; i < 5; ++i) { - notificationSlots[i] = player.getPrefs().getInt( - player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]), - NotificationConstants.SLOT_DEFAULTS[i]); - } - } - - @SuppressLint("RestrictedApi") - private void updateActions(final NotificationCompat.Builder builder, final Player player) { - builder.mActions.clear(); - for (int i = 0; i < 5; ++i) { - addAction(builder, player, notificationSlots[i]); - } - } - - private void addAction(final NotificationCompat.Builder builder, - final Player player, - @NotificationConstants.Action final int slot) { - final NotificationCompat.Action action = getAction(player, slot); - if (action != null) { - builder.addAction(action); - } - } - - @Nullable - private NotificationCompat.Action getAction( - final Player player, - @NotificationConstants.Action final int selectedAction) { - final int baseActionIcon = NotificationConstants.ACTION_ICONS[selectedAction]; - switch (selectedAction) { - case NotificationConstants.PREVIOUS: - return getAction(player, baseActionIcon, - R.string.exo_controls_previous_description, ACTION_PLAY_PREVIOUS); - - case NotificationConstants.NEXT: - return getAction(player, baseActionIcon, - R.string.exo_controls_next_description, ACTION_PLAY_NEXT); - - case NotificationConstants.REWIND: - return getAction(player, baseActionIcon, - R.string.exo_controls_rewind_description, ACTION_FAST_REWIND); - - case NotificationConstants.FORWARD: - return getAction(player, baseActionIcon, - R.string.exo_controls_fastforward_description, ACTION_FAST_FORWARD); - - case NotificationConstants.SMART_REWIND_PREVIOUS: - if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) { - return getAction(player, R.drawable.exo_notification_previous, - R.string.exo_controls_previous_description, ACTION_PLAY_PREVIOUS); - } else { - return getAction(player, R.drawable.exo_controls_rewind, - R.string.exo_controls_rewind_description, ACTION_FAST_REWIND); - } - - case NotificationConstants.SMART_FORWARD_NEXT: - if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) { - return getAction(player, R.drawable.exo_notification_next, - R.string.exo_controls_next_description, ACTION_PLAY_NEXT); - } else { - return getAction(player, R.drawable.exo_controls_fastforward, - R.string.exo_controls_fastforward_description, ACTION_FAST_FORWARD); - } - - case NotificationConstants.PLAY_PAUSE_BUFFERING: - if (player.getCurrentState() == Player.STATE_PREFLIGHT - || player.getCurrentState() == Player.STATE_BLOCKED - || player.getCurrentState() == Player.STATE_BUFFERING) { - // null intent -> show hourglass icon that does nothing when clicked - return new NotificationCompat.Action(R.drawable.ic_hourglass_top, - player.getContext().getString(R.string.notification_action_buffering), - null); - } - - case NotificationConstants.PLAY_PAUSE: - if (player.getCurrentState() == Player.STATE_COMPLETED) { - return getAction(player, R.drawable.ic_replay, - R.string.exo_controls_pause_description, ACTION_PLAY_PAUSE); - } else if (player.isPlaying() - || player.getCurrentState() == Player.STATE_PREFLIGHT - || player.getCurrentState() == Player.STATE_BLOCKED - || player.getCurrentState() == Player.STATE_BUFFERING) { - return getAction(player, R.drawable.exo_notification_pause, - R.string.exo_controls_pause_description, ACTION_PLAY_PAUSE); - } else { - return getAction(player, R.drawable.exo_notification_play, - R.string.exo_controls_play_description, ACTION_PLAY_PAUSE); - } - - case NotificationConstants.REPEAT: - if (player.getRepeatMode() == REPEAT_MODE_ALL) { - return getAction(player, R.drawable.exo_media_action_repeat_all, - R.string.exo_controls_repeat_all_description, ACTION_REPEAT); - } else if (player.getRepeatMode() == REPEAT_MODE_ONE) { - return getAction(player, R.drawable.exo_media_action_repeat_one, - R.string.exo_controls_repeat_one_description, ACTION_REPEAT); - } else /* player.getRepeatMode() == REPEAT_MODE_OFF */ { - return getAction(player, R.drawable.exo_media_action_repeat_off, - R.string.exo_controls_repeat_off_description, ACTION_REPEAT); - } - - case NotificationConstants.SHUFFLE: - if (player.getPlayQueue() != null && player.getPlayQueue().isShuffled()) { - return getAction(player, R.drawable.exo_controls_shuffle_on, - R.string.exo_controls_shuffle_on_description, ACTION_SHUFFLE); - } else { - return getAction(player, R.drawable.exo_controls_shuffle_off, - R.string.exo_controls_shuffle_off_description, ACTION_SHUFFLE); - } - - case NotificationConstants.CLOSE: - return getAction(player, R.drawable.ic_close, - R.string.close, ACTION_CLOSE); - - case NotificationConstants.NOTHING: - default: - // do nothing - return null; - } - } - - private NotificationCompat.Action getAction(final Player player, - @DrawableRes final int drawable, - @StringRes final int title, - final String intentAction) { - return new NotificationCompat.Action(drawable, player.getContext().getString(title), - PendingIntent.getBroadcast(player.getContext(), NOTIFICATION_ID, - new Intent(intentAction), FLAG_UPDATE_CURRENT)); - } - - private Intent getIntentForNotification(final Player player) { - if (player.audioPlayerSelected() || player.popupPlayerSelected()) { - // Means we play in popup or audio only. Let's show the play queue - return NavigationHelper.getPlayQueueActivityIntent(player.getContext()); - } else { - // We are playing in fragment. Don't open another activity just show fragment. That's it - final Intent intent = NavigationHelper.getPlayerIntent( - player.getContext(), MainActivity.class, null, true); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - intent.setAction(Intent.ACTION_MAIN); - intent.addCategory(Intent.CATEGORY_LAUNCHER); - return intent; - } - } - - - ///////////////////////////////////////////////////// - // BITMAP - ///////////////////////////////////////////////////// - - private void setLargeIcon(final NotificationCompat.Builder builder, final Player player) { - final boolean scaleImageToSquareAspectRatio = player.getPrefs().getBoolean( - player.getContext().getString(R.string.scale_to_square_image_in_notifications_key), - false); - if (scaleImageToSquareAspectRatio) { - builder.setLargeIcon(getBitmapWithSquareAspectRatio(player.getThumbnail())); - } else { - builder.setLargeIcon(player.getThumbnail()); - } - } - - private Bitmap getBitmapWithSquareAspectRatio(final Bitmap bitmap) { - return getResizedBitmap(bitmap, bitmap.getWidth(), bitmap.getWidth()); - } - - private Bitmap getResizedBitmap(final Bitmap bitmap, final int newWidth, final int newHeight) { - final int width = bitmap.getWidth(); - final int height = bitmap.getHeight(); - final float scaleWidth = ((float) newWidth) / width; - final float scaleHeight = ((float) newHeight) / height; - final Matrix matrix = new Matrix(); - matrix.postScale(scaleWidth, scaleHeight); - return Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, false); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java index 53e6ce591..c012f6008 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java @@ -13,8 +13,10 @@ import android.provider.Settings; import android.util.Log; import android.view.Menu; import android.view.MenuItem; +import android.view.SubMenu; import android.view.View; import android.view.ViewGroup; +import android.widget.ImageButton; import android.widget.SeekBar; import androidx.annotation.Nullable; @@ -27,10 +29,13 @@ import com.google.android.exoplayer2.PlaybackParameters; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.ActivityPlayerQueueControlBinding; +import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; +import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.player.event.PlayerEventListener; import org.schabi.newpipe.player.helper.PlaybackParameterDialog; +import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueAdapter; import org.schabi.newpipe.player.playqueue.PlayQueueItem; @@ -43,6 +48,9 @@ import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.ThemeHelper; +import java.util.List; +import java.util.Optional; + public final class PlayQueueActivity extends AppCompatActivity implements PlayerEventListener, SeekBar.OnSeekBarChangeListener, View.OnClickListener, PlaybackParameterDialog.Callback { @@ -51,7 +59,9 @@ public final class PlayQueueActivity extends AppCompatActivity private static final int SMOOTH_SCROLL_MAXIMUM_DISTANCE = 80; - protected Player player; + private static final int MENU_ID_AUDIO_TRACK = 71; + + private Player player; private boolean serviceBound; private ServiceConnection serviceConnection; @@ -96,8 +106,12 @@ public final class PlayQueueActivity extends AppCompatActivity this.menu = m; getMenuInflater().inflate(R.menu.menu_play_queue, m); getMenuInflater().inflate(R.menu.menu_play_queue_bg, m); + buildAudioTrackMenu(); onMaybeMuteChanged(); - onPlaybackParameterChanged(player.getPlaybackParameters()); + // to avoid null reference + if (player != null) { + onPlaybackParameterChanged(player.getPlaybackParameters()); + } return true; } @@ -123,13 +137,13 @@ public final class PlayQueueActivity extends AppCompatActivity NavigationHelper.openSettings(this); return true; case R.id.action_append_playlist: - player.onAddToPlaylistClicked(getSupportFragmentManager()); + PlaylistDialog.showForPlayQueue(player, getSupportFragmentManager()); return true; case R.id.action_playback_speed: openPlaybackParameterDialog(); return true; case R.id.action_mute: - player.onMuteUnmuteButtonClicked(); + player.toggleMute(); return true; case R.id.action_system_audio: startActivity(new Intent(Settings.ACTION_SOUND_SETTINGS)); @@ -139,11 +153,9 @@ public final class PlayQueueActivity extends AppCompatActivity NavigationHelper.playOnMainPlayer(this, player.getPlayQueue(), true); return true; case R.id.action_switch_popup: - if (PermissionHelper.isPopupEnabled(this)) { + if (PermissionHelper.isPopupEnabledElseAsk(this)) { this.player.setRecovery(); NavigationHelper.playOnPopupPlayer(this, player.getPlayQueue(), true); - } else { - PermissionHelper.showPopupEnablementToast(this); } return true; case R.id.action_switch_background: @@ -151,6 +163,12 @@ public final class PlayQueueActivity extends AppCompatActivity NavigationHelper.playOnBackgroundPlayer(this, player.getPlayQueue(), true); return true; } + + if (item.getGroupId() == MENU_ID_AUDIO_TRACK) { + onAudioTrackClick(item.getItemId()); + return true; + } + return super.onOptionsItemSelected(item); } @@ -165,7 +183,7 @@ public final class PlayQueueActivity extends AppCompatActivity //////////////////////////////////////////////////////////////////////////// private void bind() { - final Intent bindIntent = new Intent(this, MainPlayer.class); + final Intent bindIntent = new Intent(this, PlayerService.class); final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE); if (!success) { unbindService(serviceConnection); @@ -181,10 +199,7 @@ public final class PlayQueueActivity extends AppCompatActivity player.removeActivityListener(this); } - if (player != null && player.getPlayQueueAdapter() != null) { - player.getPlayQueueAdapter().unsetSelectedListener(); - } - queueControlBinding.playQueue.setAdapter(null); + onQueueUpdate(null); if (itemTouchHelper != null) { itemTouchHelper.attachToRecyclerView(null); } @@ -205,17 +220,14 @@ public final class PlayQueueActivity extends AppCompatActivity public void onServiceConnected(final ComponentName name, final IBinder service) { Log.d(TAG, "Player service is connected"); - if (service instanceof PlayerServiceBinder) { - player = ((PlayerServiceBinder) service).getPlayerInstance(); - } else if (service instanceof MainPlayer.LocalBinder) { - player = ((MainPlayer.LocalBinder) service).getPlayer(); + if (service instanceof PlayerService.LocalBinder) { + player = ((PlayerService.LocalBinder) service).getPlayer(); } - if (player == null || player.getPlayQueue() == null - || player.getPlayQueueAdapter() == null || player.exoPlayerIsNull()) { + if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) { unbind(); - finish(); } else { + onQueueUpdate(player.getPlayQueue()); buildComponents(); if (player != null) { player.setActivityListener(PlayQueueActivity.this); @@ -238,7 +250,6 @@ public final class PlayQueueActivity extends AppCompatActivity private void buildQueue() { queueControlBinding.playQueue.setLayoutManager(new LinearLayoutManager(this)); - queueControlBinding.playQueue.setAdapter(player.getPlayQueueAdapter()); queueControlBinding.playQueue.setClickable(true); queueControlBinding.playQueue.setLongClickable(true); queueControlBinding.playQueue.clearOnScrollListeners(); @@ -246,8 +257,6 @@ public final class PlayQueueActivity extends AppCompatActivity itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); itemTouchHelper.attachToRecyclerView(queueControlBinding.playQueue); - - player.getPlayQueueAdapter().setSelectedListener(getOnSelectedListener()); } private void buildMetadata() { @@ -367,7 +376,7 @@ public final class PlayQueueActivity extends AppCompatActivity } if (view.getId() == queueControlBinding.controlRepeat.getId()) { - player.onRepeatClicked(); + player.cycleNextRepeatMode(); } else if (view.getId() == queueControlBinding.controlBackward.getId()) { player.playPrevious(); } else if (view.getId() == queueControlBinding.controlFastRewind.getId()) { @@ -379,7 +388,7 @@ public final class PlayQueueActivity extends AppCompatActivity } else if (view.getId() == queueControlBinding.controlForward.getId()) { player.playNext(); } else if (view.getId() == queueControlBinding.controlShuffle.getId()) { - player.onShuffleClicked(); + player.toggleShuffleModeEnabled(); } else if (view.getId() == queueControlBinding.metadata.getId()) { scrollToSelected(); } else if (view.getId() == queueControlBinding.liveSync.getId()) { @@ -442,7 +451,14 @@ public final class PlayQueueActivity extends AppCompatActivity //////////////////////////////////////////////////////////////////////////// @Override - public void onQueueUpdate(final PlayQueue queue) { + public void onQueueUpdate(@Nullable final PlayQueue queue) { + if (queue == null) { + queueControlBinding.playQueue.setAdapter(null); + } else { + final PlayQueueAdapter adapter = new PlayQueueAdapter(this, queue); + adapter.setSelectedListener(getOnSelectedListener()); + queueControlBinding.playQueue.setAdapter(adapter); + } } @Override @@ -451,7 +467,6 @@ public final class PlayQueueActivity extends AppCompatActivity onStateChanged(state); onPlayModeChanged(repeatMode, shuffled); onPlaybackParameterChanged(parameters); - onMaybePlaybackAdapterChanged(); onMaybeMuteChanged(); } @@ -517,18 +532,19 @@ public final class PlayQueueActivity extends AppCompatActivity //////////////////////////////////////////////////////////////////////////// private void onStateChanged(final int state) { + final ImageButton playPauseButton = queueControlBinding.controlPlayPause; switch (state) { case Player.STATE_PAUSED: - queueControlBinding.controlPlayPause - .setImageResource(R.drawable.ic_play_arrow); + playPauseButton.setImageResource(R.drawable.ic_play_arrow); + playPauseButton.setContentDescription(getString(R.string.play)); break; case Player.STATE_PLAYING: - queueControlBinding.controlPlayPause - .setImageResource(R.drawable.ic_pause); + playPauseButton.setImageResource(R.drawable.ic_pause); + playPauseButton.setContentDescription(getString(R.string.pause)); break; case Player.STATE_COMPLETED: - queueControlBinding.controlPlayPause - .setImageResource(R.drawable.ic_replay); + playPauseButton.setImageResource(R.drawable.ic_replay); + playPauseButton.setContentDescription(getString(R.string.replay)); break; default: break; @@ -571,22 +587,9 @@ public final class PlayQueueActivity extends AppCompatActivity } private void onPlaybackParameterChanged(@Nullable final PlaybackParameters parameters) { - if (parameters != null) { - if (menu != null && player != null) { - final MenuItem item = menu.findItem(R.id.action_playback_speed); - item.setTitle(formatSpeed(parameters.speed)); - } - } - } - - private void onMaybePlaybackAdapterChanged() { - if (player == null) { - return; - } - final PlayQueueAdapter maybeNewAdapter = player.getPlayQueueAdapter(); - if (maybeNewAdapter != null - && queueControlBinding.playQueue.getAdapter() != maybeNewAdapter) { - queueControlBinding.playQueue.setAdapter(maybeNewAdapter); + if (parameters != null && menu != null && player != null) { + final MenuItem item = menu.findItem(R.id.action_playback_speed); + item.setTitle(formatSpeed(parameters.speed)); } } @@ -603,4 +606,71 @@ public final class PlayQueueActivity extends AppCompatActivity item.setIcon(player.isMuted() ? R.drawable.ic_volume_off : R.drawable.ic_volume_up); } } + + @Override + public void onAudioTrackUpdate() { + buildAudioTrackMenu(); + } + + private void buildAudioTrackMenu() { + if (menu == null) { + return; + } + + final MenuItem audioTrackSelector = menu.findItem(R.id.action_audio_track); + final List availableStreams = + Optional.ofNullable(player) + .map(Player::getCurrentMetadata) + .flatMap(MediaItemTag::getMaybeAudioTrack) + .map(MediaItemTag.AudioTrack::getAudioStreams) + .orElse(null); + final Optional selectedAudioStream = Optional.ofNullable(player) + .flatMap(Player::getSelectedAudioStream); + + if (availableStreams == null || availableStreams.size() < 2 + || selectedAudioStream.isEmpty()) { + audioTrackSelector.setVisible(false); + } else { + final SubMenu audioTrackMenu = audioTrackSelector.getSubMenu(); + audioTrackMenu.clear(); + + for (int i = 0; i < availableStreams.size(); i++) { + final AudioStream audioStream = availableStreams.get(i); + audioTrackMenu.add(MENU_ID_AUDIO_TRACK, i, Menu.NONE, + Localization.audioTrackName(this, audioStream)); + } + + final AudioStream s = selectedAudioStream.get(); + final String trackName = Localization.audioTrackName(this, s); + audioTrackSelector.setTitle( + getString(R.string.play_queue_audio_track, trackName)); + + final String shortName = s.getAudioLocale() != null + ? s.getAudioLocale().getLanguage() : trackName; + audioTrackSelector.setTitleCondensed( + shortName.substring(0, Math.min(shortName.length(), 2))); + audioTrackSelector.setVisible(true); + } + } + + /** + * Called when an item from the audio track selector is selected. + * + * @param itemId index of the selected item + */ + private void onAudioTrackClick(final int itemId) { + if (player.getCurrentMetadata() == null) { + return; + } + player.getCurrentMetadata().getMaybeAudioTrack().ifPresent(audioTrack -> { + final List availableStreams = audioTrack.getAudioStreams(); + final int selectedStreamIndex = audioTrack.getSelectedAudioStreamIndex(); + if (selectedStreamIndex == itemId || availableStreams.size() <= itemId) { + return; + } + + final String newAudioTrack = availableStreams.get(itemId).getAudioTrackId(); + player.setAudioTrack(newAudioTrack); + }); + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java index 2c8be5d78..49e72328e 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -24,108 +24,44 @@ import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK_ADJ import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SKIP; import static com.google.android.exoplayer2.Player.DiscontinuityReason; import static com.google.android.exoplayer2.Player.Listener; -import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF; import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; import static com.google.android.exoplayer2.Player.RepeatMode; -import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu; import static org.schabi.newpipe.extractor.ServiceList.YouTube; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; -import static org.schabi.newpipe.ktx.ViewUtils.animate; -import static org.schabi.newpipe.ktx.ViewUtils.animateRotation; -import static org.schabi.newpipe.player.MainPlayer.ACTION_CLOSE; -import static org.schabi.newpipe.player.MainPlayer.ACTION_FAST_FORWARD; -import static org.schabi.newpipe.player.MainPlayer.ACTION_FAST_REWIND; -import static org.schabi.newpipe.player.MainPlayer.ACTION_PLAY_NEXT; -import static org.schabi.newpipe.player.MainPlayer.ACTION_PLAY_PAUSE; -import static org.schabi.newpipe.player.MainPlayer.ACTION_PLAY_PREVIOUS; -import static org.schabi.newpipe.player.MainPlayer.ACTION_RECREATE_NOTIFICATION; -import static org.schabi.newpipe.player.MainPlayer.ACTION_REPEAT; -import static org.schabi.newpipe.player.MainPlayer.ACTION_SHUFFLE; -import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_BACKGROUND; -import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_NONE; -import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_POPUP; -import static org.schabi.newpipe.player.helper.PlayerHelper.buildCloseOverlayLayoutParams; -import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; -import static org.schabi.newpipe.player.helper.PlayerHelper.getMinimizeOnExitAction; -import static org.schabi.newpipe.player.helper.PlayerHelper.getMinimumVideoHeight; -import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; -import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientationLocked; -import static org.schabi.newpipe.player.helper.PlayerHelper.isPlaybackResumeEnabled; import static org.schabi.newpipe.player.helper.PlayerHelper.nextRepeatMode; -import static org.schabi.newpipe.player.helper.PlayerHelper.nextResizeModeAndSaveToPrefs; import static org.schabi.newpipe.player.helper.PlayerHelper.retrievePlaybackParametersFromPrefs; -import static org.schabi.newpipe.player.helper.PlayerHelper.retrievePlayerTypeFromIntent; -import static org.schabi.newpipe.player.helper.PlayerHelper.retrievePopupLayoutParamsFromPrefs; import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences; import static org.schabi.newpipe.player.helper.PlayerHelper.savePlaybackParametersToPrefs; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_FORWARD; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_REWIND; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_NEXT; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PREVIOUS; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_RECREATE_NOTIFICATION; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_REPEAT; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE; import static org.schabi.newpipe.util.ListHelper.getPopupResolutionIndex; import static org.schabi.newpipe.util.ListHelper.getResolutionIndex; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; -import static org.schabi.newpipe.util.Localization.containsCaseInsensitive; import static java.util.concurrent.TimeUnit.MILLISECONDS; -import android.animation.Animator; -import android.animation.AnimatorListenerAdapter; -import android.annotation.SuppressLint; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; -import android.content.res.Resources; -import android.database.ContentObserver; import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Color; -import android.graphics.PorterDuff; -import android.graphics.PorterDuffColorFilter; import android.graphics.drawable.Drawable; import android.media.AudioManager; -import android.net.Uri; -import android.os.Build; -import android.os.Handler; -import android.provider.Settings; -import android.util.DisplayMetrics; import android.util.Log; -import android.util.TypedValue; -import android.view.Gravity; -import android.view.KeyEvent; import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuItem; -import android.view.MotionEvent; -import android.view.Surface; -import android.view.View; -import android.view.ViewGroup; -import android.view.WindowManager; -import android.view.animation.AnticipateInterpolator; -import android.widget.FrameLayout; -import android.widget.ImageButton; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.ProgressBar; -import android.widget.RelativeLayout; -import android.widget.SeekBar; -import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import androidx.appcompat.app.AppCompatActivity; -import androidx.appcompat.content.res.AppCompatResources; -import androidx.appcompat.view.ContextThemeWrapper; -import androidx.appcompat.widget.AppCompatImageButton; -import androidx.appcompat.widget.PopupMenu; -import androidx.collection.ArraySet; -import androidx.core.content.ContextCompat; -import androidx.core.graphics.Insets; -import androidx.core.view.GestureDetectorCompat; -import androidx.core.view.ViewCompat; -import androidx.core.view.WindowInsetsCompat; -import androidx.fragment.app.FragmentManager; +import androidx.core.math.MathUtils; import androidx.preference.PreferenceManager; -import androidx.recyclerview.widget.ItemTouchHelper; -import androidx.recyclerview.widget.RecyclerView; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.DefaultRenderersFactory; @@ -133,91 +69,61 @@ import com.google.android.exoplayer2.ExoPlayer; import com.google.android.exoplayer2.PlaybackException; import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.Player.PositionInfo; -import com.google.android.exoplayer2.RenderersFactory; import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.TracksInfo; +import com.google.android.exoplayer2.Tracks; import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.source.TrackGroup; -import com.google.android.exoplayer2.source.TrackGroupArray; -import com.google.android.exoplayer2.text.Cue; +import com.google.android.exoplayer2.text.CueGroup; import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; import com.google.android.exoplayer2.trackselection.MappingTrackSelector; -import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; -import com.google.android.exoplayer2.ui.CaptionStyleCompat; -import com.google.android.exoplayer2.ui.SubtitleView; import com.google.android.exoplayer2.upstream.DefaultBandwidthMeter; -import com.google.android.exoplayer2.util.Util; import com.google.android.exoplayer2.video.VideoSize; -import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.squareup.picasso.Picasso; import com.squareup.picasso.Target; -import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; -import org.schabi.newpipe.database.stream.model.StreamEntity; 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.Info; -import org.schabi.newpipe.extractor.MediaFormat; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.Image; import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.extractor.stream.StreamSegment; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; import org.schabi.newpipe.fragments.detail.VideoDetailFragment; -import org.schabi.newpipe.info_list.StreamSegmentAdapter; -import org.schabi.newpipe.ktx.AnimationType; -import org.schabi.newpipe.local.dialog.PlaylistDialog; 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; import org.schabi.newpipe.player.helper.AudioReactor; +import org.schabi.newpipe.player.helper.CustomRenderersFactory; import org.schabi.newpipe.player.helper.LoadController; -import org.schabi.newpipe.player.helper.MediaSessionManager; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.helper.PlayerHelper; -import org.schabi.newpipe.player.listeners.view.PlaybackSpeedClickListener; -import org.schabi.newpipe.player.listeners.view.QualityClickListener; import org.schabi.newpipe.player.mediaitem.MediaItemTag; +import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi; +import org.schabi.newpipe.player.notification.NotificationPlayerUi; 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.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.PlayQueueAdapter; import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import org.schabi.newpipe.player.playqueue.PlayQueueItemBuilder; -import org.schabi.newpipe.player.playqueue.PlayQueueItemHolder; -import org.schabi.newpipe.player.playqueue.PlayQueueItemTouchCallback; import org.schabi.newpipe.player.resolver.AudioPlaybackResolver; import org.schabi.newpipe.player.resolver.VideoPlaybackResolver; import org.schabi.newpipe.player.resolver.VideoPlaybackResolver.SourceType; -import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper; -import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHolder; -import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.player.ui.MainPlayerUi; +import org.schabi.newpipe.player.ui.PlayerUi; +import org.schabi.newpipe.player.ui.PlayerUiList; +import org.schabi.newpipe.player.ui.PopupPlayerUi; +import org.schabi.newpipe.player.ui.VideoPlayerUi; +import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.SerializedCache; import org.schabi.newpipe.util.StreamTypeUtil; -import org.schabi.newpipe.util.external_communication.KoreUtils; -import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.views.ExpandableSurfaceView; -import org.schabi.newpipe.views.player.PlayerFastSeekOverlay; -import java.util.ArrayList; -import java.util.Collections; import java.util.List; -import java.util.Objects; import java.util.Optional; -import java.util.stream.Collectors; import java.util.stream.IntStream; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; @@ -226,14 +132,7 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.SerialDisposable; -public final class Player implements - PlaybackListener, - Listener, - SeekBar.OnSeekBarChangeListener, - View.OnClickListener, - PopupMenu.OnMenuItemClickListener, - PopupMenu.OnDismissListener, - View.OnLongClickListener { +public final class Player implements PlaybackListener, Listener { public static final boolean DEBUG = MainActivity.DEBUG; public static final String TAG = Player.class.getSimpleName(); @@ -269,33 +168,31 @@ public final class Player implements public static final int PLAY_PREV_ACTIVATION_LIMIT_MILLIS = 5000; // 5 seconds public static final int PROGRESS_LOOP_INTERVAL_MILLIS = 1000; // 1 second - 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 //////////////////////////////////////////////////////////////////////////*/ - private static final float[] PLAYBACK_SPEEDS = {0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f}; - - private static final int RENDERER_UNAVAILABLE = -1; + public static final int RENDERER_UNAVAILABLE = -1; + private static final String PICASSO_PLAYER_THUMBNAIL_TAG = "PICASSO_PLAYER_THUMBNAIL_TAG"; /*////////////////////////////////////////////////////////////////////////// // Playback //////////////////////////////////////////////////////////////////////////*/ // play queue might be null e.g. while player is starting - @Nullable private PlayQueue playQueue; - private PlayQueueAdapter playQueueAdapter; - private StreamSegmentAdapter segmentAdapter; + @Nullable + private PlayQueue playQueue; - @Nullable private MediaSourceManager playQueueManager; + @Nullable + private MediaSourceManager playQueueManager; - @Nullable private PlayQueueItem currentItem; - @Nullable private MediaItemTag currentMetadata; - @Nullable private Bitmap currentThumbnail; + @Nullable + private PlayQueueItem currentItem; + @Nullable + private MediaItemTag currentMetadata; + @Nullable + private Bitmap currentThumbnail; /*////////////////////////////////////////////////////////////////////////// // Player @@ -303,119 +200,68 @@ public final class Player implements private ExoPlayer simpleExoPlayer; private AudioReactor audioReactor; - private MediaSessionManager mediaSessionManager; - @Nullable private SurfaceHolderCallback surfaceHolderCallback; - @NonNull private final DefaultTrackSelector trackSelector; - @NonNull private final LoadController loadController; - @NonNull private final RenderersFactory renderFactory; + @NonNull + private final DefaultTrackSelector trackSelector; + @NonNull + private final LoadController loadController; + @NonNull + private final DefaultRenderersFactory renderFactory; - @NonNull private final VideoPlaybackResolver videoResolver; - @NonNull private final AudioPlaybackResolver audioResolver; + @NonNull + private final VideoPlaybackResolver videoResolver; + @NonNull + private final AudioPlaybackResolver audioResolver; - private final MainPlayer service; //TODO try to remove and replace everything with context + private final PlayerService service; //TODO try to remove and replace everything with context /*////////////////////////////////////////////////////////////////////////// // Player states //////////////////////////////////////////////////////////////////////////*/ - private PlayerType playerType = PlayerType.VIDEO; + private PlayerType playerType = PlayerType.MAIN; private int currentState = STATE_PREFLIGHT; // audio only mode does not mean that player type is background, but that the player was // minimized to background but will resume automatically to the original player type private boolean isAudioOnly = false; private boolean isPrepared = false; - private boolean wasPlaying = false; - private boolean isFullscreen = false; - private boolean isVerticalVideo = false; - private boolean fragmentIsVisible = false; - - private List availableStreams; - private int selectedStreamIndex; /*////////////////////////////////////////////////////////////////////////// - // Views + // UIs, listeners and disposables //////////////////////////////////////////////////////////////////////////*/ - private PlayerBinding binding; - - private final Handler controlsVisibilityHandler = new Handler(); - - // fullscreen player - private boolean isQueueVisible = false; - private boolean areSegmentsVisible = false; - private ItemTouchHelper itemTouchHelper; - - /*////////////////////////////////////////////////////////////////////////// - // Popup menus ("popup" means that they pop up, not that they belong to the popup player) - //////////////////////////////////////////////////////////////////////////*/ - - private static final int POPUP_MENU_ID_QUALITY = 69; - private static final int POPUP_MENU_ID_PLAYBACK_SPEED = 79; - private static final int POPUP_MENU_ID_CAPTION = 89; - - private boolean isSomePopupMenuVisible = false; - private PopupMenu qualityPopupMenu; - private PopupMenu playbackSpeedPopupMenu; - private PopupMenu captionPopupMenu; - - /*////////////////////////////////////////////////////////////////////////// - // Popup player - //////////////////////////////////////////////////////////////////////////*/ - - private PlayerPopupCloseOverlayBinding closeOverlayBinding; - - private boolean isPopupClosing = false; - - private float screenWidth; - private float screenHeight; - - /*////////////////////////////////////////////////////////////////////////// - // Popup player window manager - //////////////////////////////////////////////////////////////////////////*/ - - public static final int IDLE_WINDOW_FLAGS = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE - | WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM; - public static final int ONGOING_PLAYBACK_WINDOW_FLAGS = IDLE_WINDOW_FLAGS - | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON; - - @Nullable private WindowManager.LayoutParams popupLayoutParams; // null if player is not popup - @Nullable private final WindowManager windowManager; - - /*////////////////////////////////////////////////////////////////////////// - // Gestures - //////////////////////////////////////////////////////////////////////////*/ - - private static final float MAX_GESTURE_LENGTH = 0.75f; - - private int maxGestureLength; // scaled - private GestureDetectorCompat gestureDetector; - private PlayerGestureListener playerGestureListener; - - /*////////////////////////////////////////////////////////////////////////// - // Listeners and disposables - //////////////////////////////////////////////////////////////////////////*/ + @SuppressWarnings({"MemberName", "java:S116"}) // keep the unusual member name + private final PlayerUiList UIs; private BroadcastReceiver broadcastReceiver; private IntentFilter intentFilter; - private PlayerServiceEventListener fragmentListener; - private PlayerEventListener activityListener; - private ContentObserver settingsContentObserver; + @Nullable + private PlayerServiceEventListener fragmentListener = null; + @Nullable + private PlayerEventListener activityListener = null; - @NonNull private final SerialDisposable progressUpdateDisposable = new SerialDisposable(); - @NonNull private final CompositeDisposable databaseUpdateDisposable = new CompositeDisposable(); + @NonNull + private final SerialDisposable progressUpdateDisposable = new SerialDisposable(); + @NonNull + private final CompositeDisposable databaseUpdateDisposable = new CompositeDisposable(); + + // This is the only listener we need for thumbnail loading, since there is always at most only + // one thumbnail being loaded at a time. This field is also here to maintain a strong reference, + // which would otherwise be garbage collected since Picasso holds weak references to targets. + @NonNull + private final Target currentThumbnailTarget; /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ - @NonNull private final Context context; - @NonNull private final SharedPreferences prefs; - @NonNull private final HistoryRecordManager recordManager; - - @NonNull private final SeekbarPreviewThumbnailHolder seekbarPreviewThumbnailHolder = - new SeekbarPreviewThumbnailHolder(); + @NonNull + private final Context context; + @NonNull + private final SharedPreferences prefs; + @NonNull + private final HistoryRecordManager recordManager; /*////////////////////////////////////////////////////////////////////////// @@ -423,7 +269,7 @@ public final class Player implements //////////////////////////////////////////////////////////////////////////*/ //region Constructor - public Player(@NonNull final MainPlayer service) { + public Player(@NonNull final PlayerService service) { this.service = service; context = service; prefs = PreferenceManager.getDefaultSharedPreferences(context); @@ -432,15 +278,33 @@ public final class Player implements setupBroadcastReceiver(); trackSelector = new DefaultTrackSelector(context, PlayerHelper.getQualitySelector()); - final PlayerDataSource dataSource = new PlayerDataSource(context, DownloaderImpl.USER_AGENT, + final PlayerDataSource dataSource = new PlayerDataSource(context, new DefaultBandwidthMeter.Builder(context).build()); loadController = new LoadController(); - renderFactory = new DefaultRenderersFactory(context); + + renderFactory = prefs.getBoolean( + context.getString( + R.string.always_use_exoplayer_set_output_surface_workaround_key), false) + ? new CustomRenderersFactory(context) : new DefaultRenderersFactory(context); + + renderFactory.setEnableDecoderFallback( + prefs.getBoolean( + context.getString( + R.string.use_exoplayer_decoder_fallback_key), false)); videoResolver = new VideoPlaybackResolver(context, dataSource, getQualityResolver()); audioResolver = new AudioPlaybackResolver(context, dataSource); - windowManager = ContextCompat.getSystemService(context, WindowManager.class); + currentThumbnailTarget = getCurrentThumbnailTarget(); + + // The UIs added here should always be present. They will be initialized when the player + // reaches the initialization step. Make sure the media session ui is before the + // notification ui in the UIs list, since the notification depends on the media session in + // PlayerUi#initPlayer(), and UIs.call() guarantees UI order is preserved. + UIs = new PlayerUiList( + new MediaSessionPlayerUi(this), + new NotificationPlayerUi(this) + ); } private VideoPlaybackResolver.QualityResolver getQualityResolver() { @@ -465,234 +329,6 @@ public final class Player implements - /*////////////////////////////////////////////////////////////////////////// - // Setup and initialization - //////////////////////////////////////////////////////////////////////////*/ - //region Setup and initialization - - public void setupFromView(@NonNull final PlayerBinding playerBinding) { - initViews(playerBinding); - if (exoPlayerIsNull()) { - initPlayer(true); - } - initListeners(); - - setupPlayerSeekOverlay(); - } - - private void initViews(@NonNull final PlayerBinding playerBinding) { - binding = playerBinding; - setupSubtitleView(); - - binding.resizeTextView - .setText(PlayerHelper.resizeTypeOf(context, binding.surfaceView.getResizeMode())); - - binding.playbackSeekBar.getThumb() - .setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN)); - binding.playbackSeekBar.getProgressDrawable() - .setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.MULTIPLY)); - - final ContextThemeWrapper themeWrapper = new ContextThemeWrapper(getContext(), - R.style.DarkPopupMenu); - - qualityPopupMenu = new PopupMenu(themeWrapper, binding.qualityTextView); - playbackSpeedPopupMenu = new PopupMenu(context, binding.playbackSpeed); - captionPopupMenu = new PopupMenu(themeWrapper, binding.captionTextView); - - binding.progressBarLoadingPanel.getIndeterminateDrawable() - .setColorFilter(new PorterDuffColorFilter(Color.WHITE, PorterDuff.Mode.MULTIPLY)); - - binding.titleTextView.setSelected(true); - binding.channelTextView.setSelected(true); - - // Prevent hiding of bottom sheet via swipe inside queue - binding.itemsList.setNestedScrollingEnabled(false); - } - - private void initPlayer(final boolean playOnReady) { - if (DEBUG) { - Log.d(TAG, "initPlayer() called with: playOnReady = [" + playOnReady + "]"); - } - - simpleExoPlayer = new ExoPlayer.Builder(context, renderFactory) - .setTrackSelector(trackSelector) - .setLoadControl(loadController) - .build(); - simpleExoPlayer.addListener(this); - simpleExoPlayer.setPlayWhenReady(playOnReady); - simpleExoPlayer.setSeekParameters(PlayerHelper.getSeekParameters(context)); - simpleExoPlayer.setWakeMode(C.WAKE_MODE_NETWORK); - simpleExoPlayer.setHandleAudioBecomingNoisy(true); - - audioReactor = new AudioReactor(context, simpleExoPlayer); - mediaSessionManager = new MediaSessionManager(context, simpleExoPlayer, - new PlayerMediaSession(this)); - - registerBroadcastReceiver(); - - // Setup video view - setupVideoSurface(); - - // enable media tunneling - if (DEBUG && PreferenceManager.getDefaultSharedPreferences(context) - .getBoolean(context.getString(R.string.disable_media_tunneling_key), false)) { - Log.d(TAG, "[" + Util.DEVICE_DEBUG_INFO + "] " - + "media tunneling disabled in debug preferences"); - } else if (DeviceUtils.shouldSupportMediaTunneling()) { - trackSelector.setParameters(trackSelector.buildUponParameters() - .setTunnelingEnabled(true)); - } else if (DEBUG) { - Log.d(TAG, "[" + Util.DEVICE_DEBUG_INFO + "] does not support media tunneling"); - } - } - - private void initListeners() { - binding.qualityTextView.setOnClickListener( - new QualityClickListener(this, qualityPopupMenu)); - binding.playbackSpeed.setOnClickListener( - new PlaybackSpeedClickListener(this, playbackSpeedPopupMenu)); - - binding.playbackSeekBar.setOnSeekBarChangeListener(this); - binding.captionTextView.setOnClickListener(this); - binding.resizeTextView.setOnClickListener(this); - binding.playbackLiveSync.setOnClickListener(this); - - playerGestureListener = new PlayerGestureListener(this, service); - gestureDetector = new GestureDetectorCompat(context, playerGestureListener); - binding.getRoot().setOnTouchListener(playerGestureListener); - - binding.queueButton.setOnClickListener(v -> onQueueClicked()); - binding.segmentsButton.setOnClickListener(v -> onSegmentsClicked()); - binding.repeatButton.setOnClickListener(v -> onRepeatClicked()); - binding.shuffleButton.setOnClickListener(v -> onShuffleClicked()); - binding.addToPlaylistButton.setOnClickListener(v -> { - if (getParentActivity() != null) { - onAddToPlaylistClicked(getParentActivity().getSupportFragmentManager()); - } - }); - - binding.playPauseButton.setOnClickListener(this); - binding.playPreviousButton.setOnClickListener(this); - binding.playNextButton.setOnClickListener(this); - - binding.moreOptionsButton.setOnClickListener(this); - binding.moreOptionsButton.setOnLongClickListener(this); - binding.share.setOnClickListener(this); - binding.share.setOnLongClickListener(this); - binding.fullScreenButton.setOnClickListener(this); - binding.screenRotationButton.setOnClickListener(this); - binding.playWithKodi.setOnClickListener(this); - binding.openInBrowser.setOnClickListener(this); - binding.playerCloseButton.setOnClickListener(this); - binding.switchMute.setOnClickListener(this); - - settingsContentObserver = new ContentObserver(new Handler()) { - @Override - public void onChange(final boolean selfChange) { - setupScreenRotationButton(); - } - }; - context.getContentResolver().registerContentObserver( - Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false, - settingsContentObserver); - binding.getRoot().addOnLayoutChangeListener(this::onLayoutChange); - - ViewCompat.setOnApplyWindowInsetsListener(binding.itemsListPanel, (view, windowInsets) -> { - final Insets cutout = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()); - if (!cutout.equals(Insets.NONE)) { - view.setPadding(cutout.left, cutout.top, cutout.right, cutout.bottom); - } - return windowInsets; - }); - - // PlaybackControlRoot already consumed window insets but we should pass them to - // 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()); - - // If we added padding to the fast seek overlay, too, it would not go under the - // system ui. Instead we apply negative margins equal to the window insets of - // the opposite side, so that the view covers all of the player (overflowing on - // some sides) and its center coincides with the center of other controls. - final RelativeLayout.LayoutParams fastSeekParams = (RelativeLayout.LayoutParams) - binding.fastSeekOverlay.getLayoutParams(); - fastSeekParams.leftMargin = -v.getPaddingRight(); - fastSeekParams.topMargin = -v.getPaddingBottom(); - fastSeekParams.rightMargin = -v.getPaddingLeft(); - fastSeekParams.bottomMargin = -v.getPaddingTop(); - }); - } - - /** - * Initializes the Fast-For/Backward overlay. - */ - private void setupPlayerSeekOverlay() { - binding.fastSeekOverlay - .seekSecondsSupplier(() -> retrieveSeekDurationFromPreferences(this) / 1000) - .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); - } - - @NonNull - @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 - - - /*////////////////////////////////////////////////////////////////////////// // Playback initialization via intent //////////////////////////////////////////////////////////////////////////*/ @@ -711,12 +347,13 @@ public final class Player implements } final PlayerType oldPlayerType = playerType; - playerType = retrievePlayerTypeFromIntent(intent); + playerType = PlayerType.retrieveFromIntent(intent); + initUIsForCurrentPlayerType(); // We need to setup audioOnly before super(), see "sourceOf" isAudioOnly = audioPlayerSelected(); if (intent.hasExtra(PLAYBACK_QUALITY)) { - setPlaybackQuality(intent.getStringExtra(PLAYBACK_QUALITY)); + videoResolver.setPlaybackQuality(intent.getStringExtra(PLAYBACK_QUALITY)); } // Resolve enqueue intents @@ -724,7 +361,7 @@ public final class Player implements playQueue.append(newQueue.getStreams()); return; - // Resolve enqueue next intents + // Resolve enqueue next intents } else if (intent.getBooleanExtra(ENQUEUE_NEXT, false) && playQueue != null) { final int currentIndex = playQueue.getIndex(); playQueue.append(newQueue.getStreams()); @@ -732,16 +369,13 @@ public final class Player implements return; } - // needed for tablets, check the function for a better explanation - directlyOpenFullscreenIfNeeded(); - final PlaybackParameters savedParameters = retrievePlaybackParametersFromPrefs(this); final float playbackSpeed = savedParameters.speed; final float playbackPitch = savedParameters.pitch; final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString( R.string.playback_skip_silence_key), getPlaybackSkipSilence()); - final boolean samePlayQueue = playQueue != null && playQueue.equals(newQueue); + final boolean samePlayQueue = playQueue != null && playQueue.equalStreamsAndIndex(newQueue); final int repeatMode = intent.getIntExtra(REPEAT_MODE, getRepeatMode()); final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true); final boolean isMuted = intent.getBooleanExtra(IS_MUTED, isMuted()); @@ -784,7 +418,7 @@ public final class Player implements simpleExoPlayer.setPlayWhenReady(playWhenReady); } else if (intent.getBooleanExtra(RESUME_PLAYBACK, false) - && isPlaybackResumeEnabled(this) + && DependentPreferenceHelper.getResumePlaybackEnabled(context) && !samePlayQueue && !newQueue.isEmpty() && newQueue.getItem() != null @@ -832,46 +466,39 @@ public final class Player implements reloadPlayQueueManager(); } - setupElementsVisibility(); - setupElementsSize(); - - if (audioPlayerSelected()) { - service.removeViewFromParent(); - } else if (popupPlayerSelected()) { - binding.getRoot().setVisibility(View.VISIBLE); - initPopup(); - initPopupCloseOverlay(); - binding.playPauseButton.requestFocus(); - } else { - binding.getRoot().setVisibility(View.VISIBLE); - initVideoPlayer(); - closeItemsList(); - // Android TV: without it focus will frame the whole player - binding.playPauseButton.requestFocus(); - - // Note: This is for automatically playing (when "Resume playback" is off), see #6179 - if (getPlayWhenReady()) { - play(); - } else { - pause(); - } - } + UIs.call(PlayerUi::setupAfterIntent); NavigationHelper.sendPlayerStartedEvent(context); } - /** - * Open fullscreen on tablets where the option to have the main player start automatically in - * fullscreen mode is on. Rotating the device to landscape is already done in {@link - * VideoDetailFragment#openVideoPlayer(boolean)} when the thumbnail is clicked, and that's - * enough for phones, but not for tablets since the mini player can be also shown in landscape. - */ - private void directlyOpenFullscreenIfNeeded() { - if (fragmentListener != null - && PlayerHelper.isStartMainPlayerFullscreenEnabled(service) - && DeviceUtils.isTablet(service) - && videoPlayerSelected() - && PlayerHelper.globalScreenOrientationLocked(service)) { - fragmentListener.onScreenRotationButtonClicked(); + private void initUIsForCurrentPlayerType() { + if ((UIs.get(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN) + || (UIs.get(PopupPlayerUi.class).isPresent() && playerType == PlayerType.POPUP)) { + // correct UI already in place + return; + } + + // try to reuse binding if possible + final PlayerBinding binding = UIs.get(VideoPlayerUi.class).map(VideoPlayerUi::getBinding) + .orElseGet(() -> { + if (playerType == PlayerType.AUDIO) { + return null; + } else { + return PlayerBinding.inflate(LayoutInflater.from(context)); + } + }); + + switch (playerType) { + case MAIN: + UIs.destroyAll(PopupPlayerUi.class); + UIs.addAndPrepare(new MainPlayerUi(this, binding)); + break; + case POPUP: + UIs.destroyAll(MainPlayerUi.class); + UIs.addAndPrepare(new PopupPlayerUi(this, binding)); + break; + case AUDIO: + UIs.destroyAll(VideoPlayerUi.class); + break; } } @@ -885,23 +512,48 @@ public final class Player implements destroyPlayer(); initPlayer(playOnReady); setRepeatMode(repeatMode); - // #6825 - Ensure that the shuffle-button is in the correct state on the UI - setShuffleButton(binding.shuffleButton, simpleExoPlayer.getShuffleModeEnabled()); setPlaybackParameters(playbackSpeed, playbackPitch, playbackSkipSilence); playQueue = queue; playQueue.init(); reloadPlayQueueManager(); - if (playQueueAdapter != null) { - playQueueAdapter.dispose(); - } - playQueueAdapter = new PlayQueueAdapter(context, playQueue); - segmentAdapter = new StreamSegmentAdapter(getStreamSegmentListener()); + UIs.call(PlayerUi::initPlayback); simpleExoPlayer.setVolume(isMuted ? 0 : 1); notifyQueueUpdateToListeners(); } + + private void initPlayer(final boolean playOnReady) { + if (DEBUG) { + Log.d(TAG, "initPlayer() called with: playOnReady = [" + playOnReady + "]"); + } + + simpleExoPlayer = new ExoPlayer.Builder(context, renderFactory) + .setTrackSelector(trackSelector) + .setLoadControl(loadController) + .setUsePlatformDiagnostics(false) + .build(); + simpleExoPlayer.addListener(this); + simpleExoPlayer.setPlayWhenReady(playOnReady); + simpleExoPlayer.setSeekParameters(PlayerHelper.getSeekParameters(context)); + simpleExoPlayer.setWakeMode(C.WAKE_MODE_NETWORK); + simpleExoPlayer.setHandleAudioBecomingNoisy(true); + + audioReactor = new AudioReactor(context, simpleExoPlayer); + + registerBroadcastReceiver(); + + // Setup UIs + UIs.call(PlayerUi::initPlayer); + + // Disable media tunneling if requested by the user from ExoPlayer settings + if (!PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.disable_media_tunneling_key), false)) { + trackSelector.setParameters(trackSelector.buildUponParameters() + .setTunnelingEnabled(true)); + } + } //endregion @@ -915,8 +567,7 @@ public final class Player implements if (DEBUG) { Log.d(TAG, "destroyPlayer() called"); } - - cleanupVideoSurface(); + UIs.call(PlayerUi::destroyPlayer); if (!exoPlayerIsNull()) { simpleExoPlayer.removeListener(this); @@ -935,32 +586,25 @@ public final class Player implements if (playQueueManager != null) { playQueueManager.dispose(); } - if (mediaSessionManager != null) { - mediaSessionManager.dispose(); - } - - if (playQueueAdapter != null) { - playQueueAdapter.unsetSelectedListener(); - playQueueAdapter.dispose(); - } } public void destroy() { if (DEBUG) { Log.d(TAG, "destroy() called"); } + + saveStreamProgressState(); + setRecovery(); + stopActivityBinding(); + destroyPlayer(); unregisterBroadcastReceiver(); databaseUpdateDisposable.clear(); progressUpdateDisposable.set(null); - PicassoHelper.cancelTag(PicassoHelper.PLAYER_THUMBNAIL_TAG); // cancel thumbnail loading + cancelLoadingCurrentThumbnail(); - if (binding != null) { - binding.endScreen.setImageBitmap(null); - } - - context.getContentResolver().unregisterContentObserver(settingsContentObserver); + UIs.destroyAll(Object.class); // destroy every UI: obviously every UI extends Object } public void setRecovery() { @@ -973,11 +617,11 @@ public final class Player implements final long duration = simpleExoPlayer.getDuration(); // No checks due to https://github.com/TeamNewPipe/NewPipe/pull/7195#issuecomment-962624380 - setRecovery(queuePos, Math.max(0, Math.min(windowPos, duration))); + setRecovery(queuePos, MathUtils.clamp(windowPos, 0, duration)); } private void setRecovery(final int queuePos, final long windowPos) { - if (playQueue.size() <= queuePos) { + if (playQueue == null || playQueue.size() <= queuePos) { return; } @@ -987,7 +631,7 @@ public final class Player implements playQueue.setRecovery(queuePos, windowPos); } - private void reloadPlayQueueManager() { + public void reloadPlayQueueManager() { if (playQueueManager != null) { playQueueManager.dispose(); } @@ -1006,185 +650,11 @@ public final class Player implements service.stopService(); } - public void smoothStopPlayer() { + public void smoothStopForImmediateReusing() { // Pausing would make transition from one stream to a new stream not smooth, so only stop simpleExoPlayer.stop(); - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Player type specific setup - //////////////////////////////////////////////////////////////////////////*/ - //region Player type specific setup - - private void initVideoPlayer() { - // restore last resize mode - setResizeMode(PlayerHelper.retrieveResizeModeFromPrefs(this)); - binding.getRoot().setLayoutParams(new FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.MATCH_PARENT)); - } - - @SuppressLint("RtlHardcoded") - private void initPopup() { - if (DEBUG) { - Log.d(TAG, "initPopup() called"); - } - - // Popup is already added to windowManager - if (popupHasParent()) { - return; - } - - updateScreenSize(); - - popupLayoutParams = retrievePopupLayoutParamsFromPrefs(this); - binding.surfaceView.setHeights(popupLayoutParams.height, popupLayoutParams.height); - - checkPopupPositionBounds(); - - binding.loadingPanel.setMinimumWidth(popupLayoutParams.width); - binding.loadingPanel.setMinimumHeight(popupLayoutParams.height); - - service.removeViewFromParent(); - Objects.requireNonNull(windowManager).addView(binding.getRoot(), popupLayoutParams); - - // Popup doesn't have aspectRatio selector, using FIT automatically - setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FIT); - } - - @SuppressLint("RtlHardcoded") - private void initPopupCloseOverlay() { - if (DEBUG) { - Log.d(TAG, "initPopupCloseOverlay() called"); - } - - // closeOverlayView is already added to windowManager - if (closeOverlayBinding != null) { - return; - } - - closeOverlayBinding = PlayerPopupCloseOverlayBinding.inflate(LayoutInflater.from(context)); - - final WindowManager.LayoutParams closeOverlayLayoutParams = buildCloseOverlayLayoutParams(); - closeOverlayBinding.closeButton.setVisibility(View.GONE); - Objects.requireNonNull(windowManager).addView( - closeOverlayBinding.getRoot(), closeOverlayLayoutParams); - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Elements visibility and size: popup and main players have different look - //////////////////////////////////////////////////////////////////////////*/ - //region Elements visibility and size: popup and main players have different look - - /** - * This method ensures that popup and main players have different look. - * We use one layout for both players and need to decide what to show and what to hide. - * Additional measuring should be done inside {@link #setupElementsSize}. - */ - private void setupElementsVisibility() { - if (popupPlayerSelected()) { - binding.fullScreenButton.setVisibility(View.VISIBLE); - binding.screenRotationButton.setVisibility(View.GONE); - binding.resizeTextView.setVisibility(View.GONE); - binding.getRoot().findViewById(R.id.metadataView).setVisibility(View.GONE); - binding.queueButton.setVisibility(View.GONE); - binding.segmentsButton.setVisibility(View.GONE); - binding.moreOptionsButton.setVisibility(View.GONE); - binding.topControls.setOrientation(LinearLayout.HORIZONTAL); - binding.primaryControls.getLayoutParams().width - = LinearLayout.LayoutParams.WRAP_CONTENT; - binding.secondaryControls.setAlpha(1.0f); - binding.secondaryControls.setVisibility(View.VISIBLE); - binding.secondaryControls.setTranslationY(0); - binding.share.setVisibility(View.GONE); - binding.playWithKodi.setVisibility(View.GONE); - binding.openInBrowser.setVisibility(View.GONE); - binding.switchMute.setVisibility(View.GONE); - binding.playerCloseButton.setVisibility(View.GONE); - binding.topControls.bringToFront(); - binding.topControls.setClickable(false); - binding.topControls.setFocusable(false); - binding.bottomControls.bringToFront(); - closeItemsList(); - } else if (videoPlayerSelected()) { - binding.fullScreenButton.setVisibility(View.GONE); - setupScreenRotationButton(); - binding.resizeTextView.setVisibility(View.VISIBLE); - binding.getRoot().findViewById(R.id.metadataView).setVisibility(View.VISIBLE); - binding.moreOptionsButton.setVisibility(View.VISIBLE); - binding.topControls.setOrientation(LinearLayout.VERTICAL); - binding.primaryControls.getLayoutParams().width - = LinearLayout.LayoutParams.MATCH_PARENT; - binding.secondaryControls.setVisibility(View.INVISIBLE); - binding.moreOptionsButton.setImageDrawable(AppCompatResources.getDrawable(context, - R.drawable.ic_expand_more)); - binding.share.setVisibility(View.VISIBLE); - binding.openInBrowser.setVisibility(View.VISIBLE); - binding.switchMute.setVisibility(View.VISIBLE); - binding.playerCloseButton.setVisibility(isFullscreen ? View.GONE : View.VISIBLE); - // Top controls have a large minHeight which is allows to drag the player - // down in fullscreen mode (just larger area to make easy to locate by finger) - binding.topControls.setClickable(true); - binding.topControls.setFocusable(true); - } - showHideKodiButton(); - - if (isFullscreen) { - binding.titleTextView.setVisibility(View.VISIBLE); - binding.channelTextView.setVisibility(View.VISIBLE); - } else { - binding.titleTextView.setVisibility(View.GONE); - binding.channelTextView.setVisibility(View.GONE); - } - setMuteButton(binding.switchMute, isMuted()); - - animateRotation(binding.moreOptionsButton, DEFAULT_CONTROLS_DURATION, 0); - } - - /** - * Changes padding, size of elements based on player selected right now. - * Popup player has small padding in comparison with the main player - */ - private void setupElementsSize() { - final Resources res = context.getResources(); - final int buttonsMinWidth; - final int playerTopPad; - final int controlsPad; - final int buttonsPad; - - if (popupPlayerSelected()) { - buttonsMinWidth = 0; - playerTopPad = 0; - controlsPad = res.getDimensionPixelSize(R.dimen.player_popup_controls_padding); - buttonsPad = res.getDimensionPixelSize(R.dimen.player_popup_buttons_padding); - } else if (videoPlayerSelected()) { - buttonsMinWidth = res.getDimensionPixelSize(R.dimen.player_main_buttons_min_width); - playerTopPad = res.getDimensionPixelSize(R.dimen.player_main_top_padding); - controlsPad = res.getDimensionPixelSize(R.dimen.player_main_controls_padding); - buttonsPad = res.getDimensionPixelSize(R.dimen.player_main_buttons_padding); - } else { - return; - } - - binding.topControls.setPaddingRelative(controlsPad, playerTopPad, controlsPad, 0); - binding.bottomControls.setPaddingRelative(controlsPad, 0, controlsPad, 0); - binding.qualityTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad); - binding.playbackSpeed.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad); - binding.playbackSpeed.setMinimumWidth(buttonsMinWidth); - binding.captionTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad); - } - - private void showHideKodiButton() { - // show kodi button if it supports the current service and it is enabled in settings - binding.playWithKodi.setVisibility(videoPlayerSelected() - && playQueue != null && playQueue.getItem() != null - && KoreUtils.shouldShowPlayWithKodi(context, playQueue.getItem().getServiceId()) - ? View.VISIBLE : View.GONE); + setRecovery(); + UIs.call(PlayerUi::smoothStopForImmediateReusing); } //endregion @@ -1195,6 +665,12 @@ public final class Player implements //////////////////////////////////////////////////////////////////////////*/ //region Broadcast receiver + /** + * This function prepares the broadcast receiver and is called only in the constructor. + * Therefore if you want any PlayerUi to receive a broadcast action, you should add it here, + * even if that player ui might never be added to the player. In that case the received + * broadcast would not do anything. + */ private void setupBroadcastReceiver() { if (DEBUG) { Log.d(TAG, "setupBroadcastReceiver() called"); @@ -1247,11 +723,6 @@ public final class Player implements break; case ACTION_PLAY_PAUSE: playPause(); - if (!fragmentIsVisible) { - // Ensure that we have audio-only stream playing when a user - // started to play from notification's play button from outside of the app - onFragmentStopped(); - } break; case ACTION_PLAY_PREVIOUS: playPrevious(); @@ -1266,62 +737,20 @@ public final class Player implements fastForward(); break; case ACTION_REPEAT: - onRepeatClicked(); + cycleNextRepeatMode(); break; case ACTION_SHUFFLE: - onShuffleClicked(); - break; - case ACTION_RECREATE_NOTIFICATION: - NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, true); - break; - case VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED: - fragmentIsVisible = true; - useVideoSource(true); - break; - case VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED: - fragmentIsVisible = false; - onFragmentStopped(); + toggleShuffleModeEnabled(); break; case Intent.ACTION_CONFIGURATION_CHANGED: assureCorrectAppLanguage(service); if (DEBUG) { - Log.d(TAG, "onConfigurationChanged() called"); + Log.d(TAG, "ACTION_CONFIGURATION_CHANGED received"); } - if (popupPlayerSelected()) { - updateScreenSize(); - changePopupSize(popupLayoutParams.width); - checkPopupPositionBounds(); - } - // Close it because when changing orientation from portrait - // (in fullscreen mode) the size of queue layout can be larger than the screen size - closeItemsList(); - // When the orientation changed, the screen height might be smaller. - // If the end screen thumbnail is not re-scaled, - // it can be larger than the current screen height - // and thus enlarging the whole player. - // This causes the seekbar to be ouf the visible area. - updateEndScreenThumbnail(); - break; - case Intent.ACTION_SCREEN_ON: - // Interrupt playback only when screen turns on - // and user is watching video in popup player. - // Same actions for video player will be handled in ACTION_VIDEO_FRAGMENT_RESUMED - if (popupPlayerSelected() && (isPlaying() || isLoading())) { - useVideoSource(true); - } - break; - case Intent.ACTION_SCREEN_OFF: - // Interrupt playback only when screen turns off with popup player working - if (popupPlayerSelected() && (isPlaying() || isLoading())) { - useVideoSource(false); - } - break; - case Intent.ACTION_HEADSET_PLUG: //FIXME - /*notificationManager.cancel(NOTIFICATION_ID); - mediaSessionManager.dispose(); - mediaSessionManager.enable(getBaseContext(), basePlayerImpl.simpleExoPlayer);*/ break; } + + UIs.call(playerUi -> playerUi.onBroadcastReceived(intent)); } private void registerBroadcastReceiver() { @@ -1347,295 +776,72 @@ public final class Player implements //////////////////////////////////////////////////////////////////////////*/ //region Thumbnail loading - private void initThumbnail(final String url) { - if (DEBUG) { - Log.d(TAG, "Thumbnail - initThumbnail() called with url = [" - + (url == null ? "null" : url) + "]"); - } - if (isNullOrEmpty(url)) { - return; - } - - // scale down the notification thumbnail for performance - PicassoHelper.loadScaledDownThumbnail(context, url).into(new Target() { + private Target getCurrentThumbnailTarget() { + // a Picasso target is just a listener for thumbnail loading events + return new Target() { @Override public void onBitmapLoaded(final Bitmap bitmap, final Picasso.LoadedFrom from) { if (DEBUG) { - Log.d(TAG, "Thumbnail - onLoadingComplete() called with: url = [" + url - + "], " + "loadedImage = [" + bitmap + " -> " + bitmap.getWidth() + "x" - + bitmap.getHeight() + "], from = [" + from + "]"); + Log.d(TAG, "Thumbnail - onBitmapLoaded() called with: bitmap = [" + bitmap + + " -> " + bitmap.getWidth() + "x" + bitmap.getHeight() + "], from = [" + + from + "]"); } - - currentThumbnail = bitmap; - NotificationUtil.getInstance() - .createNotificationIfNeededAndUpdate(Player.this, false); - // there is a new thumbnail, so changed the end screen thumbnail, too. - updateEndScreenThumbnail(); + // there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too. + onThumbnailLoaded(bitmap); } @Override public void onBitmapFailed(final Exception e, final Drawable errorDrawable) { - Log.e(TAG, "Thumbnail - onBitmapFailed() called with: url = [" + url + "]", e); - currentThumbnail = null; - NotificationUtil.getInstance() - .createNotificationIfNeededAndUpdate(Player.this, false); + Log.e(TAG, "Thumbnail - onBitmapFailed() called", e); + // there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too. + onThumbnailLoaded(null); } @Override public void onPrepareLoad(final Drawable placeHolderDrawable) { if (DEBUG) { - Log.d(TAG, "Thumbnail - onLoadingStarted() called with: url = [" + url + "]"); + Log.d(TAG, "Thumbnail - onPrepareLoad() called"); } } - }); + }; } - /** - * Scale the player audio / end screen thumbnail down if necessary. - *

- * This is necessary when the thumbnail's height is larger than the device's height - * and thus is enlarging the player's height - * causing the bottom playback controls to be out of the visible screen. - *

- */ - public void updateEndScreenThumbnail() { - if (currentThumbnail == null) { + private void loadCurrentThumbnail(final List thumbnails) { + if (DEBUG) { + Log.d(TAG, "Thumbnail - loadCurrentThumbnail() called with thumbnails = [" + + thumbnails.size() + "]"); + } + + // first cancel any previous loading + cancelLoadingCurrentThumbnail(); + + // Unset currentThumbnail, since it is now outdated. This ensures it is not used in media + // session metadata while the new thumbnail is being loaded by Picasso. + onThumbnailLoaded(null); + if (thumbnails.isEmpty()) { return; } - final float endScreenHeight = calculateMaxEndScreenThumbnailHeight(); - - final Bitmap endScreenBitmap = Bitmap.createScaledBitmap( - currentThumbnail, - (int) (currentThumbnail.getWidth() - / (currentThumbnail.getHeight() / endScreenHeight)), - (int) endScreenHeight, - true); - - if (DEBUG) { - Log.d(TAG, "Thumbnail - updateEndScreenThumbnail() called with: " - + "currentThumbnail = [" + currentThumbnail + "], " - + currentThumbnail.getWidth() + "x" + currentThumbnail.getHeight() - + ", scaled end screen height = " + endScreenHeight - + ", scaled end screen width = " + endScreenBitmap.getWidth()); - } - - binding.endScreen.setImageBitmap(endScreenBitmap); + // scale down the notification thumbnail for performance + PicassoHelper.loadScaledDownThumbnail(context, thumbnails) + .tag(PICASSO_PLAYER_THUMBNAIL_TAG) + .into(currentThumbnailTarget); } - /** - * Calculate the maximum allowed height for the {@link R.id.endScreen} - * to prevent it from enlarging the player. - *

- * The calculating follows these rules: - *

    - *
  • - * Show at least stream title and content creator on TVs and tablets - * when in landscape (always the case for TVs) and not in fullscreen mode. - * This requires to have at least 85dp free space for {@link R.id.detail_root} - * and additional space for the stream title text size - * ({@link R.id.detail_title_root_layout}). - * The text size is 15sp on tablets and 16sp on TVs, - * see {@link R.id.titleTextView}. - *
  • - *
  • - * Otherwise, the max thumbnail height is the screen height. - *
  • - *
- * - * @return the maximum height for the end screen thumbnail - */ - private float calculateMaxEndScreenThumbnailHeight() { - // ensure that screenHeight is initialized and thus not 0 - updateScreenSize(); - - if (DeviceUtils.isTv(context) && !isFullscreen) { - final int videoInfoHeight = - DeviceUtils.dpToPx(85, context) + DeviceUtils.spToPx(16, context); - return Math.min(currentThumbnail.getHeight(), screenHeight - videoInfoHeight); - } else if (DeviceUtils.isTablet(context) && service.isLandscape() && !isFullscreen) { - final int videoInfoHeight = - DeviceUtils.dpToPx(85, context) + DeviceUtils.spToPx(15, context); - return Math.min(currentThumbnail.getHeight(), screenHeight - videoInfoHeight); - } else { // fullscreen player: max height is the device height - return Math.min(currentThumbnail.getHeight(), screenHeight); - } - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Popup player utils - //////////////////////////////////////////////////////////////////////////*/ - //region Popup player utils - - /** - * Check if {@link #popupLayoutParams}' position is within a arbitrary boundary - * that goes from (0, 0) to (screenWidth, screenHeight). - *

- * If it's out of these boundaries, {@link #popupLayoutParams}' position is changed - * and {@code true} is returned to represent this change. - *

- */ - public void checkPopupPositionBounds() { - if (DEBUG) { - Log.d(TAG, "checkPopupPositionBounds() called with: " - + "screenWidth = [" + screenWidth + "], " - + "screenHeight = [" + screenHeight + "]"); - } - if (popupLayoutParams == null) { - return; - } - - if (popupLayoutParams.x < 0) { - popupLayoutParams.x = 0; - } else if (popupLayoutParams.x > screenWidth - popupLayoutParams.width) { - popupLayoutParams.x = (int) (screenWidth - popupLayoutParams.width); - } - - if (popupLayoutParams.y < 0) { - popupLayoutParams.y = 0; - } else if (popupLayoutParams.y > screenHeight - popupLayoutParams.height) { - popupLayoutParams.y = (int) (screenHeight - popupLayoutParams.height); - } + private void cancelLoadingCurrentThumbnail() { + // cancel the Picasso job associated with the player thumbnail, if any + PicassoHelper.cancelTag(PICASSO_PLAYER_THUMBNAIL_TAG); } - public void updateScreenSize() { - if (windowManager != null) { - final DisplayMetrics metrics = new DisplayMetrics(); - windowManager.getDefaultDisplay().getMetrics(metrics); - - screenWidth = metrics.widthPixels; - screenHeight = metrics.heightPixels; - if (DEBUG) { - Log.d(TAG, "updateScreenSize() called: screenWidth = [" - + screenWidth + "], screenHeight = [" + screenHeight + "]"); - } + private void onThumbnailLoaded(@Nullable final Bitmap bitmap) { + // Avoid useless thumbnail updates, if the thumbnail has not actually changed. Based on the + // thumbnail loading code, this if would be skipped only when both bitmaps are `null`, since + // onThumbnailLoaded won't be called twice with the same nonnull bitmap by Picasso's target. + if (currentThumbnail != bitmap) { + currentThumbnail = bitmap; + UIs.call(playerUi -> playerUi.onThumbnailLoaded(bitmap)); } } - - /** - * Changes the size of the popup based on the width. - * @param width the new width, height is calculated with - * {@link PlayerHelper#getMinimumVideoHeight(float)} - */ - public void changePopupSize(final int width) { - if (DEBUG) { - Log.d(TAG, "changePopupSize() called with: width = [" + width + "]"); - } - - if (anyPopupViewIsNull()) { - return; - } - - final float minimumWidth = context.getResources().getDimension(R.dimen.popup_minimum_width); - final int actualWidth = (int) (width > screenWidth ? screenWidth - : (width < minimumWidth ? minimumWidth : width)); - final int actualHeight = (int) getMinimumVideoHeight(width); - if (DEBUG) { - Log.d(TAG, "updatePopupSize() updated values:" - + " width = [" + actualWidth + "], height = [" + actualHeight + "]"); - } - - popupLayoutParams.width = actualWidth; - popupLayoutParams.height = actualHeight; - binding.surfaceView.setHeights(popupLayoutParams.height, popupLayoutParams.height); - Objects.requireNonNull(windowManager) - .updateViewLayout(binding.getRoot(), popupLayoutParams); - } - - private void changePopupWindowFlags(final int flags) { - if (DEBUG) { - Log.d(TAG, "changePopupWindowFlags() called with: flags = [" + flags + "]"); - } - - if (!anyPopupViewIsNull()) { - popupLayoutParams.flags = flags; - Objects.requireNonNull(windowManager) - .updateViewLayout(binding.getRoot(), popupLayoutParams); - } - } - - public void closePopup() { - if (DEBUG) { - Log.d(TAG, "closePopup() called, isPopupClosing = " + isPopupClosing); - } - if (isPopupClosing) { - return; - } - isPopupClosing = true; - - saveStreamProgressState(); - Objects.requireNonNull(windowManager).removeView(binding.getRoot()); - - animatePopupOverlayAndFinishService(); - } - - public void removePopupFromView() { - if (windowManager != null) { - // wrap in try-catch since it could sometimes generate errors randomly - try { - if (popupHasParent()) { - windowManager.removeView(binding.getRoot()); - } - } catch (final IllegalArgumentException e) { - Log.w(TAG, "Failed to remove popup from window manager", e); - } - - try { - final boolean closeOverlayHasParent = closeOverlayBinding != null - && closeOverlayBinding.getRoot().getParent() != null; - if (closeOverlayHasParent) { - windowManager.removeView(closeOverlayBinding.getRoot()); - } - } catch (final IllegalArgumentException e) { - Log.w(TAG, "Failed to remove popup overlay from window manager", e); - } - } - } - - private void animatePopupOverlayAndFinishService() { - final int targetTranslationY = - (int) (closeOverlayBinding.closeButton.getRootView().getHeight() - - closeOverlayBinding.closeButton.getY()); - - closeOverlayBinding.closeButton.animate().setListener(null).cancel(); - closeOverlayBinding.closeButton.animate() - .setInterpolator(new AnticipateInterpolator()) - .translationY(targetTranslationY) - .setDuration(400) - .setListener(new AnimatorListenerAdapter() { - @Override - public void onAnimationCancel(final Animator animation) { - end(); - } - - @Override - public void onAnimationEnd(final Animator animation) { - end(); - } - - private void end() { - Objects.requireNonNull(windowManager) - .removeView(closeOverlayBinding.getRoot()); - closeOverlayBinding = null; - service.stopService(); - } - }).start(); - } - - private boolean popupHasParent() { - return binding != null - && binding.getRoot().getLayoutParams() instanceof WindowManager.LayoutParams - && binding.getRoot().getParent() != null; - } - - private boolean anyPopupViewIsNull() { - // TODO understand why checking getParentActivity() != null - return popupLayoutParams == null || windowManager == null - || getParentActivity() != null || binding.getRoot().getParent() == null; - } //endregion @@ -1649,7 +855,7 @@ public final class Player implements return getPlaybackParameters().speed; } - private void setPlaybackSpeed(final float speed) { + public void setPlaybackSpeed(final float speed) { setPlaybackParameters(speed, getPlaybackPitch(), getPlaybackSkipSilence()); } @@ -1698,40 +904,13 @@ public final class Player implements private void onUpdateProgress(final int currentProgress, final int duration, final int bufferPercent) { - if (!isPrepared) { - return; - } - - if (duration != binding.playbackSeekBar.getMax()) { - setVideoDurationToControls(duration); - } - if (currentState != STATE_PAUSED) { - updatePlayBackElementsCurrentDuration(currentProgress); - } - if (simpleExoPlayer.isLoading() || bufferPercent > 90) { - binding.playbackSeekBar.setSecondaryProgress( - (int) (binding.playbackSeekBar.getMax() * ((float) bufferPercent / 100))); - } - if (DEBUG && bufferPercent % 20 == 0) { //Limit log - Log.d(TAG, "notifyProgressUpdateToListeners() called with: " - + "isVisible = " + isControlsVisible() + ", " - + "currentProgress = [" + currentProgress + "], " - + "duration = [" + duration + "], bufferPercent = [" + bufferPercent + "]"); - } - binding.playbackLiveSync.setClickable(!isLiveEdge()); - - notifyProgressUpdateToListeners(currentProgress, duration, bufferPercent); - - if (areSegmentsVisible) { - segmentAdapter.selectSegmentAt(getNearestStreamSegmentPosition(currentProgress)); - } - - if (isQueueVisible) { - updateQueueTime(currentProgress); + if (isPrepared) { + UIs.call(ui -> ui.onUpdateProgress(currentProgress, duration, bufferPercent)); + notifyProgressUpdateToListeners(currentProgress, duration, bufferPercent); } } - private void startProgressLoop() { + public void startProgressLoop() { progressUpdateDisposable.set(getProgressUpdateDisposable()); } @@ -1739,270 +918,30 @@ public final class Player implements progressUpdateDisposable.set(null); } - private boolean isProgressLoopRunning() { + public boolean isProgressLoopRunning() { return progressUpdateDisposable.get() != null; } - private void triggerProgressUpdate() { + public void triggerProgressUpdate() { if (exoPlayerIsNull()) { return; } - // Use duration of currentItem for non-live streams, - // because HLS streams are fragmented - // and thus the whole duration is not available to the player - // TODO: revert #6307 when introducing proper HLS support - final int duration; - if (currentItem != null - && !StreamTypeUtil.isLiveStream(currentItem.getStreamType()) - ) { - // convert seconds to milliseconds - duration = (int) (currentItem.getDuration() * 1000); - } else { - duration = (int) simpleExoPlayer.getDuration(); - } - onUpdateProgress( - Math.max((int) simpleExoPlayer.getCurrentPosition(), 0), - duration, - simpleExoPlayer.getBufferedPercentage() - ); + + onUpdateProgress(Math.max((int) simpleExoPlayer.getCurrentPosition(), 0), + (int) simpleExoPlayer.getDuration(), simpleExoPlayer.getBufferedPercentage()); } private Disposable getProgressUpdateDisposable() { return Observable.interval(PROGRESS_LOOP_INTERVAL_MILLIS, MILLISECONDS, - AndroidSchedulers.mainThread()) + AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(ignored -> triggerProgressUpdate(), error -> Log.e(TAG, "Progress update failure: ", error)); } - @Override // seekbar listener - public void onProgressChanged(final SeekBar seekBar, final int progress, - final boolean fromUser) { - // Currently we don't need method execution when fromUser is false - if (!fromUser) { - return; - } - if (DEBUG) { - Log.d(TAG, "onProgressChanged() called with: " - + "seekBar = [" + seekBar + "], progress = [" + progress + "]"); - } - - binding.currentDisplaySeek.setText(getTimeString(progress)); - - // Seekbar Preview Thumbnail - SeekbarPreviewThumbnailHelper - .tryResizeAndSetSeekbarPreviewThumbnail( - getContext(), - seekbarPreviewThumbnailHolder.getBitmapAt(progress), - binding.currentSeekbarPreviewThumbnail, - binding.subtitleView::getWidth); - - adjustSeekbarPreviewContainer(); - } - - private void adjustSeekbarPreviewContainer() { - try { - // Should only be required when an error occurred before - // and the layout was positioned in the center - binding.bottomSeekbarPreviewLayout.setGravity(Gravity.NO_GRAVITY); - - // Calculate the current left position of seekbar progress in px - // More info: https://stackoverflow.com/q/20493577 - final int currentSeekbarLeft = - binding.playbackSeekBar.getLeft() - + binding.playbackSeekBar.getPaddingLeft() - + binding.playbackSeekBar.getThumb().getBounds().left; - - // Calculate the (unchecked) left position of the container - final int uncheckedContainerLeft = - currentSeekbarLeft - (binding.seekbarPreviewContainer.getWidth() / 2); - - // Fix the position so it's within the boundaries - final int checkedContainerLeft = - Math.max( - Math.min( - uncheckedContainerLeft, - // Max left - binding.playbackWindowRoot.getWidth() - - binding.seekbarPreviewContainer.getWidth() - ), - 0 // Min left - ); - - // See also: https://stackoverflow.com/a/23249734 - final LinearLayout.LayoutParams params = - new LinearLayout.LayoutParams( - binding.seekbarPreviewContainer.getLayoutParams()); - params.setMarginStart(checkedContainerLeft); - binding.seekbarPreviewContainer.setLayoutParams(params); - } catch (final Exception ex) { - Log.e(TAG, "Failed to adjust seekbarPreviewContainer", ex); - // Fallback - position in the middle - binding.bottomSeekbarPreviewLayout.setGravity(Gravity.CENTER); - } - } - - @Override // seekbar listener - public void onStartTrackingTouch(final SeekBar seekBar) { - if (DEBUG) { - Log.d(TAG, "onStartTrackingTouch() called with: seekBar = [" + seekBar + "]"); - } - if (currentState != STATE_PAUSED_SEEK) { - changeState(STATE_PAUSED_SEEK); - } - - saveWasPlaying(); - if (isPlaying()) { - simpleExoPlayer.pause(); - } - - showControls(0); - animate(binding.currentDisplaySeek, true, DEFAULT_CONTROLS_DURATION, - AnimationType.SCALE_AND_ALPHA); - animate(binding.currentSeekbarPreviewThumbnail, true, DEFAULT_CONTROLS_DURATION, - AnimationType.SCALE_AND_ALPHA); - } - - @Override // seekbar listener - public void onStopTrackingTouch(final SeekBar seekBar) { - if (DEBUG) { - Log.d(TAG, "onStopTrackingTouch() called with: seekBar = [" + seekBar + "]"); - } - - seekTo(seekBar.getProgress()); - if (wasPlaying || simpleExoPlayer.getDuration() == seekBar.getProgress()) { - simpleExoPlayer.play(); - } - - binding.playbackCurrentTime.setText(getTimeString(seekBar.getProgress())); - animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA); - animate(binding.currentSeekbarPreviewThumbnail, false, 200, AnimationType.SCALE_AND_ALPHA); - - if (currentState == STATE_PAUSED_SEEK) { - changeState(STATE_BUFFERING); - } - if (!isProgressLoopRunning()) { - startProgressLoop(); - } - if (wasPlaying) { - showControlsThenHide(); - } - } - - public void saveWasPlaying() { - this.wasPlaying = getPlayWhenReady(); - } //endregion - - /*////////////////////////////////////////////////////////////////////////// - // Controls showing / hiding - //////////////////////////////////////////////////////////////////////////*/ - //region Controls showing / hiding - - public boolean isControlsVisible() { - return binding != null && binding.playbackControlRoot.getVisibility() == View.VISIBLE; - } - - public void showControlsThenHide() { - if (DEBUG) { - Log.d(TAG, "showControlsThenHide() called"); - } - showOrHideButtons(); - showSystemUIPartially(); - - final int hideTime = binding.playbackControlRoot.isInTouchMode() - ? DEFAULT_CONTROLS_HIDE_TIME - : DPAD_CONTROLS_HIDE_TIME; - - showHideShadow(true, DEFAULT_CONTROLS_DURATION); - animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION, - AnimationType.ALPHA, 0, () -> hideControls(DEFAULT_CONTROLS_DURATION, hideTime)); - } - - public void showControls(final long duration) { - if (DEBUG) { - Log.d(TAG, "showControls() called"); - } - showOrHideButtons(); - showSystemUIPartially(); - controlsVisibilityHandler.removeCallbacksAndMessages(null); - showHideShadow(true, duration); - animate(binding.playbackControlRoot, true, duration); - } - - public void hideControls(final long duration, final long delay) { - if (DEBUG) { - Log.d(TAG, "hideControls() called with: duration = [" + duration - + "], delay = [" + delay + "]"); - } - - showOrHideButtons(); - - controlsVisibilityHandler.removeCallbacksAndMessages(null); - controlsVisibilityHandler.postDelayed(() -> { - showHideShadow(false, duration); - animate(binding.playbackControlRoot, false, duration, AnimationType.ALPHA, - 0, this::hideSystemUIIfNeeded); - }, delay); - } - - public 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); - } - - private void showOrHideButtons() { - if (playQueue == null) { - return; - } - - final boolean showPrev = playQueue.getIndex() != 0; - final boolean showNext = playQueue.getIndex() + 1 != playQueue.getStreams().size(); - final boolean showQueue = playQueue.getStreams().size() > 1 && !popupPlayerSelected(); - /* only when stream has segments and is not playing in popup player */ - final boolean showSegment = !popupPlayerSelected() - && !getCurrentStreamInfo() - .map(StreamInfo::getStreamSegments) - .map(List::isEmpty) - .orElse(/*no stream info=*/true); - - binding.playPreviousButton.setVisibility(showPrev ? View.VISIBLE : View.INVISIBLE); - binding.playPreviousButton.setAlpha(showPrev ? 1.0f : 0.0f); - binding.playNextButton.setVisibility(showNext ? View.VISIBLE : View.INVISIBLE); - binding.playNextButton.setAlpha(showNext ? 1.0f : 0.0f); - binding.queueButton.setVisibility(showQueue ? View.VISIBLE : View.GONE); - binding.queueButton.setAlpha(showQueue ? 1.0f : 0.0f); - binding.segmentsButton.setVisibility(showSegment ? View.VISIBLE : View.GONE); - binding.segmentsButton.setAlpha(showSegment ? 1.0f : 0.0f); - } - - private void showSystemUIPartially() { - final AppCompatActivity activity = getParentActivity(); - if (isFullscreen && activity != null) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - activity.getWindow().setStatusBarColor(Color.TRANSPARENT); - activity.getWindow().setNavigationBarColor(Color.TRANSPARENT); - } - final int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION; - activity.getWindow().getDecorView().setSystemUiVisibility(visibility); - activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); - } - } - - private void hideSystemUIIfNeeded() { - if (fragmentListener != null) { - fragmentListener.hideSystemUiIfNeeded(); - } - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// // Playback states //////////////////////////////////////////////////////////////////////////*/ @@ -2031,7 +970,7 @@ public final class Player implements private void updatePlaybackState(final boolean playWhenReady, final int playbackState) { if (DEBUG) { - Log.d(TAG, "ExoPlayer - onPlayerStateChanged() called with: " + Log.d(TAG, "ExoPlayer - updatePlaybackState() called with: " + "playWhenReady = [" + playWhenReady + "], " + "playbackState = [" + playbackState + "]"); } @@ -2142,11 +1081,9 @@ public final class Player implements Log.d(TAG, "onPrepared() called with: playWhenReady = [" + playWhenReady + "]"); } - setVideoDurationToControls((int) simpleExoPlayer.getDuration()); + UIs.call(PlayerUi::onPrepared); - binding.playbackSpeed.setText(formatSpeed(getPlaybackSpeed())); - - if (playWhenReady) { + if (playWhenReady && !isMuted()) { audioReactor.requestAudioFocus(); } } @@ -2159,22 +1096,7 @@ public final class Player implements startProgressLoop(); } - // if we are e.g. switching players, hide controls - hideControls(DEFAULT_CONTROLS_DURATION, 0); - - binding.playbackSeekBar.setEnabled(false); - binding.playbackSeekBar.getThumb() - .setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN)); - - binding.loadingPanel.setBackgroundColor(Color.BLACK); - animate(binding.loadingPanel, true, 0); - animate(binding.surfaceForeground, true, 100); - - binding.playPauseButton.setImageResource(R.drawable.ic_play_arrow); - animatePlayButtons(false, 100); - binding.getRoot().setKeepScreenOn(false); - - NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); + UIs.call(PlayerUi::onBlocked); } private void onPlaying() { @@ -2185,44 +1107,15 @@ public final class Player implements startProgressLoop(); } - updateStreamRelatedViews(); - - binding.playbackSeekBar.setEnabled(true); - binding.playbackSeekBar.getThumb() - .setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN)); - - binding.loadingPanel.setVisibility(View.GONE); - - animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA); - - animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0, - () -> { - binding.playPauseButton.setImageResource(R.drawable.ic_pause); - animatePlayButtons(true, 200); - if (!isQueueVisible) { - binding.playPauseButton.requestFocus(); - } - }); - - changePopupWindowFlags(ONGOING_PLAYBACK_WINDOW_FLAGS); - checkLandscape(); - binding.getRoot().setKeepScreenOn(true); - - NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); + UIs.call(PlayerUi::onPlaying); } private void onBuffering() { if (DEBUG) { Log.d(TAG, "onBuffering() called"); } - binding.loadingPanel.setBackgroundColor(Color.TRANSPARENT); - binding.loadingPanel.setVisibility(View.VISIBLE); - binding.getRoot().setKeepScreenOn(true); - - if (NotificationUtil.getInstance().shouldUpdateBufferingSlot()) { - NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); - } + UIs.call(PlayerUi::onBuffering); } private void onPaused() { @@ -2234,43 +1127,14 @@ public final class Player implements stopProgressLoop(); } - // 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 - if (PlayerHelper.getMinimizeOnExitAction(context) == MINIMIZE_ON_EXIT_MODE_NONE - && videoPlayerSelected()) { - NotificationUtil.getInstance().cancelNotificationAndStopForeground(service); - } else { - NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); - } - - binding.getRoot().setKeepScreenOn(false); + UIs.call(PlayerUi::onPaused); } private void onPausedSeek() { if (DEBUG) { Log.d(TAG, "onPausedSeek() called"); } - - animatePlayButtons(false, 100); - binding.getRoot().setKeepScreenOn(true); - - NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); + UIs.call(PlayerUi::onPausedSeek); } private void onCompleted() { @@ -2281,19 +1145,7 @@ public final class Player implements return; } - animate(binding.playPauseButton, false, 0, AnimationType.SCALE_AND_ALPHA, 0, - () -> { - binding.playPauseButton.setImageResource(R.drawable.ic_replay); - animatePlayButtons(true, DEFAULT_CONTROLS_DURATION); - }); - - binding.getRoot().setKeepScreenOn(false); - changePopupWindowFlags(IDLE_WINDOW_FLAGS); - - NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); - if (isFullscreen) { - toggleFullscreen(); - } + UIs.call(PlayerUi::onCompleted); if (playQueue.getIndex() < playQueue.size() - 1) { playQueue.offsetIndex(+1); @@ -2301,38 +1153,6 @@ public final class Player implements if (isProgressLoopRunning()) { stopProgressLoop(); } - - // When a (short) video ends the elements have to display the correct values - see #6180 - updatePlayBackElementsCurrentDuration(binding.playbackSeekBar.getMax()); - - showControls(500); - animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA); - binding.loadingPanel.setVisibility(View.GONE); - animate(binding.surfaceForeground, true, 100); - } - - private void animatePlayButtons(final boolean show, final int duration) { - animate(binding.playPauseButton, show, duration, AnimationType.SCALE_AND_ALPHA); - - boolean showQueueButtons = show; - if (playQueue == null) { - showQueueButtons = false; - } - - if (!showQueueButtons || playQueue.getIndex() > 0) { - animate( - binding.playPreviousButton, - showQueueButtons, - duration, - AnimationType.SCALE_AND_ALPHA); - } - if (!showQueueButtons || playQueue.getIndex() + 1 < playQueue.getStreams().size()) { - animate( - binding.playNextButton, - showQueueButtons, - duration, - AnimationType.SCALE_AND_ALPHA); - } } //endregion @@ -2343,43 +1163,29 @@ public final class Player implements //////////////////////////////////////////////////////////////////////////*/ //region Repeat and shuffle - public void onRepeatClicked() { - if (DEBUG) { - Log.d(TAG, "onRepeatClicked() called"); - } - setRepeatMode(nextRepeatMode(getRepeatMode())); - } - - public void onShuffleClicked() { - if (DEBUG) { - Log.d(TAG, "onShuffleClicked() called"); - } - - if (exoPlayerIsNull()) { - return; - } - simpleExoPlayer.setShuffleModeEnabled(!simpleExoPlayer.getShuffleModeEnabled()); - } - @RepeatMode public int getRepeatMode() { return exoPlayerIsNull() ? REPEAT_MODE_OFF : simpleExoPlayer.getRepeatMode(); } - private void setRepeatMode(@RepeatMode final int repeatMode) { + public void setRepeatMode(@RepeatMode final int repeatMode) { if (!exoPlayerIsNull()) { simpleExoPlayer.setRepeatMode(repeatMode); } } + public void cycleNextRepeatMode() { + setRepeatMode(nextRepeatMode(getRepeatMode())); + } + @Override public void onRepeatModeChanged(@RepeatMode final int repeatMode) { if (DEBUG) { Log.d(TAG, "ExoPlayer - onRepeatModeChanged() called with: " + "repeatMode = [" + repeatMode + "]"); } - setRepeatModeButton(binding.repeatButton, repeatMode); - onShuffleOrRepeatModeChanged(); + UIs.call(playerUi -> playerUi.onRepeatModeChanged(repeatMode)); + notifyPlaybackUpdateToListeners(); } @Override @@ -2397,57 +1203,13 @@ public final class Player implements } } - setShuffleButton(binding.shuffleButton, shuffleModeEnabled); - onShuffleOrRepeatModeChanged(); - } - - private void onShuffleOrRepeatModeChanged() { + UIs.call(playerUi -> playerUi.onShuffleModeEnabledChanged(shuffleModeEnabled)); notifyPlaybackUpdateToListeners(); - NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); } - private void setRepeatModeButton(final AppCompatImageButton imageButton, - @RepeatMode final int repeatMode) { - switch (repeatMode) { - case REPEAT_MODE_OFF: - imageButton.setImageResource(R.drawable.exo_controls_repeat_off); - break; - case REPEAT_MODE_ONE: - imageButton.setImageResource(R.drawable.exo_controls_repeat_one); - break; - case REPEAT_MODE_ALL: - imageButton.setImageResource(R.drawable.exo_controls_repeat_all); - break; - } - } - - private void setShuffleButton(@NonNull final ImageButton button, final boolean shuffled) { - button.setImageAlpha(shuffled ? 255 : 77); - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Playlist append - //////////////////////////////////////////////////////////////////////////*/ - //region Playlist append - - public void onAddToPlaylistClicked(@NonNull final FragmentManager fragmentManager) { - if (DEBUG) { - Log.d(TAG, "onAddToPlaylistClicked() called"); - } - - if (getPlayQueue() != null) { - PlaylistDialog.createCorrespondingDialog( - getContext(), - getPlayQueue() - .getStreams() - .stream() - .map(StreamEntity::new) - .collect(Collectors.toList()), - dialog -> dialog.show(fragmentManager, TAG) - ); + public void toggleShuffleModeEnabled() { + if (!exoPlayerIsNull()) { + simpleExoPlayer.setShuffleModeEnabled(!simpleExoPlayer.getShuffleModeEnabled()); } } //endregion @@ -2459,23 +1221,21 @@ public final class Player implements //////////////////////////////////////////////////////////////////////////*/ //region Mute / Unmute - public void onMuteUnmuteButtonClicked() { - if (DEBUG) { - Log.d(TAG, "onMuteUnmuteButtonClicked() called"); + public void toggleMute() { + final boolean wasMuted = isMuted(); + simpleExoPlayer.setVolume(wasMuted ? 1 : 0); + if (wasMuted) { + audioReactor.requestAudioFocus(); + } else { + audioReactor.abandonAudioFocus(); } - simpleExoPlayer.setVolume(isMuted() ? 1 : 0); + UIs.call(playerUi -> playerUi.onMuteUnmuteChanged(!wasMuted)); notifyPlaybackUpdateToListeners(); - setMuteButton(binding.switchMute, isMuted()); } - boolean isMuted() { + public boolean isMuted() { return !exoPlayerIsNull() && simpleExoPlayer.getVolume() == 0; } - - 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)); - } //endregion @@ -2504,33 +1264,51 @@ public final class Player implements Listener.super.onEvents(player, events); MediaItemTag.from(player.getCurrentMediaItem()).ifPresent(tag -> { if (tag == currentMetadata) { - return; + return; // we still have the same metadata, no need to do anything } + final StreamInfo previousInfo = Optional.ofNullable(currentMetadata) + .flatMap(MediaItemTag::getMaybeStreamInfo).orElse(null); + final MediaItemTag.AudioTrack previousAudioTrack = + Optional.ofNullable(currentMetadata) + .flatMap(MediaItemTag::getMaybeAudioTrack).orElse(null); currentMetadata = tag; - if (!tag.getErrors().isEmpty()) { + + if (!currentMetadata.getErrors().isEmpty()) { + // new errors might have been added even if previousInfo == tag.getMaybeStreamInfo() final ErrorInfo errorInfo = new ErrorInfo( - tag.getErrors().get(0), + currentMetadata.getErrors(), UserAction.PLAY_STREAM, - "Loading failed for [" + tag.getTitle() + "]: " + tag.getStreamUrl(), - tag.getServiceId()); + "Loading failed for [" + currentMetadata.getTitle() + + "]: " + currentMetadata.getStreamUrl(), + currentMetadata.getServiceId()); ErrorUtil.createNotification(context, errorInfo); } - tag.getMaybeStreamInfo().ifPresent(info -> { + + currentMetadata.getMaybeStreamInfo().ifPresent(info -> { if (DEBUG) { Log.d(TAG, "ExoPlayer - onEvents() update stream info: " + info.getName()); } - updateMetadataWith(info); + if (previousInfo == null || !previousInfo.getUrl().equals(info.getUrl())) { + // only update with the new stream info if it has actually changed + updateMetadataWith(info); + } else if (previousAudioTrack == null + || tag.getMaybeAudioTrack() + .map(t -> t.getSelectedAudioStreamIndex() + != previousAudioTrack.getSelectedAudioStreamIndex()) + .orElse(false)) { + notifyAudioTrackUpdateToListeners(); + } }); }); } @Override - public void onTracksInfoChanged(@NonNull final TracksInfo tracksInfo) { + public void onTracksChanged(@NonNull final Tracks tracks) { if (DEBUG) { Log.d(TAG, "ExoPlayer - onTracksChanged(), " - + "track group size = " + tracksInfo.getTrackGroupInfos().size()); + + "track group size = " + tracks.getGroups().size()); } - onTextTracksChanged(); + UIs.call(playerUi -> playerUi.onTextTracksChanged(tracks)); } @Override @@ -2539,7 +1317,7 @@ public final class Player implements Log.d(TAG, "ExoPlayer - playbackParameters(), speed = [" + playbackParameters.speed + "], pitch = [" + playbackParameters.pitch + "]"); } - binding.playbackSpeed.setText(formatSpeed(playbackParameters.speed)); + UIs.call(playerUi -> playerUi.onPlaybackParametersChanged(playbackParameters)); } @Override @@ -2591,13 +1369,12 @@ public final class Player implements @Override public void onRenderedFirstFrame() { - //TODO check if this causes black screen when switching to fullscreen - animate(binding.surfaceForeground, false, DEFAULT_CONTROLS_DURATION); + UIs.call(PlayerUi::onRenderedFirstFrame); } @Override - public void onCues(@NonNull final List cues) { - binding.subtitleView.onCues(cues); + public void onCues(@NonNull final CueGroup cueGroup) { + UIs.call(playerUi -> playerUi.onCues(cueGroup.cues)); } //endregion @@ -2607,6 +1384,7 @@ public final class Player implements // Errors //////////////////////////////////////////////////////////////////////////*/ //region Errors + /** * Process exceptions produced by {@link com.google.android.exoplayer2.ExoPlayer ExoPlayer}. *

There are multiple types of errors:

@@ -2633,12 +1411,13 @@ public final class Player implements * For any error above that is not explicitly catchable, the player will * create a notification so users are aware. * + * * @see com.google.android.exoplayer2.Player.Listener#onPlayerError(PlaybackException) - * */ + */ // Any error code not explicitly covered here are either unrelated to NewPipe use case // (e.g. DRM) or not recoverable (e.g. Decoder error). In both cases, the player should // shutdown. - @SuppressLint("SwitchIntDef") + @SuppressWarnings("SwitchIntDef") @Override public void onPlayerError(@NonNull final PlaybackException error) { Log.e(TAG, "ExoPlayer - onPlayerError() called with:", error); @@ -2717,18 +1496,6 @@ public final class Player implements //////////////////////////////////////////////////////////////////////////*/ //region Playback position and seek - /** - * Sets the current duration into the corresponding elements. - * @param currentProgress - */ - private void updatePlayBackElementsCurrentDuration(final int currentProgress) { - // Don't set seekbar progress while user is seeking - if (currentState != STATE_PAUSED_SEEK) { - binding.playbackSeekBar.setProgress(currentProgress); - } - binding.playbackCurrentTime.setText(getTimeString(currentProgress)); - } - @Override // own playback listener (this is a getter) public boolean isApproachingPlaybackEdge(final long timeToEndMillis) { // If live, then not near playback edge @@ -2771,48 +1538,50 @@ public final class Player implements Log.d(TAG, "Playback - onPlaybackSynchronize(was blocked: " + wasBlocked + ") called with item=[" + item.getTitle() + "], url=[" + item.getUrl() + "]"); } - if (exoPlayerIsNull() || playQueue == null) { - return; + if (exoPlayerIsNull() || playQueue == null || currentItem == item) { + return; // nothing to synchronize } - final boolean hasPlayQueueItemChanged = currentItem != item; + final int playQueueIndex = playQueue.indexOf(item); + final int playlistIndex = simpleExoPlayer.getCurrentMediaItemIndex(); + final int playlistSize = simpleExoPlayer.getCurrentTimeline().getWindowCount(); + final boolean removeThumbnailBeforeSync = currentItem == null + || currentItem.getServiceId() != item.getServiceId() + || !currentItem.getUrl().equals(item.getUrl()); - final int currentPlayQueueIndex = playQueue.indexOf(item); - final int currentPlaylistIndex = simpleExoPlayer.getCurrentMediaItemIndex(); - final int currentPlaylistSize = simpleExoPlayer.getCurrentTimeline().getWindowCount(); - - // If nothing to synchronize - if (!hasPlayQueueItemChanged) { - return; - } currentItem = item; - // Check if on wrong window - if (currentPlayQueueIndex != playQueue.getIndex()) { - Log.e(TAG, "Playback - Play Queue may be desynchronized: item " - + "index=[" + currentPlayQueueIndex + "], " - + "queue index=[" + playQueue.getIndex() + "]"); + if (playQueueIndex != playQueue.getIndex()) { + // wrong window (this should be impossible, as this method is called with + // `item=playQueue.getItem()`, so the index of that item must be equal to `getIndex()`) + Log.e(TAG, "Playback - Play Queue may be not in sync: item index=[" + + playQueueIndex + "], " + "queue index=[" + playQueue.getIndex() + "]"); - // Check if bad seek position - } else if ((currentPlaylistSize > 0 && currentPlayQueueIndex >= currentPlaylistSize) - || currentPlayQueueIndex < 0) { - Log.e(TAG, "Playback - Trying to seek to invalid " - + "index=[" + currentPlayQueueIndex + "] with " - + "playlist length=[" + currentPlaylistSize + "]"); + } else if ((playlistSize > 0 && playQueueIndex >= playlistSize) || playQueueIndex < 0) { + // the queue and the player's timeline are not in sync, since the play queue index + // points outside of the timeline + Log.e(TAG, "Playback - Trying to seek to invalid index=[" + playQueueIndex + + "] with playlist length=[" + playlistSize + "]"); - } else if (wasBlocked || currentPlaylistIndex != currentPlayQueueIndex || !isPlaying()) { + } else if (wasBlocked || playlistIndex != playQueueIndex || !isPlaying()) { + // either the player needs to be unblocked, or the play queue index has just been + // changed and needs to be synchronized, or the player is not playing if (DEBUG) { - Log.d(TAG, "Playback - Rewinding to correct " - + "index=[" + currentPlayQueueIndex + "], " - + "from=[" + currentPlaylistIndex + "], " - + "size=[" + currentPlaylistSize + "]."); + Log.d(TAG, "Playback - Rewinding to correct index=[" + playQueueIndex + "], " + + "from=[" + playlistIndex + "], size=[" + playlistSize + "]."); } + if (removeThumbnailBeforeSync) { + // unset the current (now outdated) thumbnail to ensure it is not used during sync + onThumbnailLoaded(null); + } + + // sync the player index with the queue index, and seek to the correct position if (item.getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) { - simpleExoPlayer.seekTo(currentPlayQueueIndex, item.getRecoveryPosition()); - playQueue.unsetRecovery(currentPlayQueueIndex); + simpleExoPlayer.seekTo(playQueueIndex, item.getRecoveryPosition()); + playQueue.unsetRecovery(playQueueIndex); } else { - simpleExoPlayer.seekToDefaultPosition(currentPlayQueueIndex); + simpleExoPlayer.seekToDefaultPosition(playQueueIndex); } } } @@ -2823,14 +1592,8 @@ public final class Player implements } if (!exoPlayerIsNull()) { // prevent invalid positions when fast-forwarding/-rewinding - long normalizedPositionMillis = positionMillis; - if (normalizedPositionMillis < 0) { - normalizedPositionMillis = 0; - } else if (normalizedPositionMillis > simpleExoPlayer.getDuration()) { - normalizedPositionMillis = simpleExoPlayer.getDuration(); - } - - simpleExoPlayer.seekTo(normalizedPositionMillis); + simpleExoPlayer.seekTo(MathUtils.clamp(positionMillis, 0, + simpleExoPlayer.getDuration())); } } @@ -2846,20 +1609,6 @@ public final class Player implements simpleExoPlayer.seekToDefaultPosition(); } } - - /** - * Sets the video duration time into all control components (e.g. seekbar). - * @param duration - */ - private void setVideoDurationToControls(final int duration) { - binding.playbackEndTime.setText(getTimeString(duration)); - - binding.playbackSeekBar.setMax(duration); - // This is important for Android TVs otherwise it would apply the default from - // setMax/Min methods which is (max - min) / 20 - binding.playbackSeekBar.setKeyProgressIncrement( - PlayerHelper.retrieveSeekDurationFromPreferences(this)); - } //endregion @@ -2877,7 +1626,9 @@ public final class Player implements return; } - audioReactor.requestAudioFocus(); + if (!isMuted()) { + audioReactor.requestAudioFocus(); + } if (currentState == STATE_COMPLETED) { if (playQueue.getIndex() == 0) { @@ -2983,25 +1734,25 @@ public final class Player implements } private void saveStreamProgressState(final long progressMillis) { - if (!getCurrentStreamInfo().isPresent() - || !prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) { - return; - } - if (DEBUG) { - Log.d(TAG, "saveStreamProgressState() called with: progressMillis=" + progressMillis - + ", currentMetadata=[" + getCurrentStreamInfo().get().getName() + "]"); - } + getCurrentStreamInfo().ifPresent(info -> { + if (!prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) { + return; + } + if (DEBUG) { + Log.d(TAG, "saveStreamProgressState() called with: progressMillis=" + progressMillis + + ", currentMetadata=[" + info.getName() + "]"); + } - databaseUpdateDisposable - .add(recordManager.saveStreamState(getCurrentStreamInfo().get(), progressMillis) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError(e -> { - if (DEBUG) { - e.printStackTrace(); - } - }) - .onErrorComplete() - .subscribe()); + databaseUpdateDisposable.add(recordManager.saveStreamState(info, progressMillis) + .observeOn(AndroidSchedulers.mainThread()) + .doOnError(e -> { + if (DEBUG) { + e.printStackTrace(); + } + }) + .onErrorComplete() + .subscribe()); + }); } public void saveStreamProgressState() { @@ -3032,66 +1783,34 @@ public final class Player implements //////////////////////////////////////////////////////////////////////////*/ //region Metadata - private void onMetadataChanged(@NonNull final StreamInfo info) { + private void updateMetadataWith(@NonNull final StreamInfo info) { if (DEBUG) { Log.d(TAG, "Playback - onMetadataChanged() called, playing: " + info.getName()); } - - initThumbnail(info.getThumbnailUrl()); - registerStreamViewed(); - updateStreamRelatedViews(); - showHideKodiButton(); - - binding.titleTextView.setText(info.getName()); - binding.channelTextView.setText(info.getUploaderName()); - - this.seekbarPreviewThumbnailHolder.resetFrom(this.getContext(), info.getPreviewFrames()); - - NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); - - final boolean showThumbnail = prefs.getBoolean( - context.getString(R.string.show_thumbnail_key), true); - mediaSessionManager.setMetadata( - getVideoTitle(), - getUploaderName(), - showThumbnail ? Optional.ofNullable(getThumbnail()) : Optional.empty(), - StreamTypeUtil.isLiveStream(info.getStreamType()) ? -1 : info.getDuration() - ); - - notifyMetadataUpdateToListeners(); - - if (areSegmentsVisible) { - if (segmentAdapter.setItems(info)) { - final int adapterPosition = getNearestStreamSegmentPosition( - simpleExoPlayer.getCurrentPosition()); - segmentAdapter.selectSegmentAt(adapterPosition); - binding.itemsList.scrollToPosition(adapterPosition); - } else { - closeItemsList(); - } - } - } - - private void updateMetadataWith(@NonNull final StreamInfo streamInfo) { if (exoPlayerIsNull()) { return; } - maybeAutoQueueNextStream(streamInfo); - onMetadataChanged(streamInfo); - NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, true); + maybeAutoQueueNextStream(info); + + loadCurrentThumbnail(info.getThumbnails()); + registerStreamViewed(); + + notifyMetadataUpdateToListeners(); + notifyAudioTrackUpdateToListeners(); + UIs.call(playerUi -> playerUi.onMetadataChanged(info)); } @NonNull - private String getVideoUrl() { + public String getVideoUrl() { return currentMetadata == null ? context.getString(R.string.unknown_content) : currentMetadata.getStreamUrl(); } @NonNull - private String getVideoUrlAtCurrentTime() { - final int timeSeconds = binding.playbackSeekBar.getProgress() / 1000; + public String getVideoUrlAtCurrentTime() { + final long timeSeconds = simpleExoPlayer.getCurrentPosition() / 1000; String videoUrl = getVideoUrl(); if (!isLive() && timeSeconds >= 0 && currentMetadata != null && currentMetadata.getServiceId() == YouTube.getServiceId()) { @@ -3117,10 +1836,6 @@ public final class Player implements @Nullable public Bitmap getThumbnail() { - if (currentThumbnail == null) { - currentThumbnail = BitmapFactory.decodeResource( - context.getResources(), R.drawable.dummy_thumbnail); - } return currentThumbnail; } //endregion @@ -3167,188 +1882,7 @@ public final class Player implements @Override public void onPlayQueueEdited() { notifyPlaybackUpdateToListeners(); - showOrHideButtons(); - NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); - } - - private void onQueueClicked() { - isQueueVisible = true; - - hideSystemUIIfNeeded(); - buildQueue(); - - binding.itemsListHeaderTitle.setVisibility(View.GONE); - binding.itemsListHeaderDuration.setVisibility(View.VISIBLE); - binding.shuffleButton.setVisibility(View.VISIBLE); - binding.repeatButton.setVisibility(View.VISIBLE); - binding.addToPlaylistButton.setVisibility(View.VISIBLE); - - hideControls(0, 0); - binding.itemsListPanel.requestFocus(); - animate(binding.itemsListPanel, true, DEFAULT_CONTROLS_DURATION, - AnimationType.SLIDE_AND_ALPHA); - - binding.itemsList.scrollToPosition(playQueue.getIndex()); - - updateQueueTime((int) simpleExoPlayer.getCurrentPosition()); - } - - private void buildQueue() { - binding.itemsList.setAdapter(playQueueAdapter); - binding.itemsList.setClickable(true); - binding.itemsList.setLongClickable(true); - - binding.itemsList.clearOnScrollListeners(); - binding.itemsList.addOnScrollListener(getQueueScrollListener()); - - itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); - itemTouchHelper.attachToRecyclerView(binding.itemsList); - - playQueueAdapter.setSelectedListener(getOnSelectedListener()); - - binding.itemsListClose.setOnClickListener(view -> closeItemsList()); - } - - private void onSegmentsClicked() { - areSegmentsVisible = true; - - hideSystemUIIfNeeded(); - buildSegments(); - - binding.itemsListHeaderTitle.setVisibility(View.VISIBLE); - binding.itemsListHeaderDuration.setVisibility(View.GONE); - binding.shuffleButton.setVisibility(View.GONE); - binding.repeatButton.setVisibility(View.GONE); - binding.addToPlaylistButton.setVisibility(View.GONE); - - hideControls(0, 0); - binding.itemsListPanel.requestFocus(); - animate(binding.itemsListPanel, true, DEFAULT_CONTROLS_DURATION, - AnimationType.SLIDE_AND_ALPHA); - - final int adapterPosition = getNearestStreamSegmentPosition(simpleExoPlayer - .getCurrentPosition()); - segmentAdapter.selectSegmentAt(adapterPosition); - binding.itemsList.scrollToPosition(adapterPosition); - } - - private void buildSegments() { - binding.itemsList.setAdapter(segmentAdapter); - binding.itemsList.setClickable(true); - binding.itemsList.setLongClickable(false); - - binding.itemsList.clearOnScrollListeners(); - if (itemTouchHelper != null) { - itemTouchHelper.attachToRecyclerView(null); - } - - getCurrentStreamInfo().ifPresent(segmentAdapter::setItems); - - binding.shuffleButton.setVisibility(View.GONE); - binding.repeatButton.setVisibility(View.GONE); - binding.addToPlaylistButton.setVisibility(View.GONE); - binding.itemsListClose.setOnClickListener(view -> closeItemsList()); - } - - public void closeItemsList() { - if (isQueueVisible || areSegmentsVisible) { - isQueueVisible = false; - areSegmentsVisible = false; - - if (itemTouchHelper != null) { - itemTouchHelper.attachToRecyclerView(null); - } - - animate(binding.itemsListPanel, false, DEFAULT_CONTROLS_DURATION, - AnimationType.SLIDE_AND_ALPHA, 0, () -> { - // Even when queueLayout is GONE it receives touch events - // and ruins normal behavior of the app. This line fixes it - binding.itemsListPanel.setTranslationY( - -binding.itemsListPanel.getHeight() * 5); - }); - - // clear focus, otherwise a white rectangle remains on top of the player - binding.itemsListClose.clearFocus(); - binding.playPauseButton.requestFocus(); - } - } - - private OnScrollBelowItemsListener getQueueScrollListener() { - return new OnScrollBelowItemsListener() { - @Override - public void onScrolledDown(final RecyclerView recyclerView) { - if (playQueue != null && !playQueue.isComplete()) { - playQueue.fetch(); - } else if (binding != null) { - binding.itemsList.clearOnScrollListeners(); - } - } - }; - } - - private StreamSegmentAdapter.StreamSegmentListener getStreamSegmentListener() { - return (item, seconds) -> { - segmentAdapter.selectSegment(item); - seekTo(seconds * 1000L); - triggerProgressUpdate(); - }; - } - - private int getNearestStreamSegmentPosition(final long playbackPosition) { - int nearestPosition = 0; - final List segments = getCurrentStreamInfo() - .map(StreamInfo::getStreamSegments) - .orElse(Collections.emptyList()); - - for (int i = 0; i < segments.size(); i++) { - if (segments.get(i).getStartTimeSeconds() * 1000L > playbackPosition) { - break; - } - nearestPosition++; - } - return Math.max(0, nearestPosition - 1); - } - - private ItemTouchHelper.SimpleCallback getItemTouchCallback() { - return new PlayQueueItemTouchCallback() { - @Override - public void onMove(final int sourceIndex, final int targetIndex) { - if (playQueue != null) { - playQueue.move(sourceIndex, targetIndex); - } - } - - @Override - public void onSwiped(final int index) { - if (index != -1) { - playQueue.remove(index); - } - } - }; - } - - private PlayQueueItemBuilder.OnSelectedListener getOnSelectedListener() { - return new PlayQueueItemBuilder.OnSelectedListener() { - @Override - public void selected(final PlayQueueItem item, final View view) { - selectQueueItem(item); - } - - @Override - public void held(final PlayQueueItem item, final View view) { - if (playQueue.indexOf(item) != -1) { - openPopupMenu(playQueue, item, view, true, - getParentActivity().getSupportFragmentManager(), context); - } - } - - @Override - public void onStartDrag(final PlayQueueItemHolder viewHolder) { - if (itemTouchHelper != null) { - itemTouchHelper.startDrag(viewHolder); - } - } - }; + UIs.call(PlayerUi::onPlayQueueEdited); } @Override // own playback listener @@ -3381,265 +1915,22 @@ public final class Player implements loadController.disablePreloadingOfCurrentTrack(); } - @Nullable - public VideoStream getSelectedVideoStream() { - return (selectedStreamIndex >= 0 && availableStreams != null - && availableStreams.size() > selectedStreamIndex) - ? availableStreams.get(selectedStreamIndex) : null; + public Optional getSelectedVideoStream() { + return Optional.ofNullable(currentMetadata) + .flatMap(MediaItemTag::getMaybeQuality) + .filter(quality -> { + final int selectedStreamIndex = quality.getSelectedVideoStreamIndex(); + return selectedStreamIndex >= 0 + && selectedStreamIndex < quality.getSortedVideoStreams().size(); + }) + .map(quality -> quality.getSortedVideoStreams() + .get(quality.getSelectedVideoStreamIndex())); } - private void updateStreamRelatedViews() { - if (!getCurrentStreamInfo().isPresent()) { - return; - } - final StreamInfo info = getCurrentStreamInfo().get(); - - binding.qualityTextView.setVisibility(View.GONE); - binding.playbackSpeed.setVisibility(View.GONE); - - binding.playbackEndTime.setVisibility(View.GONE); - binding.playbackLiveSync.setVisibility(View.GONE); - - switch (info.getStreamType()) { - case AUDIO_STREAM: - binding.surfaceView.setVisibility(View.GONE); - binding.endScreen.setVisibility(View.VISIBLE); - binding.playbackEndTime.setVisibility(View.VISIBLE); - break; - - case AUDIO_LIVE_STREAM: - binding.surfaceView.setVisibility(View.GONE); - binding.endScreen.setVisibility(View.VISIBLE); - binding.playbackLiveSync.setVisibility(View.VISIBLE); - break; - - case LIVE_STREAM: - binding.surfaceView.setVisibility(View.VISIBLE); - binding.endScreen.setVisibility(View.GONE); - binding.playbackLiveSync.setVisibility(View.VISIBLE); - break; - - case VIDEO_STREAM: - if (currentMetadata == null - || !currentMetadata.getMaybeQuality().isPresent() - || (info.getVideoStreams().isEmpty() - && info.getVideoOnlyStreams().isEmpty())) { - break; - } - - availableStreams = currentMetadata.getMaybeQuality().get().getSortedVideoStreams(); - selectedStreamIndex = - currentMetadata.getMaybeQuality().get().getSelectedVideoStreamIndex(); - buildQualityMenu(); - - binding.qualityTextView.setVisibility(View.VISIBLE); - binding.surfaceView.setVisibility(View.VISIBLE); - default: - binding.endScreen.setVisibility(View.GONE); - binding.playbackEndTime.setVisibility(View.VISIBLE); - break; - } - - buildPlaybackSpeedMenu(); - binding.playbackSpeed.setVisibility(View.VISIBLE); - } - - private void updateQueueTime(final int currentTime) { - final int currentStream = playQueue.getIndex(); - int before = 0; - int after = 0; - - final List streams = playQueue.getStreams(); - final int nStreams = streams.size(); - - for (int i = 0; i < nStreams; i++) { - if (i < currentStream) { - before += streams.get(i).getDuration(); - } else { - after += streams.get(i).getDuration(); - } - } - - before *= 1000; - after *= 1000; - - binding.itemsListHeaderDuration.setText( - String.format("%s/%s", - getTimeString(currentTime + before), - getTimeString(before + after) - )); - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Popup menus ("popup" means that they pop up, not that they belong to the popup player) - //////////////////////////////////////////////////////////////////////////*/ - //region Popup menus ("popup" means that they pop up, not that they belong to the popup player) - - private void buildQualityMenu() { - if (qualityPopupMenu == null) { - return; - } - qualityPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_QUALITY); - - for (int i = 0; i < availableStreams.size(); i++) { - final VideoStream videoStream = availableStreams.get(i); - qualityPopupMenu.getMenu().add(POPUP_MENU_ID_QUALITY, i, Menu.NONE, MediaFormat - .getNameById(videoStream.getFormatId()) + " " + videoStream.resolution); - } - if (getSelectedVideoStream() != null) { - binding.qualityTextView.setText(getSelectedVideoStream().resolution); - } - qualityPopupMenu.setOnMenuItemClickListener(this); - qualityPopupMenu.setOnDismissListener(this); - } - - private void buildPlaybackSpeedMenu() { - if (playbackSpeedPopupMenu == null) { - return; - } - playbackSpeedPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_PLAYBACK_SPEED); - - for (int i = 0; i < PLAYBACK_SPEEDS.length; i++) { - playbackSpeedPopupMenu.getMenu().add(POPUP_MENU_ID_PLAYBACK_SPEED, i, Menu.NONE, - formatSpeed(PLAYBACK_SPEEDS[i])); - } - binding.playbackSpeed.setText(formatSpeed(getPlaybackSpeed())); - playbackSpeedPopupMenu.setOnMenuItemClickListener(this); - playbackSpeedPopupMenu.setOnDismissListener(this); - } - - private void buildCaptionMenu(@NonNull final List availableLanguages) { - if (captionPopupMenu == null) { - return; - } - captionPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_CAPTION); - - final String userPreferredLanguage = - prefs.getString(context.getString(R.string.caption_user_set_key), null); - /* - * only search for autogenerated cc as fallback - * if "(auto-generated)" was not already selected - * we are only looking for "(" instead of "(auto-generated)" to hopefully get all - * internationalized variants such as "(automatisch-erzeugt)" and so on - */ - boolean searchForAutogenerated = userPreferredLanguage != null - && !userPreferredLanguage.contains("("); - - // Add option for turning off caption - final MenuItem captionOffItem = captionPopupMenu.getMenu().add(POPUP_MENU_ID_CAPTION, - 0, Menu.NONE, R.string.caption_none); - captionOffItem.setOnMenuItemClickListener(menuItem -> { - final int textRendererIndex = getCaptionRendererIndex(); - if (textRendererIndex != RENDERER_UNAVAILABLE) { - trackSelector.setParameters(trackSelector.buildUponParameters() - .setRendererDisabled(textRendererIndex, true)); - } - prefs.edit().remove(context.getString(R.string.caption_user_set_key)).apply(); - return true; - }); - - // Add all available captions - for (int i = 0; i < availableLanguages.size(); i++) { - final String captionLanguage = availableLanguages.get(i); - final MenuItem captionItem = captionPopupMenu.getMenu().add(POPUP_MENU_ID_CAPTION, - i + 1, Menu.NONE, captionLanguage); - captionItem.setOnMenuItemClickListener(menuItem -> { - final int textRendererIndex = getCaptionRendererIndex(); - if (textRendererIndex != RENDERER_UNAVAILABLE) { - trackSelector.setParameters(trackSelector.buildUponParameters() - .setPreferredTextLanguage(captionLanguage) - .setRendererDisabled(textRendererIndex, false)); - prefs.edit().putString(context.getString(R.string.caption_user_set_key), - captionLanguage).apply(); - } - return true; - }); - // apply caption language from previous user preference - if (userPreferredLanguage != null - && (captionLanguage.equals(userPreferredLanguage) - || (searchForAutogenerated && captionLanguage.startsWith(userPreferredLanguage)) - || (userPreferredLanguage.contains("(") && captionLanguage.startsWith( - userPreferredLanguage.substring(0, userPreferredLanguage.indexOf('(')))))) { - final int textRendererIndex = getCaptionRendererIndex(); - if (textRendererIndex != RENDERER_UNAVAILABLE) { - trackSelector.setParameters(trackSelector.buildUponParameters() - .setPreferredTextLanguage(captionLanguage) - .setRendererDisabled(textRendererIndex, false)); - } - searchForAutogenerated = false; - } - } - captionPopupMenu.setOnDismissListener(this); - } - - /** - * Called when an item of the quality selector or the playback speed selector is selected. - */ - @Override - public boolean onMenuItemClick(@NonNull final MenuItem menuItem) { - if (DEBUG) { - Log.d(TAG, "onMenuItemClick() called with: " - + "menuItem = [" + menuItem + "], " - + "menuItem.getItemId = [" + menuItem.getItemId() + "]"); - } - - if (menuItem.getGroupId() == POPUP_MENU_ID_QUALITY) { - final int menuItemIndex = menuItem.getItemId(); - if (selectedStreamIndex == menuItemIndex || availableStreams == null - || availableStreams.size() <= menuItemIndex) { - return true; - } - - saveStreamProgressState(); //TODO added, check if good - final String newResolution = availableStreams.get(menuItemIndex).resolution; - setRecovery(); - setPlaybackQuality(newResolution); - reloadPlayQueueManager(); - - binding.qualityTextView.setText(menuItem.getTitle()); - return true; - } else if (menuItem.getGroupId() == POPUP_MENU_ID_PLAYBACK_SPEED) { - final int speedIndex = menuItem.getItemId(); - final float speed = PLAYBACK_SPEEDS[speedIndex]; - - setPlaybackSpeed(speed); - binding.playbackSpeed.setText(formatSpeed(speed)); - } - - return false; - } - - /** - * Called when some popup menu is dismissed. - */ - @Override - public void onDismiss(@Nullable final PopupMenu menu) { - if (DEBUG) { - Log.d(TAG, "onDismiss() called with: menu = [" + menu + "]"); - } - isSomePopupMenuVisible = false; //TODO check if this works - if (getSelectedVideoStream() != null) { - binding.qualityTextView.setText(getSelectedVideoStream().resolution); - } - if (isPlaying()) { - hideControls(DEFAULT_CONTROLS_DURATION, 0); - hideSystemUIIfNeeded(); - } - } - - private void onCaptionClicked() { - if (DEBUG) { - Log.d(TAG, "onCaptionClicked() called"); - } - captionPopupMenu.show(); - isSomePopupMenuVisible = true; - } - - private void setPlaybackQuality(@Nullable final String quality) { - videoResolver.setPlaybackQuality(quality); + public Optional getSelectedAudioStream() { + return Optional.ofNullable(currentMetadata) + .flatMap(MediaItemTag::getMaybeAudioTrack) + .map(MediaItemTag.AudioTrack::getSelectedAudioStream); } //endregion @@ -3650,65 +1941,7 @@ public final class Player implements //////////////////////////////////////////////////////////////////////////*/ //region Captions (text tracks) - private void setupSubtitleView() { - final float captionScale = PlayerHelper.getCaptionScale(context); - final CaptionStyleCompat captionStyle = PlayerHelper.getCaptionStyle(context); - if (popupPlayerSelected()) { - final float captionRatio = (captionScale - 1.0f) / 5.0f + 1.0f; - binding.subtitleView.setFractionalTextSize( - SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * captionRatio); - } else { - final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); - final int minimumLength = Math.min(metrics.heightPixels, metrics.widthPixels); - final float captionRatioInverse = 20f + 4f * (1.0f - captionScale); - binding.subtitleView.setFixedTextSize( - TypedValue.COMPLEX_UNIT_PX, minimumLength / captionRatioInverse); - } - binding.subtitleView.setApplyEmbeddedStyles(captionStyle == CaptionStyleCompat.DEFAULT); - binding.subtitleView.setStyle(captionStyle); - } - - private void onTextTracksChanged() { - final int textRenderer = getCaptionRendererIndex(); - - if (binding == null) { - return; - } - if (trackSelector.getCurrentMappedTrackInfo() == null - || textRenderer == RENDERER_UNAVAILABLE) { - binding.captionTextView.setVisibility(View.GONE); - return; - } - - final TrackGroupArray textTracks = trackSelector.getCurrentMappedTrackInfo() - .getTrackGroups(textRenderer); - - // Extract all loaded languages - final List availableLanguages = new ArrayList<>(textTracks.length); - for (int i = 0; i < textTracks.length; i++) { - final TrackGroup textTrack = textTracks.get(i); - if (textTrack.length > 0) { - availableLanguages.add(textTrack.getFormat(0).language); - } - } - - // Normalize mismatching language strings - final String preferredLanguage = trackSelector.getParameters() - .preferredTextLanguages.stream().findFirst().orElse(null); - // Build UI - buildCaptionMenu(availableLanguages); - if (trackSelector.getParameters().getRendererDisabled(textRenderer) - || preferredLanguage == null || (!availableLanguages.contains(preferredLanguage) - && !containsCaseInsensitive(availableLanguages, preferredLanguage))) { - binding.captionTextView.setText(R.string.caption_none); - } else { - binding.captionTextView.setText(preferredLanguage); - } - binding.captionTextView.setVisibility( - availableLanguages.isEmpty() ? View.GONE : View.VISIBLE); - } - - private int getCaptionRendererIndex() { + public int getCaptionRendererIndex() { if (exoPlayerIsNull()) { return RENDERER_UNAVAILABLE; } @@ -3724,218 +1957,10 @@ public final class Player implements //endregion - /*////////////////////////////////////////////////////////////////////////// - // Click listeners + // Video size //////////////////////////////////////////////////////////////////////////*/ - //region Click listeners - - @Override - public void onClick(final View v) { - if (DEBUG) { - Log.d(TAG, "onClick() called with: v = [" + v + "]"); - } - if (v.getId() == binding.resizeTextView.getId()) { - onResizeClicked(); - } else if (v.getId() == binding.captionTextView.getId()) { - onCaptionClicked(); - } else if (v.getId() == binding.playbackLiveSync.getId()) { - seekToDefault(); - } else if (v.getId() == binding.playPauseButton.getId()) { - playPause(); - } else if (v.getId() == binding.playPreviousButton.getId()) { - playPrevious(); - } else if (v.getId() == binding.playNextButton.getId()) { - playNext(); - } else if (v.getId() == binding.moreOptionsButton.getId()) { - onMoreOptionsClicked(); - } else if (v.getId() == binding.share.getId()) { - ShareUtils.shareText(context, getVideoTitle(), getVideoUrlAtCurrentTime(), - currentItem.getThumbnailUrl()); - } else if (v.getId() == binding.playWithKodi.getId()) { - onPlayWithKodiClicked(); - } else if (v.getId() == binding.openInBrowser.getId()) { - onOpenInBrowserClicked(); - } else if (v.getId() == binding.fullScreenButton.getId()) { - setRecovery(); - NavigationHelper.playOnMainPlayer(context, playQueue, true); - return; - } else if (v.getId() == binding.screenRotationButton.getId()) { - // Only if it's not a vertical video or vertical video but in landscape with locked - // orientation a screen orientation can be changed automatically - if (!isVerticalVideo - || (service.isLandscape() && globalScreenOrientationLocked(context))) { - fragmentListener.onScreenRotationButtonClicked(); - } else { - toggleFullscreen(); - } - } else if (v.getId() == binding.switchMute.getId()) { - onMuteUnmuteButtonClicked(); - } else if (v.getId() == binding.playerCloseButton.getId()) { - context.sendBroadcast(new Intent(VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER)); - } - - manageControlsAfterOnClick(v); - } - - /** - * Manages the controls after a click occurred on the player UI. - * @param v – The view that was clicked - */ - public void manageControlsAfterOnClick(@NonNull final View v) { - if (currentState == STATE_COMPLETED) { - return; - } - - controlsVisibilityHandler.removeCallbacksAndMessages(null); - showHideShadow(true, DEFAULT_CONTROLS_DURATION); - animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION, - AnimationType.ALPHA, 0, () -> { - if (currentState == STATE_PLAYING && !isSomePopupMenuVisible) { - if (v.getId() == binding.playPauseButton.getId() - // Hide controls in fullscreen immediately - || (v.getId() == binding.screenRotationButton.getId() - && isFullscreen)) { - hideControls(0, 0); - } else { - hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); - } - } - }); - } - - @Override - public boolean onLongClick(final View v) { - if (v.getId() == binding.moreOptionsButton.getId() && isFullscreen) { - fragmentListener.onMoreOptionsLongClicked(); - hideControls(0, 0); - hideSystemUIIfNeeded(); - } else if (v.getId() == binding.share.getId()) { - ShareUtils.copyToClipboard(context, getVideoUrlAtCurrentTime()); - } - return true; - } - - public boolean onKeyDown(final int keyCode) { - switch (keyCode) { - default: - break; - case KeyEvent.KEYCODE_SPACE: - if (isFullscreen) { - playPause(); - if (isPlaying()) { - hideControls(0, 0); - } - return true; - } - break; - case KeyEvent.KEYCODE_BACK: - if (DeviceUtils.isTv(context) && isControlsVisible()) { - hideControls(0, 0); - return true; - } - break; - case KeyEvent.KEYCODE_DPAD_UP: - case KeyEvent.KEYCODE_DPAD_LEFT: - case KeyEvent.KEYCODE_DPAD_DOWN: - case KeyEvent.KEYCODE_DPAD_RIGHT: - case KeyEvent.KEYCODE_DPAD_CENTER: - if ((binding.getRoot().hasFocus() && !binding.playbackControlRoot.hasFocus()) - || isQueueVisible) { - // do not interfere with focus in playlist and play queue etc. - return false; - } - - if (currentState == Player.STATE_BLOCKED) { - return true; - } - - if (isControlsVisible()) { - hideControls(DEFAULT_CONTROLS_DURATION, DPAD_CONTROLS_HIDE_TIME); - } else { - binding.playPauseButton.requestFocus(); - showControlsThenHide(); - showSystemUIPartially(); - return true; - } - break; - } - - return false; - } - - private void onMoreOptionsClicked() { - if (DEBUG) { - Log.d(TAG, "onMoreOptionsClicked() called"); - } - - final boolean isMoreControlsVisible = - binding.secondaryControls.getVisibility() == View.VISIBLE; - - animateRotation(binding.moreOptionsButton, DEFAULT_CONTROLS_DURATION, - isMoreControlsVisible ? 0 : 180); - animate(binding.secondaryControls, !isMoreControlsVisible, DEFAULT_CONTROLS_DURATION, - AnimationType.SLIDE_AND_ALPHA, 0, () -> { - // Fix for a ripple effect on background drawable. - // When view returns from GONE state it takes more milliseconds than returning - // from INVISIBLE state. And the delay makes ripple background end to fast - if (isMoreControlsVisible) { - binding.secondaryControls.setVisibility(View.INVISIBLE); - } - }); - showControls(DEFAULT_CONTROLS_DURATION); - } - - private void onPlayWithKodiClicked() { - if (currentMetadata != null) { - pause(); - try { - NavigationHelper.playWithKore(context, Uri.parse(getVideoUrl())); - } catch (final Exception e) { - if (DEBUG) { - Log.i(TAG, "Failed to start kore", e); - } - KoreUtils.showInstallKoreDialog(getParentActivity()); - } - } - } - - private void onOpenInBrowserClicked() { - getCurrentStreamInfo() - .map(Info::getOriginalUrl) - .ifPresent(originalUrl -> ShareUtils.openUrlInBrowser( - Objects.requireNonNull(getParentActivity()), originalUrl)); - } - //endregion - - - - /*////////////////////////////////////////////////////////////////////////// - // Video size, resize, orientation, fullscreen - //////////////////////////////////////////////////////////////////////////*/ - //region Video size, resize, orientation, fullscreen - - private void setupScreenRotationButton() { - binding.screenRotationButton.setVisibility(videoPlayerSelected() - && (globalScreenOrientationLocked(context) || isVerticalVideo - || DeviceUtils.isTablet(context)) - ? View.VISIBLE : View.GONE); - binding.screenRotationButton.setImageDrawable(AppCompatResources.getDrawable(context, - isFullscreen ? R.drawable.ic_fullscreen_exit - : R.drawable.ic_fullscreen)); - } - - private void setResizeMode(@AspectRatioFrameLayout.ResizeMode final int resizeMode) { - binding.surfaceView.setResizeMode(resizeMode); - binding.resizeTextView.setText(PlayerHelper.resizeTypeOf(context, resizeMode)); - } - - void onResizeClicked() { - if (binding != null) { - setResizeMode(nextResizeModeAndSaveToPrefs(this, binding.surfaceView.getResizeMode())); - } - } - + //region Video size @Override // exoplayer listener public void onVideoSizeChanged(@NonNull final VideoSize videoSize) { if (DEBUG) { @@ -3946,137 +1971,11 @@ public final class Player implements + "pixelWidthHeightRatio = [" + videoSize.pixelWidthHeightRatio + "]"); } - binding.surfaceView.setAspectRatio(((float) videoSize.width) / videoSize.height); - isVerticalVideo = videoSize.width < videoSize.height; - - if (globalScreenOrientationLocked(context) - && isFullscreen - && service.isLandscape() == isVerticalVideo - && !DeviceUtils.isTv(context) - && !DeviceUtils.isTablet(context) - && fragmentListener != null) { - // set correct orientation - fragmentListener.onScreenRotationButtonClicked(); - } - - setupScreenRotationButton(); - } - - public void toggleFullscreen() { - if (DEBUG) { - Log.d(TAG, "toggleFullscreen() called"); - } - if (popupPlayerSelected() || exoPlayerIsNull() || fragmentListener == null) { - return; - } - - isFullscreen = !isFullscreen; - if (!isFullscreen) { - // Apply window insets because Android will not do it when orientation changes - // from landscape to portrait (open vertical video to reproduce) - binding.playbackControlRoot.setPadding(0, 0, 0, 0); - } else { - // Android needs tens milliseconds to send new insets but a user is able to see - // how controls changes it's position from `0` to `nav bar height` padding. - // So just hide the controls to hide this visual inconsistency - hideControls(0, 0); - } - fragmentListener.onFullscreenStateChanged(isFullscreen); - - if (isFullscreen) { - binding.titleTextView.setVisibility(View.VISIBLE); - binding.channelTextView.setVisibility(View.VISIBLE); - binding.playerCloseButton.setVisibility(View.GONE); - } else { - binding.titleTextView.setVisibility(View.GONE); - binding.channelTextView.setVisibility(View.GONE); - binding.playerCloseButton.setVisibility( - videoPlayerSelected() ? View.VISIBLE : View.GONE); - } - setupScreenRotationButton(); - } - - public void checkLandscape() { - final AppCompatActivity parent = getParentActivity(); - final boolean videoInLandscapeButNotInFullscreen = - service.isLandscape() && !isFullscreen && videoPlayerSelected() && !isAudioOnly; - - final boolean notPaused = currentState != STATE_COMPLETED && currentState != STATE_PAUSED; - if (parent != null - && videoInLandscapeButNotInFullscreen - && notPaused - && !DeviceUtils.isTablet(context)) { - toggleFullscreen(); - } + UIs.call(playerUi -> playerUi.onVideoSizeChanged(videoSize)); } //endregion - - /*////////////////////////////////////////////////////////////////////////// - // Gestures - //////////////////////////////////////////////////////////////////////////*/ - //region Gestures - - @SuppressWarnings("checkstyle:ParameterNumber") - private void onLayoutChange(final View view, final int l, final int t, final int r, final int b, - final int ol, final int ot, final int or, final int ob) { - if (l != ol || t != ot || r != or || b != ob) { - // Use smaller value to be consistent between screen orientations - // (and to make usage easier) - final int width = r - l; - final int height = b - t; - final int min = Math.min(width, height); - maxGestureLength = (int) (min * MAX_GESTURE_LENGTH); - - if (DEBUG) { - Log.d(TAG, "maxGestureLength = " + maxGestureLength); - } - - binding.volumeProgressBar.setMax(maxGestureLength); - binding.brightnessProgressBar.setMax(maxGestureLength); - - setInitialGestureValues(); - binding.itemsListPanel.getLayoutParams().height - = height - binding.itemsListPanel.getTop(); - } - } - - private void setInitialGestureValues() { - if (audioReactor != null) { - final float currentVolumeNormalized = - (float) audioReactor.getVolume() / audioReactor.getMaxVolume(); - binding.volumeProgressBar.setProgress( - (int) (binding.volumeProgressBar.getMax() * currentVolumeNormalized)); - } - } - - private int distanceFromCloseButton(@NonNull final MotionEvent popupMotionEvent) { - final int closeOverlayButtonX = closeOverlayBinding.closeButton.getLeft() - + closeOverlayBinding.closeButton.getWidth() / 2; - final int closeOverlayButtonY = closeOverlayBinding.closeButton.getTop() - + closeOverlayBinding.closeButton.getHeight() / 2; - - final float fingerX = popupLayoutParams.x + popupMotionEvent.getX(); - final float fingerY = popupLayoutParams.y + popupMotionEvent.getY(); - - return (int) Math.sqrt(Math.pow(closeOverlayButtonX - fingerX, 2) - + Math.pow(closeOverlayButtonY - fingerY, 2)); - } - - private float getClosingRadius() { - final int buttonRadius = closeOverlayBinding.closeButton.getWidth() / 2; - // 20% wider than the button itself - return buttonRadius * 1.2f; - } - - public boolean isInsideClosingRadius(@NonNull final MotionEvent popupMotionEvent) { - return distanceFromCloseButton(popupMotionEvent) <= getClosingRadius(); - } - //endregion - - - /*////////////////////////////////////////////////////////////////////////// // Activity / fragment binding //////////////////////////////////////////////////////////////////////////*/ @@ -4084,13 +1983,7 @@ public final class Player implements public void setFragmentListener(final PlayerServiceEventListener listener) { fragmentListener = listener; - fragmentIsVisible = true; - // Apply window insets because Android will not do it when orientation changes - // from landscape to portrait - if (!isFullscreen) { - binding.playbackControlRoot.setPadding(0, 0, 0, 0); - } - binding.itemsListPanel.setPadding(0, 0, 0, 0); + UIs.call(PlayerUi::onFragmentListenerSet); notifyQueueUpdateToListeners(); notifyMetadataUpdateToListeners(); notifyPlaybackUpdateToListeners(); @@ -4128,28 +2021,6 @@ public final class Player implements } } - /** - * This will be called when a user goes to another app/activity, turns off a screen. - * We don't want to interrupt playback and don't want to see notification so - * next lines of code will enable audio-only playback only if needed - */ - private void onFragmentStopped() { - if (videoPlayerSelected() && (isPlaying() || isLoading())) { - switch (getMinimizeOnExitAction(context)) { - case MINIMIZE_ON_EXIT_MODE_BACKGROUND: - useVideoSource(false); - break; - case MINIMIZE_ON_EXIT_MODE_POPUP: - setRecovery(); - NavigationHelper.playOnPopupPlayer(getParentActivity(), playQueue, true); - break; - case MINIMIZE_ON_EXIT_MODE_NONE: default: - pause(); - break; - } - } - } - private void notifyQueueUpdateToListeners() { if (fragmentListener != null && playQueue != null) { fragmentListener.onQueueUpdate(playQueue); @@ -4192,76 +2063,49 @@ 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)) { - return null; + private void notifyAudioTrackUpdateToListeners() { + if (fragmentListener != null) { + fragmentListener.onAudioTrackUpdate(); + } + if (activityListener != null) { + activityListener.onAudioTrackUpdate(); } - - return (AppCompatActivity) ((ViewGroup) binding.getRoot().getParent()).getContext(); } - private void useVideoSource(final boolean videoEnabled) { - if (playQueue == null || isAudioOnly == !videoEnabled || audioPlayerSelected()) { + public void useVideoSource(final boolean videoEnabled) { + if (playQueue == null || audioPlayerSelected()) { return; } isAudioOnly = !videoEnabled; - // When a user returns from background, controls could be hidden but SystemUI will be shown - // 100%. Hide it. - if (!isAudioOnly && !isControlsVisible()) { - hideSystemUIIfNeeded(); - } - // The current metadata may be null sometimes (for e.g. when using an unstable connection - // in livestreams) so we will be not able to execute the block below. - // Reload the play queue manager in this case, which is the behavior when we don't know the - // index of the video renderer or playQueueManagerReloadingNeeded returns true. - final Optional optCurrentStreamInfo = getCurrentStreamInfo(); - if (!optCurrentStreamInfo.isPresent()) { + getCurrentStreamInfo().ifPresentOrElse(info -> { + // In case we don't know the source type, fall back to either video-with-audio, or + // audio-only source type + final SourceType sourceType = videoResolver.getStreamSourceType() + .orElse(SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY); + + if (playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) { + reloadPlayQueueManager(); + } + + setRecovery(); + + // Disable or enable video and subtitles renderers depending of the videoEnabled value + trackSelector.setParameters(trackSelector.buildUponParameters() + .setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !videoEnabled) + .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, !videoEnabled)); + }, () -> { + /* + The current metadata may be null sometimes (for e.g. when using an unstable connection + in livestreams) so we will be not able to execute the block below + + Reload the play queue manager in this case, which is the behavior when we don't know the + index of the video renderer or playQueueManagerReloadingNeeded returns true + */ reloadPlayQueueManager(); setRecovery(); - return; - } - - final StreamInfo info = optCurrentStreamInfo.get(); - - // In the case we don't know the source type, fallback to the one with video with audio or - // audio-only source. - final SourceType sourceType = videoResolver.getStreamSourceType().orElse( - SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY); - - if (playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) { - reloadPlayQueueManager(); - } else { - final StreamType streamType = info.getStreamType(); - if (streamType == StreamType.AUDIO_STREAM - || streamType == StreamType.AUDIO_LIVE_STREAM) { - // Nothing to do more than setting the recovery position - setRecovery(); - return; - } - - final DefaultTrackSelector.ParametersBuilder parametersBuilder = - trackSelector.buildUponParameters(); - - if (videoEnabled) { - // Enable again the video track and the subtitles, if there is one selected - parametersBuilder.setDisabledTrackTypes(Collections.emptySet()); - } else { - // Disable the video track and the ability to select subtitles - // Use an ArraySet because we can't use Set.of() on all supported APIs by the app - final ArraySet disabledTracks = new ArraySet<>(); - disabledTracks.add(C.TRACK_TYPE_TEXT); - disabledTracks.add(C.TRACK_TYPE_VIDEO); - parametersBuilder.setDisabledTrackTypes(disabledTracks); - } - - trackSelector.setParameters(parametersBuilder); - } - - setRecovery(); + }); } /** @@ -4272,13 +2116,15 @@ public final class Player implements * the content is not an audio content, but also if none of the following cases is met: * *
    - *
  • the content is an {@link StreamType#AUDIO_STREAM audio stream} or an - * {@link StreamType#AUDIO_LIVE_STREAM audio live stream};
  • + *
  • the content is an {@link StreamType#AUDIO_STREAM audio stream}, an + * {@link StreamType#AUDIO_LIVE_STREAM audio live stream}, or a + * {@link StreamType#POST_LIVE_AUDIO_STREAM ended audio live stream};
  • *
  • the content is a {@link StreamType#LIVE_STREAM live stream} and the source type is a * {@link SourceType#LIVE_STREAM live source};
  • *
  • the content's source is {@link SourceType#VIDEO_WITH_SEPARATED_AUDIO a video stream * with a separated audio source} or has no audio-only streams available and is a - * {@link StreamType#LIVE_STREAM live stream} or a + * {@link StreamType#VIDEO_STREAM video stream}, an + * {@link StreamType#POST_LIVE_STREAM ended live stream}, or a * {@link StreamType#LIVE_STREAM live stream}. *
  • *
@@ -4294,18 +2140,17 @@ public final class Player implements @NonNull final StreamInfo streamInfo, final int videoRendererIndex) { final StreamType streamType = streamInfo.getStreamType(); + final boolean isStreamTypeAudio = StreamTypeUtil.isAudio(streamType); - if (videoRendererIndex == RENDERER_UNAVAILABLE && streamType != StreamType.AUDIO_STREAM - && streamType != StreamType.AUDIO_LIVE_STREAM) { + if (videoRendererIndex == RENDERER_UNAVAILABLE && !isStreamTypeAudio) { return true; } // The content is an audio stream, an audio live stream, or a live stream with a live // source: it's not needed to reload the play queue manager because the stream source will // be the same - if ((streamType == StreamType.AUDIO_STREAM || streamType == StreamType.AUDIO_LIVE_STREAM) - || (streamType == StreamType.LIVE_STREAM - && sourceType == SourceType.LIVE_STREAM)) { + if (isStreamTypeAudio || (streamType == StreamType.LIVE_STREAM + && sourceType == SourceType.LIVE_STREAM)) { return false; } @@ -4316,10 +2161,10 @@ public final class Player implements // because the stream source will be probably the same as the current played if (sourceType == SourceType.VIDEO_WITH_SEPARATED_AUDIO || (sourceType == SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY - && isNullOrEmpty(streamInfo.getAudioStreams()))) { + && isNullOrEmpty(streamInfo.getAudioStreams()))) { // It's not needed to reload the play queue manager only if the content's stream type - // is a video stream or a live stream - return streamType != StreamType.VIDEO_STREAM && streamType != StreamType.LIVE_STREAM; + // is a video stream, a live stream or an ended live stream + return !StreamTypeUtil.isVideo(streamType); } // Other cases: the play queue manager reload is needed @@ -4333,7 +2178,7 @@ public final class Player implements //////////////////////////////////////////////////////////////////////////*/ //region Getters - private Optional getCurrentStreamInfo() { + public Optional getCurrentStreamInfo() { return Optional.ofNullable(currentMetadata).flatMap(MediaItemTag::getMaybeStreamInfo); } @@ -4345,6 +2190,10 @@ public final class Player implements return simpleExoPlayer == null; } + public ExoPlayer getExoPlayer() { + return simpleExoPlayer; + } + public boolean isStopped() { return exoPlayerIsNull() || simpleExoPlayer.getPlaybackState() == ExoPlayer.STATE_IDLE; } @@ -4357,7 +2206,7 @@ public final class Player implements return !exoPlayerIsNull() && simpleExoPlayer.getPlayWhenReady(); } - private boolean isLoading() { + public boolean isLoading() { return !exoPlayerIsNull() && simpleExoPlayer.isLoading(); } @@ -4373,6 +2222,21 @@ public final class Player implements } } + public void setPlaybackQuality(@Nullable final String quality) { + saveStreamProgressState(); + setRecovery(); + videoResolver.setPlaybackQuality(quality); + reloadPlayQueueManager(); + } + + public void setAudioTrack(@Nullable final String audioTrackId) { + saveStreamProgressState(); + setRecovery(); + videoResolver.setAudioTrack(audioTrackId); + audioResolver.setAudioTrack(audioTrackId); + reloadPlayQueueManager(); + } + @NonNull public Context getContext() { @@ -4384,10 +2248,6 @@ public final class Player implements return prefs; } - public MediaSessionManager getMediaSessionManager() { - return mediaSessionManager; - } - public PlayerType getPlayerType() { return playerType; @@ -4398,7 +2258,7 @@ public final class Player implements } public boolean videoPlayerSelected() { - return playerType == PlayerType.VIDEO; + return playerType == PlayerType.MAIN; } public boolean popupPlayerSelected() { @@ -4415,160 +2275,44 @@ public final class Player implements return audioReactor; } - public GestureDetectorCompat getGestureDetector() { - return gestureDetector; + public PlayerService getService() { + return service; } - public boolean isFullscreen() { - return isFullscreen; + public boolean isAudioOnly() { + return isAudioOnly; } - public boolean isVerticalVideo() { - return isVerticalVideo; - } - - public boolean isPopupClosing() { - return isPopupClosing; - } - - - public boolean isSomePopupMenuVisible() { - return isSomePopupMenuVisible; - } - - public void setSomePopupMenuVisible(final boolean somePopupMenuVisible) { - isSomePopupMenuVisible = somePopupMenuVisible; - } - - public ImageButton getPlayPauseButton() { - return binding.playPauseButton; - } - - public View getClosingOverlayView() { - return binding.closingOverlay; - } - - public ProgressBar getVolumeProgressBar() { - return binding.volumeProgressBar; - } - - public ProgressBar getBrightnessProgressBar() { - return binding.brightnessProgressBar; - } - - public int getMaxGestureLength() { - return maxGestureLength; - } - - public ImageView getVolumeImageView() { - return binding.volumeImageView; - } - - public RelativeLayout getVolumeRelativeLayout() { - return binding.volumeRelativeLayout; - } - - public ImageView getBrightnessImageView() { - return binding.brightnessImageView; - } - - public RelativeLayout getBrightnessRelativeLayout() { - return binding.brightnessRelativeLayout; - } - - public FloatingActionButton getCloseOverlayButton() { - return closeOverlayBinding.closeButton; - } - - public View getLoadingPanel() { - return binding.loadingPanel; - } - - public TextView getCurrentDisplaySeek() { - return binding.currentDisplaySeek; - } - - public PlayerFastSeekOverlay getFastSeekOverlay() { - return binding.fastSeekOverlay; + @NonNull + public DefaultTrackSelector getTrackSelector() { + return trackSelector; } @Nullable - public WindowManager.LayoutParams getPopupLayoutParams() { - return popupLayoutParams; + public MediaItemTag getCurrentMetadata() { + return currentMetadata; } @Nullable - public WindowManager getWindowManager() { - return windowManager; + public PlayQueueItem getCurrentItem() { + return currentItem; } - public float getScreenWidth() { - return screenWidth; + public Optional getFragmentListener() { + return Optional.ofNullable(fragmentListener); } - public float getScreenHeight() { - return screenHeight; + /** + * @return the user interfaces connected with the player + */ + @SuppressWarnings("MethodName") // keep the unusual method name + public PlayerUiList UIs() { + return UIs; } - public View getRootView() { - return binding.getRoot(); - } - - public ExpandableSurfaceView getSurfaceView() { - return binding.surfaceView; - } - - public PlayQueueAdapter getPlayQueueAdapter() { - return playQueueAdapter; - } - - public PlayerBinding getBinding() { - return binding; - } - - //endregion - - - /*////////////////////////////////////////////////////////////////////////// - // SurfaceHolderCallback helpers - //////////////////////////////////////////////////////////////////////////*/ - //region SurfaceHolderCallback helpers - - private void setupVideoSurface() { - // make sure there is nothing left over from previous calls - cleanupVideoSurface(); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // >=API23 - surfaceHolderCallback = new SurfaceHolderCallback(context, simpleExoPlayer); - binding.surfaceView.getHolder().addCallback(surfaceHolderCallback); - final Surface surface = binding.surfaceView.getHolder().getSurface(); - // ensure player is using an unreleased surface, which the surfaceView might not be - // when starting playback on background or during player switching - if (surface.isValid()) { - // initially set the surface manually otherwise - // onRenderedFirstFrame() will not be called - simpleExoPlayer.setVideoSurface(surface); - } - } else { - simpleExoPlayer.setVideoSurfaceView(binding.surfaceView); - } - } - - private void cleanupVideoSurface() { - // Only for API >= 23 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && surfaceHolderCallback != null) { - if (binding != null) { - binding.surfaceView.getHolder().removeCallback(surfaceHolderCallback); - } - surfaceHolderCallback.release(); - surfaceHolderCallback = null; - } - } - //endregion - /** * Get the video renderer index of the current playing stream. - * + *

* This method returns the video renderer index of the current * {@link MappingTrackSelector.MappedTrackInfo} or {@link #RENDERER_UNAVAILABLE} if the current * {@link MappingTrackSelector.MappedTrackInfo} is null or if there is no video renderer index. @@ -4593,4 +2337,5 @@ public final class Player implements // No video renderer index with at least one track found: return unavailable index .orElse(RENDERER_UNAVAILABLE); } + //endregion } diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java new file mode 100644 index 000000000..e7abf4320 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java @@ -0,0 +1,185 @@ +/* + * Copyright 2017 Mauricio Colli + * Part of NewPipe + * + * License: GPL-3.0+ + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.schabi.newpipe.player; + +import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.os.Binder; +import android.os.IBinder; +import android.util.Log; + +import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi; +import org.schabi.newpipe.player.notification.NotificationPlayerUi; +import org.schabi.newpipe.util.ThemeHelper; + +import java.lang.ref.WeakReference; + + +/** + * One service for all players. + */ +public final class PlayerService extends Service { + private static final String TAG = PlayerService.class.getSimpleName(); + private static final boolean DEBUG = Player.DEBUG; + + private Player player; + + private final IBinder mBinder = new PlayerService.LocalBinder(this); + + + /*////////////////////////////////////////////////////////////////////////// + // Service's LifeCycle + //////////////////////////////////////////////////////////////////////////*/ + + @Override + public void onCreate() { + if (DEBUG) { + Log.d(TAG, "onCreate() called"); + } + assureCorrectAppLanguage(this); + ThemeHelper.setTheme(this); + + player = new Player(this); + /* + Create the player notification and start immediately the service in foreground, + otherwise if nothing is played or initializing the player and its components (especially + loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the + service would never be put in the foreground while we said to the system we would do so + */ + player.UIs().get(NotificationPlayerUi.class) + .ifPresent(NotificationPlayerUi::createNotificationAndStartForeground); + } + + @Override + public int onStartCommand(final Intent intent, final int flags, final int startId) { + if (DEBUG) { + Log.d(TAG, "onStartCommand() called with: intent = [" + intent + + "], flags = [" + flags + "], startId = [" + startId + "]"); + } + + /* + Be sure that the player notification is set and the service is started in foreground, + otherwise, the app may crash on Android 8+ as the service would never be put in the + foreground while we said to the system we would do so + The service is always requested to be started in foreground, so always creating a + notification if there is no one already and starting the service in foreground should + not create any issues + If the service is already started in foreground, requesting it to be started shouldn't + do anything + */ + if (player != null) { + player.UIs().get(NotificationPlayerUi.class) + .ifPresent(NotificationPlayerUi::createNotificationAndStartForeground); + } + + if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction()) + && (player == null || player.getPlayQueue() == null)) { + /* + No need to process media button's actions if the player is not working, otherwise + the player service would strangely start with nothing to play + Stop the service in this case, which will be removed from the foreground and its + notification cancelled in its destruction + */ + stopSelf(); + return START_NOT_STICKY; + } + + if (player != null) { + player.handleIntent(intent); + player.UIs().get(MediaSessionPlayerUi.class) + .ifPresent(ui -> ui.handleMediaButtonIntent(intent)); + } + + return START_NOT_STICKY; + } + + public void stopForImmediateReusing() { + if (DEBUG) { + Log.d(TAG, "stopForImmediateReusing() called"); + } + + if (player != null && !player.exoPlayerIsNull()) { + // Releases wifi & cpu, disables keepScreenOn, etc. + // We can't just pause the player here because it will make transition + // from one stream to a new stream not smooth + player.smoothStopForImmediateReusing(); + } + } + + @Override + public void onTaskRemoved(final Intent rootIntent) { + super.onTaskRemoved(rootIntent); + if (player != null && !player.videoPlayerSelected()) { + return; + } + onDestroy(); + // Unload from memory completely + Runtime.getRuntime().halt(0); + } + + @Override + public void onDestroy() { + if (DEBUG) { + Log.d(TAG, "destroy() called"); + } + cleanup(); + } + + private void cleanup() { + if (player != null) { + player.destroy(); + player = null; + } + } + + public void stopService() { + cleanup(); + stopSelf(); + } + + @Override + protected void attachBaseContext(final Context base) { + super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base)); + } + + @Override + public IBinder onBind(final Intent intent) { + return mBinder; + } + + public static class LocalBinder extends Binder { + private final WeakReference playerService; + + LocalBinder(final PlayerService playerService) { + this.playerService = new WeakReference<>(playerService); + } + + public PlayerService getService() { + return playerService.get(); + } + + public Player getPlayer() { + return playerService.get().player; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerServiceBinder.java b/app/src/main/java/org/schabi/newpipe/player/PlayerServiceBinder.java deleted file mode 100644 index 5c28c6c7b..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerServiceBinder.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.schabi.newpipe.player; - -import android.os.Binder; - -import androidx.annotation.NonNull; - -class PlayerServiceBinder extends Binder { - private final Player player; - - PlayerServiceBinder(@NonNull final Player player) { - this.player = player; - } - - Player getPlayerInstance() { - return player; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerState.java b/app/src/main/java/org/schabi/newpipe/player/PlayerState.java deleted file mode 100644 index af875a32b..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/PlayerState.java +++ /dev/null @@ -1,79 +0,0 @@ -package org.schabi.newpipe.player; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.schabi.newpipe.player.playqueue.PlayQueue; - -import java.io.Serializable; - -public class PlayerState implements Serializable { - - @NonNull - private final PlayQueue playQueue; - private final int repeatMode; - private final float playbackSpeed; - private final float playbackPitch; - @Nullable - private final String playbackQuality; - private final boolean playbackSkipSilence; - private final boolean wasPlaying; - - PlayerState(@NonNull final PlayQueue playQueue, final int repeatMode, - final float playbackSpeed, final float playbackPitch, - final boolean playbackSkipSilence, final boolean wasPlaying) { - this(playQueue, repeatMode, playbackSpeed, playbackPitch, null, - playbackSkipSilence, wasPlaying); - } - - PlayerState(@NonNull final PlayQueue playQueue, final int repeatMode, - final float playbackSpeed, final float playbackPitch, - @Nullable final String playbackQuality, final boolean playbackSkipSilence, - final boolean wasPlaying) { - this.playQueue = playQueue; - this.repeatMode = repeatMode; - this.playbackSpeed = playbackSpeed; - this.playbackPitch = playbackPitch; - this.playbackQuality = playbackQuality; - this.playbackSkipSilence = playbackSkipSilence; - this.wasPlaying = wasPlaying; - } - - /*////////////////////////////////////////////////////////////////////////// - // Serdes - //////////////////////////////////////////////////////////////////////////*/ - - /*////////////////////////////////////////////////////////////////////////// - // Getters - //////////////////////////////////////////////////////////////////////////*/ - - @NonNull - public PlayQueue getPlayQueue() { - return playQueue; - } - - public int getRepeatMode() { - return repeatMode; - } - - public float getPlaybackSpeed() { - return playbackSpeed; - } - - public float getPlaybackPitch() { - return playbackPitch; - } - - @Nullable - public String getPlaybackQuality() { - return playbackQuality; - } - - public boolean isPlaybackSkipSilence() { - return playbackSkipSilence; - } - - public boolean wasPlaying() { - return wasPlaying; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerType.java b/app/src/main/java/org/schabi/newpipe/player/PlayerType.java new file mode 100644 index 000000000..171a70395 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/PlayerType.java @@ -0,0 +1,32 @@ +package org.schabi.newpipe.player; + +import static org.schabi.newpipe.player.Player.PLAYER_TYPE; + +import android.content.Intent; + +public enum PlayerType { + MAIN, + AUDIO, + POPUP; + + /** + * @return an integer representing this {@link PlayerType}, to be used to save it in intents + * @see #retrieveFromIntent(Intent) Use retrieveFromIntent() to retrieve and convert player type + * integers from an intent + */ + public int valueForIntent() { + return ordinal(); + } + + /** + * @param intent the intent to retrieve a player type from + * @return the player type integer retrieved from the intent, converted back into a {@link + * PlayerType}, or {@link PlayerType#MAIN} if there is no player type extra in the + * intent + * @throws ArrayIndexOutOfBoundsException if the intent contains an invalid player type integer + * @see #valueForIntent() Use valueForIntent() to obtain valid player type integers + */ + public static PlayerType retrieveFromIntent(final Intent intent) { + return values()[intent.getIntExtra(PLAYER_TYPE, MAIN.valueForIntent())]; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java b/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java new file mode 100644 index 000000000..676443a9c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/NonUriHlsDataSourceFactory.java @@ -0,0 +1,136 @@ +package org.schabi.newpipe.player.datasource; + +import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; + +import androidx.annotation.NonNull; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.source.hls.HlsDataSourceFactory; +import com.google.android.exoplayer2.upstream.ByteArrayDataSource; +import com.google.android.exoplayer2.upstream.DataSource; + +import java.nio.charset.StandardCharsets; + +/** + * A {@link HlsDataSourceFactory} which allows playback of non-URI media HLS playlists for + * {@link com.google.android.exoplayer2.source.hls.HlsMediaSource HlsMediaSource}s. + * + *

+ * If media requests are relative, the URI from which the manifest comes from (either the + * manifest URI (preferred) or the master URI (if applicable)) must be returned, otherwise the + * content will be not playable, as it will be an invalid URL, or it may be treat as something + * unexpected, for instance as a file for + * {@link com.google.android.exoplayer2.upstream.DefaultDataSource DefaultDataSource}s. + *

+ * + *

+ * See {@link #createDataSource(int)} for changes and implementation details. + *

+ */ +public final class NonUriHlsDataSourceFactory implements HlsDataSourceFactory { + + /** + * Builder class of {@link NonUriHlsDataSourceFactory} instances. + */ + public static final class Builder { + private DataSource.Factory dataSourceFactory; + private String playlistString; + + /** + * Set the {@link DataSource.Factory} which will be used to create non manifest contents + * {@link DataSource}s. + * + * @param dataSourceFactoryForNonManifestContents the {@link DataSource.Factory} which will + * be used to create non manifest contents + * {@link DataSource}s, which cannot be null + */ + public void setDataSourceFactory( + @NonNull final DataSource.Factory dataSourceFactoryForNonManifestContents) { + this.dataSourceFactory = dataSourceFactoryForNonManifestContents; + } + + /** + * Set the HLS playlist which will be used for manifests requests. + * + * @param hlsPlaylistString the string which correspond to the response of the HLS + * manifest, which cannot be null or empty + */ + public void setPlaylistString(@NonNull final String hlsPlaylistString) { + this.playlistString = hlsPlaylistString; + } + + /** + * Create a new {@link NonUriHlsDataSourceFactory} with the given data source factory and + * the given HLS playlist. + * + * @return a {@link NonUriHlsDataSourceFactory} + * @throws IllegalArgumentException if the data source factory is null or if the HLS + * playlist string set is null or empty + */ + @NonNull + public NonUriHlsDataSourceFactory build() { + if (dataSourceFactory == null) { + throw new IllegalArgumentException( + "No DataSource.Factory valid instance has been specified."); + } + + if (isNullOrEmpty(playlistString)) { + throw new IllegalArgumentException("No HLS valid playlist has been specified."); + } + + return new NonUriHlsDataSourceFactory(dataSourceFactory, + playlistString.getBytes(StandardCharsets.UTF_8)); + } + } + + private final DataSource.Factory dataSourceFactory; + private final byte[] playlistStringByteArray; + + /** + * Create a {@link NonUriHlsDataSourceFactory} instance. + * + * @param dataSourceFactory the {@link DataSource.Factory} which will be used to build + * non manifests {@link DataSource}s, which must not be null + * @param playlistStringByteArray a byte array of the HLS playlist, which must not be null + */ + private NonUriHlsDataSourceFactory(@NonNull final DataSource.Factory dataSourceFactory, + @NonNull final byte[] playlistStringByteArray) { + this.dataSourceFactory = dataSourceFactory; + this.playlistStringByteArray = playlistStringByteArray; + } + + /** + * Create a {@link DataSource} for the given data type. + * + *

+ * Contrary to {@link com.google.android.exoplayer2.source.hls.DefaultHlsDataSourceFactory + * ExoPlayer's default implementation}, this implementation is not always using the + * {@link DataSource.Factory} passed to the + * {@link com.google.android.exoplayer2.source.hls.HlsMediaSource.Factory + * HlsMediaSource.Factory} constructor, only when it's not + * {@link C#DATA_TYPE_MANIFEST the manifest type}. + *

+ * + *

+ * This change allow playback of non-URI HLS contents, when the manifest is not a master + * manifest/playlist (otherwise, endless loops should be encountered because the + * {@link DataSource}s created for media playlists should use the master playlist response + * instead). + *

+ * + * @param dataType the data type for which the {@link DataSource} will be used, which is one of + * {@link C} {@code .DATA_TYPE_*} constants + * @return a {@link DataSource} for the given data type + */ + @NonNull + @Override + public DataSource createDataSource(final int dataType) { + // The manifest is already downloaded and provided with playlistStringByteArray, so we + // don't need to download it again and we can use a ByteArrayDataSource instead + if (dataType == C.DATA_TYPE_MANIFEST) { + return new ByteArrayDataSource(playlistStringByteArray); + } + + return dataSourceFactory.createDataSource(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java b/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java new file mode 100644 index 000000000..cf1f03b45 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/datasource/YoutubeHttpDataSource.java @@ -0,0 +1,1014 @@ +/* + * Based on ExoPlayer's DefaultHttpDataSource, version 2.18.1. + * + * Original source code copyright (C) 2016 The Android Open Source Project, licensed under the + * Apache License, Version 2.0. + */ + +package org.schabi.newpipe.player.datasource; + +import static com.google.android.exoplayer2.upstream.DefaultHttpDataSource.DEFAULT_CONNECT_TIMEOUT_MILLIS; +import static com.google.android.exoplayer2.upstream.DefaultHttpDataSource.DEFAULT_READ_TIMEOUT_MILLIS; +import static com.google.android.exoplayer2.upstream.HttpUtil.buildRangeRequestHeader; +import static com.google.android.exoplayer2.util.Assertions.checkNotNull; +import static com.google.android.exoplayer2.util.Util.castNonNull; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getAndroidUserAgent; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getIosUserAgent; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isAndroidStreamingUrl; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isIosStreamingUrl; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebStreamingUrl; +import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isTvHtml5SimplyEmbeddedPlayerStreamingUrl; +import static java.lang.Math.min; + +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.PlaybackException; +import com.google.android.exoplayer2.upstream.BaseDataSource; +import com.google.android.exoplayer2.upstream.DataSource; +import com.google.android.exoplayer2.upstream.DataSourceException; +import com.google.android.exoplayer2.upstream.DataSpec; +import com.google.android.exoplayer2.upstream.DataSpec.HttpMethod; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; +import com.google.android.exoplayer2.upstream.HttpDataSource; +import com.google.android.exoplayer2.upstream.HttpUtil; +import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.util.Log; +import com.google.android.exoplayer2.util.Util; +import com.google.common.base.Predicate; +import com.google.common.collect.ForwardingMap; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Sets; +import com.google.common.net.HttpHeaders; + +import org.schabi.newpipe.DownloaderImpl; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InterruptedIOException; +import java.io.OutputStream; +import java.lang.reflect.Method; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.NoRouteToHostException; +import java.net.URL; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.zip.GZIPInputStream; + +/** + * An {@link HttpDataSource} that uses Android's {@link HttpURLConnection}, based on + * {@link com.google.android.exoplayer2.upstream.DefaultHttpDataSource}, for YouTube streams. + * + *

+ * It adds more headers to {@code videoplayback} URLs, such as {@code Origin}, {@code Referer} + * (only where it's relevant) and also more parameters, such as {@code rn} and replaces the use of + * the {@code Range} header by the corresponding parameter ({@code range}), if enabled. + *

+ * + * There are many unused methods in this class because everything was copied from {@link + * com.google.android.exoplayer2.upstream.DefaultHttpDataSource} with as little changes as possible. + * SonarQube warnings were also suppressed for the same reason. + */ +@SuppressWarnings({"squid:S3011", "squid:S4738"}) +public final class YoutubeHttpDataSource extends BaseDataSource implements HttpDataSource { + + /** + * {@link DataSource.Factory} for {@link YoutubeHttpDataSource} instances. + */ + public static final class Factory implements HttpDataSource.Factory { + + private final RequestProperties defaultRequestProperties; + + @Nullable + private TransferListener transferListener; + @Nullable + private Predicate contentTypePredicate; + private int connectTimeoutMs; + private int readTimeoutMs; + private boolean allowCrossProtocolRedirects; + private boolean keepPostFor302Redirects; + + private boolean rangeParameterEnabled; + private boolean rnParameterEnabled; + + /** + * Creates an instance. + */ + public Factory() { + defaultRequestProperties = new RequestProperties(); + connectTimeoutMs = DEFAULT_CONNECT_TIMEOUT_MILLIS; + readTimeoutMs = DEFAULT_READ_TIMEOUT_MILLIS; + } + + @NonNull + @Override + public Factory setDefaultRequestProperties( + @NonNull final Map defaultRequestPropertiesMap) { + defaultRequestProperties.clearAndSet(defaultRequestPropertiesMap); + return this; + } + + /** + * Sets the connect timeout, in milliseconds. + * + *

+ * The default is {@link DefaultHttpDataSource#DEFAULT_CONNECT_TIMEOUT_MILLIS}. + *

+ * + * @param connectTimeoutMsValue The connect timeout, in milliseconds, that will be used. + * @return This factory. + */ + public Factory setConnectTimeoutMs(final int connectTimeoutMsValue) { + connectTimeoutMs = connectTimeoutMsValue; + return this; + } + + /** + * Sets the read timeout, in milliseconds. + * + *

The default is {@link DefaultHttpDataSource#DEFAULT_READ_TIMEOUT_MILLIS}. + * + * @param readTimeoutMsValue The connect timeout, in milliseconds, that will be used. + * @return This factory. + */ + public Factory setReadTimeoutMs(final int readTimeoutMsValue) { + readTimeoutMs = readTimeoutMsValue; + return this; + } + + /** + * Sets whether to allow cross protocol redirects. + * + *

The default is {@code false}. + * + * @param allowCrossProtocolRedirectsValue Whether to allow cross protocol redirects. + * @return This factory. + */ + public Factory setAllowCrossProtocolRedirects( + final boolean allowCrossProtocolRedirectsValue) { + allowCrossProtocolRedirects = allowCrossProtocolRedirectsValue; + return this; + } + + /** + * Sets whether the use of the {@code range} parameter instead of the {@code Range} header + * to request ranges of streams is enabled. + * + *

+ * Note that it must be not enabled on streams which are using a {@link + * com.google.android.exoplayer2.source.ProgressiveMediaSource}, as it will break playback + * for them (some exceptions may be thrown). + *

+ * + * @param rangeParameterEnabledValue whether the use of the {@code range} parameter instead + * of the {@code Range} header (must be only enabled when + * non-{@code ProgressiveMediaSource}s) + * @return This factory. + */ + public Factory setRangeParameterEnabled(final boolean rangeParameterEnabledValue) { + rangeParameterEnabled = rangeParameterEnabledValue; + return this; + } + + /** + * Sets whether the use of the {@code rn}, which stands for request number, parameter is + * enabled. + * + *

+ * Note that it should be not enabled on streams which are using {@code /} to delimit URLs + * parameters, such as the streams of HLS manifests. + *

+ * + * @param rnParameterEnabledValue whether the appending the {@code rn} parameter to + * {@code videoplayback} URLs + * @return This factory. + */ + public Factory setRnParameterEnabled(final boolean rnParameterEnabledValue) { + rnParameterEnabled = rnParameterEnabledValue; + return this; + } + + /** + * Sets a content type {@link Predicate}. If a content type is rejected by the predicate + * then a {@link HttpDataSource.InvalidContentTypeException} is thrown from + * {@link YoutubeHttpDataSource#open(DataSpec)}. + * + *

+ * The default is {@code null}. + *

+ * + * @param contentTypePredicateToSet The content type {@link Predicate}, or {@code null} to + * clear a predicate that was previously set. + * @return This factory. + */ + public Factory setContentTypePredicate( + @Nullable final Predicate contentTypePredicateToSet) { + this.contentTypePredicate = contentTypePredicateToSet; + return this; + } + + /** + * Sets the {@link TransferListener} that will be used. + * + *

The default is {@code null}. + * + *

See {@link DataSource#addTransferListener(TransferListener)}. + * + * @param transferListenerToUse The listener that will be used. + * @return This factory. + */ + public Factory setTransferListener( + @Nullable final TransferListener transferListenerToUse) { + this.transferListener = transferListenerToUse; + return this; + } + + /** + * Sets whether we should keep the POST method and body when we have HTTP 302 redirects for + * a POST request. + * + * @param keepPostFor302RedirectsValue Whether we should keep the POST method and body when + * we have HTTP 302 redirects for a POST request. + * @return This factory. + */ + public Factory setKeepPostFor302Redirects(final boolean keepPostFor302RedirectsValue) { + this.keepPostFor302Redirects = keepPostFor302RedirectsValue; + return this; + } + + @NonNull + @Override + public YoutubeHttpDataSource createDataSource() { + final YoutubeHttpDataSource dataSource = new YoutubeHttpDataSource( + connectTimeoutMs, + readTimeoutMs, + allowCrossProtocolRedirects, + rangeParameterEnabled, + rnParameterEnabled, + defaultRequestProperties, + contentTypePredicate, + keepPostFor302Redirects); + if (transferListener != null) { + dataSource.addTransferListener(transferListener); + } + return dataSource; + } + } + + private static final String TAG = YoutubeHttpDataSource.class.getSimpleName(); + private static final int MAX_REDIRECTS = 20; // Same limit as okhttp. + private static final int HTTP_STATUS_TEMPORARY_REDIRECT = 307; + private static final int HTTP_STATUS_PERMANENT_REDIRECT = 308; + private static final long MAX_BYTES_TO_DRAIN = 2048; + + private static final String RN_PARAMETER = "&rn="; + private static final String YOUTUBE_BASE_URL = "https://www.youtube.com"; + + private final boolean allowCrossProtocolRedirects; + private final boolean rangeParameterEnabled; + private final boolean rnParameterEnabled; + + private final int connectTimeoutMillis; + private final int readTimeoutMillis; + @Nullable + private final RequestProperties defaultRequestProperties; + private final RequestProperties requestProperties; + private final boolean keepPostFor302Redirects; + + @Nullable + private final Predicate contentTypePredicate; + @Nullable + private DataSpec dataSpec; + @Nullable + private HttpURLConnection connection; + @Nullable + private InputStream inputStream; + private boolean opened; + private int responseCode; + private long bytesToRead; + private long bytesRead; + + private long requestNumber; + + @SuppressWarnings("checkstyle:ParameterNumber") + private YoutubeHttpDataSource(final int connectTimeoutMillis, + final int readTimeoutMillis, + final boolean allowCrossProtocolRedirects, + final boolean rangeParameterEnabled, + final boolean rnParameterEnabled, + @Nullable final RequestProperties defaultRequestProperties, + @Nullable final Predicate contentTypePredicate, + final boolean keepPostFor302Redirects) { + super(true); + this.connectTimeoutMillis = connectTimeoutMillis; + this.readTimeoutMillis = readTimeoutMillis; + this.allowCrossProtocolRedirects = allowCrossProtocolRedirects; + this.rangeParameterEnabled = rangeParameterEnabled; + this.rnParameterEnabled = rnParameterEnabled; + this.defaultRequestProperties = defaultRequestProperties; + this.contentTypePredicate = contentTypePredicate; + this.requestProperties = new RequestProperties(); + this.keepPostFor302Redirects = keepPostFor302Redirects; + this.requestNumber = 0; + } + + @Override + @Nullable + public Uri getUri() { + return connection == null ? null : Uri.parse(connection.getURL().toString()); + } + + @Override + public int getResponseCode() { + return connection == null || responseCode <= 0 ? -1 : responseCode; + } + + @NonNull + @Override + public Map> getResponseHeaders() { + if (connection == null) { + return ImmutableMap.of(); + } + // connection.getHeaderFields() always contains a null key with a value like + // ["HTTP/1.1 200 OK"]. The response code is available from + // HttpURLConnection#getResponseCode() and the HTTP version is fixed when establishing the + // connection. + // DataSource#getResponseHeaders() doesn't allow null keys in the returned map, so we need + // to remove it. + // connection.getHeaderFields() returns a special unmodifiable case-insensitive Map + // so we can't just remove the null key or make a copy without the null key. Instead we + // wrap it in a ForwardingMap subclass that ignores and filters out null keys in the read + // methods. + return new NullFilteringHeadersMap(connection.getHeaderFields()); + } + + @Override + public void setRequestProperty(@NonNull final String name, @NonNull final String value) { + checkNotNull(name); + checkNotNull(value); + requestProperties.set(name, value); + } + + @Override + public void clearRequestProperty(@NonNull final String name) { + checkNotNull(name); + requestProperties.remove(name); + } + + @Override + public void clearAllRequestProperties() { + requestProperties.clear(); + } + + /** + * Opens the source to read the specified data. + */ + @Override + public long open(@NonNull final DataSpec dataSpecParameter) throws HttpDataSourceException { + this.dataSpec = dataSpecParameter; + bytesRead = 0; + bytesToRead = 0; + transferInitializing(dataSpecParameter); + + final HttpURLConnection httpURLConnection; + final String responseMessage; + try { + this.connection = makeConnection(dataSpec); + httpURLConnection = this.connection; + responseCode = httpURLConnection.getResponseCode(); + responseMessage = httpURLConnection.getResponseMessage(); + } catch (final IOException e) { + closeConnectionQuietly(); + throw HttpDataSourceException.createForIOException(e, dataSpec, + HttpDataSourceException.TYPE_OPEN); + } + + // Check for a valid response code. + if (responseCode < 200 || responseCode > 299) { + final Map> headers = httpURLConnection.getHeaderFields(); + if (responseCode == 416) { + final long documentSize = HttpUtil.getDocumentSize( + httpURLConnection.getHeaderField(HttpHeaders.CONTENT_RANGE)); + if (dataSpecParameter.position == documentSize) { + opened = true; + transferStarted(dataSpecParameter); + return dataSpecParameter.length != C.LENGTH_UNSET + ? dataSpecParameter.length + : 0; + } + } + + final InputStream errorStream = httpURLConnection.getErrorStream(); + byte[] errorResponseBody; + try { + errorResponseBody = errorStream != null + ? Util.toByteArray(errorStream) + : Util.EMPTY_BYTE_ARRAY; + } catch (final IOException e) { + errorResponseBody = Util.EMPTY_BYTE_ARRAY; + } + + closeConnectionQuietly(); + final IOException cause = responseCode == 416 ? new DataSourceException( + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE) + : null; + throw new InvalidResponseCodeException(responseCode, responseMessage, cause, headers, + dataSpec, errorResponseBody); + } + + // Check for a valid content type. + final String contentType = httpURLConnection.getContentType(); + if (contentTypePredicate != null && !contentTypePredicate.apply(contentType)) { + closeConnectionQuietly(); + throw new InvalidContentTypeException(contentType, dataSpecParameter); + } + + final long bytesToSkip; + if (!rangeParameterEnabled) { + // If we requested a range starting from a non-zero position and received a 200 rather + // than a 206, then the server does not support partial requests. We'll need to + // manually skip to the requested position. + bytesToSkip = responseCode == 200 && dataSpecParameter.position != 0 + ? dataSpecParameter.position + : 0; + } else { + bytesToSkip = 0; + } + + + // Determine the length of the data to be read, after skipping. + final boolean isCompressed = isCompressed(httpURLConnection); + if (!isCompressed) { + if (dataSpecParameter.length != C.LENGTH_UNSET) { + bytesToRead = dataSpecParameter.length; + } else { + final long contentLength = HttpUtil.getContentLength( + httpURLConnection.getHeaderField(HttpHeaders.CONTENT_LENGTH), + httpURLConnection.getHeaderField(HttpHeaders.CONTENT_RANGE)); + bytesToRead = contentLength != C.LENGTH_UNSET + ? (contentLength - bytesToSkip) + : C.LENGTH_UNSET; + } + } else { + // Gzip is enabled. If the server opts to use gzip then the content length in the + // response will be that of the compressed data, which isn't what we want. Always use + // the dataSpec length in this case. + bytesToRead = dataSpecParameter.length; + } + + try { + inputStream = httpURLConnection.getInputStream(); + if (isCompressed) { + inputStream = new GZIPInputStream(inputStream); + } + } catch (final IOException e) { + closeConnectionQuietly(); + throw new HttpDataSourceException(e, dataSpec, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN); + } + + opened = true; + transferStarted(dataSpecParameter); + + try { + skipFully(bytesToSkip, dataSpec); + } catch (final IOException e) { + closeConnectionQuietly(); + if (e instanceof HttpDataSourceException) { + throw (HttpDataSourceException) e; + } + throw new HttpDataSourceException(e, dataSpec, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN); + } + + return bytesToRead; + } + + @Override + public int read(@NonNull final byte[] buffer, final int offset, final int length) + throws HttpDataSourceException { + try { + return readInternal(buffer, offset, length); + } catch (final IOException e) { + throw HttpDataSourceException.createForIOException(e, castNonNull(dataSpec), + HttpDataSourceException.TYPE_READ); + } + } + + @Override + public void close() throws HttpDataSourceException { + try { + final InputStream connectionInputStream = this.inputStream; + if (connectionInputStream != null) { + final long bytesRemaining = bytesToRead == C.LENGTH_UNSET + ? C.LENGTH_UNSET + : bytesToRead - bytesRead; + maybeTerminateInputStream(connection, bytesRemaining); + + try { + connectionInputStream.close(); + } catch (final IOException e) { + throw new HttpDataSourceException(e, castNonNull(dataSpec), + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_CLOSE); + } + } + } finally { + inputStream = null; + closeConnectionQuietly(); + if (opened) { + opened = false; + transferEnded(); + } + } + } + + @NonNull + private HttpURLConnection makeConnection(@NonNull final DataSpec dataSpecToUse) + throws IOException { + URL url = new URL(dataSpecToUse.uri.toString()); + @HttpMethod int httpMethod = dataSpecToUse.httpMethod; + @Nullable byte[] httpBody = dataSpecToUse.httpBody; + final long position = dataSpecToUse.position; + final long length = dataSpecToUse.length; + final boolean allowGzip = dataSpecToUse.isFlagSet(DataSpec.FLAG_ALLOW_GZIP); + + if (!allowCrossProtocolRedirects && !keepPostFor302Redirects) { + // HttpURLConnection disallows cross-protocol redirects, but otherwise performs + // redirection automatically. This is the behavior we want, so use it. + return makeConnection(url, httpMethod, httpBody, position, length, allowGzip, true, + dataSpecToUse.httpRequestHeaders); + } + + // We need to handle redirects ourselves to allow cross-protocol redirects or to keep the + // POST request method for 302. + int redirectCount = 0; + while (redirectCount++ <= MAX_REDIRECTS) { + final HttpURLConnection httpURLConnection = makeConnection(url, httpMethod, httpBody, + position, length, allowGzip, false, dataSpecToUse.httpRequestHeaders); + final int httpURLConnectionResponseCode = httpURLConnection.getResponseCode(); + final String location = httpURLConnection.getHeaderField("Location"); + if ((httpMethod == DataSpec.HTTP_METHOD_GET || httpMethod == DataSpec.HTTP_METHOD_HEAD) + && (httpURLConnectionResponseCode == HttpURLConnection.HTTP_MULT_CHOICE + || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_PERM + || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_TEMP + || httpURLConnectionResponseCode == HttpURLConnection.HTTP_SEE_OTHER + || httpURLConnectionResponseCode == HTTP_STATUS_TEMPORARY_REDIRECT + || httpURLConnectionResponseCode == HTTP_STATUS_PERMANENT_REDIRECT)) { + httpURLConnection.disconnect(); + url = handleRedirect(url, location, dataSpecToUse); + } else if (httpMethod == DataSpec.HTTP_METHOD_POST + && (httpURLConnectionResponseCode == HttpURLConnection.HTTP_MULT_CHOICE + || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_PERM + || httpURLConnectionResponseCode == HttpURLConnection.HTTP_MOVED_TEMP + || httpURLConnectionResponseCode == HttpURLConnection.HTTP_SEE_OTHER)) { + httpURLConnection.disconnect(); + final boolean shouldKeepPost = keepPostFor302Redirects + && responseCode == HttpURLConnection.HTTP_MOVED_TEMP; + if (!shouldKeepPost) { + // POST request follows the redirect and is transformed into a GET request. + httpMethod = DataSpec.HTTP_METHOD_GET; + httpBody = null; + } + url = handleRedirect(url, location, dataSpecToUse); + } else { + return httpURLConnection; + } + } + + // If we get here we've been redirected more times than are permitted. + throw new HttpDataSourceException( + new NoRouteToHostException("Too many redirects: " + redirectCount), + dataSpecToUse, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + + /** + * Configures a connection and opens it. + * + * @param url The url to connect to. + * @param httpMethod The http method. + * @param httpBody The body data, or {@code null} if not required. + * @param position The byte offset of the requested data. + * @param length The length of the requested data, or {@link C#LENGTH_UNSET}. + * @param allowGzip Whether to allow the use of gzip. + * @param followRedirects Whether to follow redirects. + * @param requestParameters parameters (HTTP headers) to include in request. + * @return the connection opened + */ + @SuppressWarnings("checkstyle:ParameterNumber") + @NonNull + private HttpURLConnection makeConnection( + @NonNull final URL url, + @HttpMethod final int httpMethod, + @Nullable final byte[] httpBody, + final long position, + final long length, + final boolean allowGzip, + final boolean followRedirects, + final Map requestParameters) throws IOException { + // This is the method that contains breaking changes with respect to DefaultHttpDataSource! + + String requestUrl = url.toString(); + + // Don't add the request number parameter if it has been already added (for instance in + // DASH manifests) or if that's not a videoplayback URL + final boolean isVideoPlaybackUrl = url.getPath().startsWith("/videoplayback"); + if (isVideoPlaybackUrl && rnParameterEnabled && !requestUrl.contains(RN_PARAMETER)) { + requestUrl += RN_PARAMETER + requestNumber; + ++requestNumber; + } + + if (rangeParameterEnabled && isVideoPlaybackUrl) { + final String rangeParameterBuilt = buildRangeParameter(position, length); + if (rangeParameterBuilt != null) { + requestUrl += rangeParameterBuilt; + } + } + + final HttpURLConnection httpURLConnection = openConnection(new URL(requestUrl)); + httpURLConnection.setConnectTimeout(connectTimeoutMillis); + httpURLConnection.setReadTimeout(readTimeoutMillis); + + final Map requestHeaders = new HashMap<>(); + if (defaultRequestProperties != null) { + requestHeaders.putAll(defaultRequestProperties.getSnapshot()); + } + requestHeaders.putAll(requestProperties.getSnapshot()); + requestHeaders.putAll(requestParameters); + + for (final Map.Entry property : requestHeaders.entrySet()) { + httpURLConnection.setRequestProperty(property.getKey(), property.getValue()); + } + + if (!rangeParameterEnabled) { + final String rangeHeader = buildRangeRequestHeader(position, length); + if (rangeHeader != null) { + httpURLConnection.setRequestProperty(HttpHeaders.RANGE, rangeHeader); + } + } + + if (isWebStreamingUrl(requestUrl) + || isTvHtml5SimplyEmbeddedPlayerStreamingUrl(requestUrl)) { + httpURLConnection.setRequestProperty(HttpHeaders.ORIGIN, YOUTUBE_BASE_URL); + httpURLConnection.setRequestProperty(HttpHeaders.REFERER, YOUTUBE_BASE_URL); + httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_DEST, "empty"); + httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_MODE, "cors"); + httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_SITE, "cross-site"); + } + + httpURLConnection.setRequestProperty(HttpHeaders.TE, "trailers"); + + final boolean isAndroidStreamingUrl = isAndroidStreamingUrl(requestUrl); + final boolean isIosStreamingUrl = isIosStreamingUrl(requestUrl); + if (isAndroidStreamingUrl) { + // Improvement which may be done: find the content country used to request YouTube + // contents to add it in the user agent instead of using the default + httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, + getAndroidUserAgent(null)); + } else if (isIosStreamingUrl) { + httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, + getIosUserAgent(null)); + } else { + // non-mobile user agent + httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, DownloaderImpl.USER_AGENT); + } + + httpURLConnection.setRequestProperty(HttpHeaders.ACCEPT_ENCODING, + allowGzip ? "gzip" : "identity"); + httpURLConnection.setInstanceFollowRedirects(followRedirects); + httpURLConnection.setDoOutput(httpBody != null); + + // Mobile clients uses POST requests to fetch contents + httpURLConnection.setRequestMethod(isAndroidStreamingUrl || isIosStreamingUrl + ? "POST" + : DataSpec.getStringForHttpMethod(httpMethod)); + + if (httpBody != null) { + httpURLConnection.setFixedLengthStreamingMode(httpBody.length); + httpURLConnection.connect(); + final OutputStream os = httpURLConnection.getOutputStream(); + os.write(httpBody); + os.close(); + } else { + httpURLConnection.connect(); + } + return httpURLConnection; + } + + /** + * Creates an {@link HttpURLConnection} that is connected with the {@code url}. + * + * @param url the {@link URL} to create an {@link HttpURLConnection} + * @return an {@link HttpURLConnection} created with the {@code url} + */ + private HttpURLConnection openConnection(@NonNull final URL url) throws IOException { + return (HttpURLConnection) url.openConnection(); + } + + /** + * Handles a redirect. + * + * @param originalUrl The original URL. + * @param location The Location header in the response. May be {@code null}. + * @param dataSpecToHandleRedirect The {@link DataSpec}. + * @return The next URL. + * @throws HttpDataSourceException If redirection isn't possible. + */ + @NonNull + private URL handleRedirect(final URL originalUrl, + @Nullable final String location, + final DataSpec dataSpecToHandleRedirect) + throws HttpDataSourceException { + if (location == null) { + throw new HttpDataSourceException("Null location redirect", dataSpecToHandleRedirect, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + + // Form the new url. + final URL url; + try { + url = new URL(originalUrl, location); + } catch (final MalformedURLException e) { + throw new HttpDataSourceException(e, dataSpecToHandleRedirect, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + + // Check that the protocol of the new url is supported. + final String protocol = url.getProtocol(); + if (!"https".equals(protocol) && !"http".equals(protocol)) { + throw new HttpDataSourceException("Unsupported protocol redirect: " + protocol, + dataSpecToHandleRedirect, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + + if (!allowCrossProtocolRedirects && !protocol.equals(originalUrl.getProtocol())) { + throw new HttpDataSourceException( + "Disallowed cross-protocol redirect (" + + originalUrl.getProtocol() + + " to " + + protocol + + ")", + dataSpecToHandleRedirect, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED, + HttpDataSourceException.TYPE_OPEN); + } + + return url; + } + + /** + * Attempts to skip the specified number of bytes in full. + * + * @param bytesToSkip The number of bytes to skip. + * @param dataSpecToUse The {@link DataSpec}. + * @throws IOException If the thread is interrupted during the operation, or if the data ended + * before skipping the specified number of bytes. + */ + @SuppressWarnings("checkstyle:FinalParameters") + private void skipFully(long bytesToSkip, final DataSpec dataSpecToUse) throws IOException { + if (bytesToSkip == 0) { + return; + } + + final byte[] skipBuffer = new byte[4096]; + while (bytesToSkip > 0) { + final int readLength = (int) min(bytesToSkip, skipBuffer.length); + final int read = castNonNull(inputStream).read(skipBuffer, 0, readLength); + if (Thread.currentThread().isInterrupted()) { + throw new HttpDataSourceException( + new InterruptedIOException(), + dataSpecToUse, + PlaybackException.ERROR_CODE_IO_UNSPECIFIED, + HttpDataSourceException.TYPE_OPEN); + } + + if (read == -1) { + throw new HttpDataSourceException( + dataSpecToUse, + PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE, + HttpDataSourceException.TYPE_OPEN); + } + + bytesToSkip -= read; + bytesTransferred(read); + } + } + + /** + * Reads up to {@code length} bytes of data and stores them into {@code buffer}, starting at + * index {@code offset}. + * + *

+ * This method blocks until at least one byte of data can be read, the end of the opened range + * is detected, or an exception is thrown. + *

+ * + * @param buffer The buffer into which the read data should be stored. + * @param offset The start offset into {@code buffer} at which data should be written. + * @param readLength The maximum number of bytes to read. + * @return The number of bytes read, or {@link C#RESULT_END_OF_INPUT} if the end of the opened + * range is reached. + * @throws IOException If an error occurs reading from the source. + */ + @SuppressWarnings("checkstyle:FinalParameters") + private int readInternal(final byte[] buffer, final int offset, int readLength) + throws IOException { + if (readLength == 0) { + return 0; + } + if (bytesToRead != C.LENGTH_UNSET) { + final long bytesRemaining = bytesToRead - bytesRead; + if (bytesRemaining == 0) { + return C.RESULT_END_OF_INPUT; + } + readLength = (int) min(readLength, bytesRemaining); + } + + final int read = castNonNull(inputStream).read(buffer, offset, readLength); + if (read == -1) { + return C.RESULT_END_OF_INPUT; + } + + bytesRead += read; + bytesTransferred(read); + return read; + } + + /** + * On platform API levels 19 and 20, okhttp's implementation of {@link InputStream#close} can + * block for a long time if the stream has a lot of data remaining. Call this method before + * closing the input stream to make a best effort to cause the input stream to encounter an + * unexpected end of input, working around this issue. On other platform API levels, the method + * does nothing. + * + * @param connection The connection whose {@link InputStream} should be terminated. + * @param bytesRemaining The number of bytes remaining to be read from the input stream if its + * length is known. {@link C#LENGTH_UNSET} otherwise. + */ + private static void maybeTerminateInputStream(@Nullable final HttpURLConnection connection, + final long bytesRemaining) { + if (connection == null || Util.SDK_INT < 19 || Util.SDK_INT > 20) { + return; + } + + try { + final InputStream inputStream = connection.getInputStream(); + if (bytesRemaining == C.LENGTH_UNSET) { + // If the input stream has already ended, do nothing. The socket may be re-used. + if (inputStream.read() == -1) { + return; + } + } else if (bytesRemaining <= MAX_BYTES_TO_DRAIN) { + // There isn't much data left. Prefer to allow it to drain, which may allow the + // socket to be re-used. + return; + } + final String className = inputStream.getClass().getName(); + if ("com.android.okhttp.internal.http.HttpTransport$ChunkedInputStream" + .equals(className) + || "com.android.okhttp.internal.http.HttpTransport$FixedLengthInputStream" + .equals(className)) { + final Class superclass = inputStream.getClass().getSuperclass(); + final Method unexpectedEndOfInput = checkNotNull(superclass).getDeclaredMethod( + "unexpectedEndOfInput"); + unexpectedEndOfInput.setAccessible(true); + unexpectedEndOfInput.invoke(inputStream); + } + } catch (final Exception e) { + // If an IOException then the connection didn't ever have an input stream, or it was + // closed already. If another type of exception then something went wrong, most likely + // the device isn't using okhttp. + } + } + + /** + * Closes the current connection quietly, if there is one. + */ + private void closeConnectionQuietly() { + if (connection != null) { + try { + connection.disconnect(); + } catch (final Exception e) { + Log.e(TAG, "Unexpected error while disconnecting", e); + } + connection = null; + } + } + + private static boolean isCompressed(@NonNull final HttpURLConnection connection) { + final String contentEncoding = connection.getHeaderField("Content-Encoding"); + return "gzip".equalsIgnoreCase(contentEncoding); + } + + /** + * Builds a {@code range} parameter for the given position and length. + * + *

+ * To fetch its contents, YouTube use range requests which append a {@code range} parameter + * to videoplayback URLs instead of the {@code Range} header (even if the server respond + * correctly when requesting a range of a ressouce with it). + *

+ * + *

+ * The parameter works in the same way as the header. + *

+ * + * @param position The request position. + * @param length The request length, or {@link C#LENGTH_UNSET} if the request is unbounded. + * @return The corresponding {@code range} parameter, or {@code null} if this parameter is + * unnecessary because the whole resource is being requested. + */ + @Nullable + private static String buildRangeParameter(final long position, final long length) { + if (position == 0 && length == C.LENGTH_UNSET) { + return null; + } + + final StringBuilder rangeParameter = new StringBuilder(); + rangeParameter.append("&range="); + rangeParameter.append(position); + rangeParameter.append("-"); + if (length != C.LENGTH_UNSET) { + rangeParameter.append(position + length - 1); + } + return rangeParameter.toString(); + } + + private static final class NullFilteringHeadersMap + extends ForwardingMap> { + private final Map> headers; + + NullFilteringHeadersMap(final Map> headers) { + this.headers = headers; + } + + @NonNull + @Override + protected Map> delegate() { + return headers; + } + + @Override + public boolean containsKey(@Nullable final Object key) { + return key != null && super.containsKey(key); + } + + @Nullable + @Override + public List get(@Nullable final Object key) { + return key == null ? null : super.get(key); + } + + @NonNull + @Override + public Set keySet() { + return Sets.filter(super.keySet(), Objects::nonNull); + } + + @NonNull + @Override + public Set>> entrySet() { + return Sets.filter(super.entrySet(), entry -> entry.getKey() != null); + } + + @Override + public int size() { + return super.size() - (super.containsKey(null) ? 1 : 0); + } + + @Override + public boolean isEmpty() { + return super.isEmpty() || (super.size() == 1 && super.containsKey(null)); + } + + @Override + public boolean containsValue(@Nullable final Object value) { + return super.standardContainsValue(value); + } + + @Override + public boolean equals(@Nullable final Object object) { + return object != null && super.standardEquals(object); + } + + @Override + public int hashCode() { + return super.standardHashCode(); + } + } +} + diff --git a/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt b/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt deleted file mode 100644 index c89eabb47..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt +++ /dev/null @@ -1,520 +0,0 @@ -package org.schabi.newpipe.player.event - -import android.content.Context -import android.os.Handler -import android.util.Log -import android.view.GestureDetector -import android.view.MotionEvent -import android.view.View -import android.view.ViewConfiguration -import org.schabi.newpipe.ktx.animate -import org.schabi.newpipe.player.MainPlayer -import org.schabi.newpipe.player.Player -import org.schabi.newpipe.player.helper.PlayerHelper -import org.schabi.newpipe.player.helper.PlayerHelper.savePopupPositionAndSizeToPrefs -import kotlin.math.abs -import kotlin.math.hypot -import kotlin.math.max -import kotlin.math.min - -/** - * Base gesture handling for [Player] - * - * This class contains the logic for the player gestures like View preparations - * and provides some abstract methods to make it easier separating the logic from the UI. - */ -abstract class BasePlayerGestureListener( - @JvmField - protected val player: Player, - @JvmField - protected val service: MainPlayer -) : GestureDetector.SimpleOnGestureListener(), View.OnTouchListener { - - // /////////////////////////////////////////////////////////////////// - // Abstract methods for VIDEO and POPUP - // /////////////////////////////////////////////////////////////////// - - abstract fun onDoubleTap(event: MotionEvent, portion: DisplayPortion) - - abstract fun onSingleTap(playerType: MainPlayer.PlayerType) - - abstract fun onScroll( - playerType: MainPlayer.PlayerType, - portion: DisplayPortion, - initialEvent: MotionEvent, - movingEvent: MotionEvent, - distanceX: Float, - distanceY: Float - ) - - abstract fun onScrollEnd(playerType: MainPlayer.PlayerType, event: MotionEvent) - - // /////////////////////////////////////////////////////////////////// - // Abstract methods for POPUP (exclusive) - // /////////////////////////////////////////////////////////////////// - - abstract fun onPopupResizingStart() - - abstract fun onPopupResizingEnd() - - private var initialPopupX: Int = -1 - private var initialPopupY: Int = -1 - - private var isMovingInMain = false - private var isMovingInPopup = false - private var isResizing = false - - private val tossFlingVelocity = PlayerHelper.getTossFlingVelocity() - - // [popup] initial coordinates and distance between fingers - private var initPointerDistance = -1.0 - private var initFirstPointerX = -1f - private var initFirstPointerY = -1f - private var initSecPointerX = -1f - private var initSecPointerY = -1f - - // /////////////////////////////////////////////////////////////////// - // onTouch implementation - // /////////////////////////////////////////////////////////////////// - - override fun onTouch(v: View, event: MotionEvent): Boolean { - return if (player.popupPlayerSelected()) { - onTouchInPopup(v, event) - } else { - onTouchInMain(v, event) - } - } - - private fun onTouchInMain(v: View, event: MotionEvent): Boolean { - player.gestureDetector.onTouchEvent(event) - if (event.action == MotionEvent.ACTION_UP && isMovingInMain) { - isMovingInMain = false - onScrollEnd(MainPlayer.PlayerType.VIDEO, event) - } - return when (event.action) { - MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { - v.parent.requestDisallowInterceptTouchEvent(player.isFullscreen) - true - } - MotionEvent.ACTION_UP -> { - v.parent.requestDisallowInterceptTouchEvent(false) - false - } - else -> true - } - } - - private fun onTouchInPopup(v: View, event: MotionEvent): Boolean { - player.gestureDetector.onTouchEvent(event) - if (event.pointerCount == 2 && !isMovingInPopup && !isResizing) { - if (DEBUG) { - Log.d(TAG, "onTouch() 2 finger pointer detected, enabling resizing.") - } - onPopupResizingStart() - - // record coordinates of fingers - initFirstPointerX = event.getX(0) - initFirstPointerY = event.getY(0) - initSecPointerX = event.getX(1) - initSecPointerY = event.getY(1) - // record distance between fingers - initPointerDistance = hypot( - initFirstPointerX - initSecPointerX.toDouble(), - initFirstPointerY - initSecPointerY.toDouble() - ) - - isResizing = true - } - if (event.action == MotionEvent.ACTION_MOVE && !isMovingInPopup && isResizing) { - if (DEBUG) { - Log.d( - TAG, - "onTouch() ACTION_MOVE > v = [$v], e1.getRaw =" + - "[${event.rawX}, ${event.rawY}]" - ) - } - return handleMultiDrag(event) - } - if (event.action == MotionEvent.ACTION_UP) { - if (DEBUG) { - Log.d( - TAG, - "onTouch() ACTION_UP > v = [$v], e1.getRaw =" + - " [${event.rawX}, ${event.rawY}]" - ) - } - if (isMovingInPopup) { - isMovingInPopup = false - onScrollEnd(MainPlayer.PlayerType.POPUP, event) - } - if (isResizing) { - isResizing = false - - initPointerDistance = (-1).toDouble() - initFirstPointerX = (-1).toFloat() - initFirstPointerY = (-1).toFloat() - initSecPointerX = (-1).toFloat() - initSecPointerY = (-1).toFloat() - - onPopupResizingEnd() - player.changeState(player.currentState) - } - if (!player.isPopupClosing) { - savePopupPositionAndSizeToPrefs(player) - } - } - - v.performClick() - return true - } - - private fun handleMultiDrag(event: MotionEvent): Boolean { - if (initPointerDistance != -1.0 && event.pointerCount == 2) { - // get the movements of the fingers - val firstPointerMove = hypot( - event.getX(0) - initFirstPointerX.toDouble(), - event.getY(0) - initFirstPointerY.toDouble() - ) - val secPointerMove = hypot( - event.getX(1) - initSecPointerX.toDouble(), - event.getY(1) - initSecPointerY.toDouble() - ) - - // minimum threshold beyond which pinch gesture will work - val minimumMove = ViewConfiguration.get(service).scaledTouchSlop - - if (max(firstPointerMove, secPointerMove) > minimumMove) { - // calculate current distance between the pointers - val currentPointerDistance = hypot( - event.getX(0) - event.getX(1).toDouble(), - event.getY(0) - event.getY(1).toDouble() - ) - - val popupWidth = player.popupLayoutParams!!.width.toDouble() - // change co-ordinates of popup so the center stays at the same position - val newWidth = popupWidth * currentPointerDistance / initPointerDistance - initPointerDistance = currentPointerDistance - player.popupLayoutParams!!.x += ((popupWidth - newWidth) / 2.0).toInt() - - player.checkPopupPositionBounds() - player.updateScreenSize() - player.changePopupSize(min(player.screenWidth.toDouble(), newWidth).toInt()) - return true - } - } - return false - } - - // /////////////////////////////////////////////////////////////////// - // Simple gestures - // /////////////////////////////////////////////////////////////////// - - override fun onDown(e: MotionEvent): Boolean { - if (DEBUG) - Log.d(TAG, "onDown called with e = [$e]") - - if (isDoubleTapping && isDoubleTapEnabled) { - doubleTapControls?.onDoubleTapProgressDown(getDisplayPortion(e)) - return true - } - - return if (player.popupPlayerSelected()) - onDownInPopup(e) - else - true - } - - private fun onDownInPopup(e: MotionEvent): Boolean { - // Fix popup position when the user touch it, it may have the wrong one - // because the soft input is visible (the draggable area is currently resized). - player.updateScreenSize() - player.checkPopupPositionBounds() - player.popupLayoutParams?.let { - initialPopupX = it.x - initialPopupY = it.y - } - return super.onDown(e) - } - - override fun onDoubleTap(e: MotionEvent): Boolean { - if (DEBUG) - Log.d(TAG, "onDoubleTap called with e = [$e]") - - onDoubleTap(e, getDisplayPortion(e)) - return true - } - - override fun onSingleTapConfirmed(e: MotionEvent): Boolean { - if (DEBUG) - Log.d(TAG, "onSingleTapConfirmed() called with: e = [$e]") - - if (isDoubleTapping) - return true - - if (player.popupPlayerSelected()) { - if (player.exoPlayerIsNull()) - return false - - onSingleTap(MainPlayer.PlayerType.POPUP) - return true - } else { - super.onSingleTapConfirmed(e) - if (player.currentState == Player.STATE_BLOCKED) - return true - - onSingleTap(MainPlayer.PlayerType.VIDEO) - } - return true - } - - override fun onLongPress(e: MotionEvent?) { - if (player.popupPlayerSelected()) { - player.updateScreenSize() - player.checkPopupPositionBounds() - player.changePopupSize(player.screenWidth.toInt()) - } - } - - override fun onScroll( - initialEvent: MotionEvent, - movingEvent: MotionEvent, - distanceX: Float, - distanceY: Float - ): Boolean { - return if (player.popupPlayerSelected()) { - onScrollInPopup(initialEvent, movingEvent, distanceX, distanceY) - } else { - onScrollInMain(initialEvent, movingEvent, distanceX, distanceY) - } - } - - override fun onFling( - e1: MotionEvent?, - e2: MotionEvent?, - velocityX: Float, - velocityY: Float - ): Boolean { - return if (player.popupPlayerSelected()) { - val absVelocityX = abs(velocityX) - val absVelocityY = abs(velocityY) - if (absVelocityX.coerceAtLeast(absVelocityY) > tossFlingVelocity) { - if (absVelocityX > tossFlingVelocity) { - player.popupLayoutParams!!.x = velocityX.toInt() - } - if (absVelocityY > tossFlingVelocity) { - player.popupLayoutParams!!.y = velocityY.toInt() - } - player.checkPopupPositionBounds() - player.windowManager!!.updateViewLayout(player.rootView, player.popupLayoutParams) - return true - } - return false - } else { - true - } - } - - private fun onScrollInMain( - initialEvent: MotionEvent, - movingEvent: MotionEvent, - distanceX: Float, - distanceY: Float - ): Boolean { - - if (!player.isFullscreen) { - return false - } - - val isTouchingStatusBar: Boolean = initialEvent.y < getStatusBarHeight(service) - val isTouchingNavigationBar: Boolean = - initialEvent.y > (player.rootView.height - getNavigationBarHeight(service)) - if (isTouchingStatusBar || isTouchingNavigationBar) { - return false - } - - val insideThreshold = abs(movingEvent.y - initialEvent.y) <= MOVEMENT_THRESHOLD - if ( - !isMovingInMain && (insideThreshold || abs(distanceX) > abs(distanceY)) || - player.currentState == Player.STATE_COMPLETED - ) { - return false - } - - isMovingInMain = true - - onScroll( - MainPlayer.PlayerType.VIDEO, - getDisplayHalfPortion(initialEvent), - initialEvent, - movingEvent, - distanceX, - distanceY - ) - - return true - } - - private fun onScrollInPopup( - initialEvent: MotionEvent, - movingEvent: MotionEvent, - distanceX: Float, - distanceY: Float - ): Boolean { - - if (isResizing) { - return super.onScroll(initialEvent, movingEvent, distanceX, distanceY) - } - - if (!isMovingInPopup) { - player.closeOverlayButton.animate(true, 200) - } - - isMovingInPopup = true - - val diffX: Float = (movingEvent.rawX - initialEvent.rawX) - var posX: Float = (initialPopupX + diffX) - val diffY: Float = (movingEvent.rawY - initialEvent.rawY) - var posY: Float = (initialPopupY + diffY) - - if (posX > player.screenWidth - player.popupLayoutParams!!.width) { - posX = (player.screenWidth - player.popupLayoutParams!!.width) - } else if (posX < 0) { - posX = 0f - } - - if (posY > player.screenHeight - player.popupLayoutParams!!.height) { - posY = (player.screenHeight - player.popupLayoutParams!!.height) - } else if (posY < 0) { - posY = 0f - } - - player.popupLayoutParams!!.x = posX.toInt() - player.popupLayoutParams!!.y = posY.toInt() - - onScroll( - MainPlayer.PlayerType.POPUP, - getDisplayHalfPortion(initialEvent), - initialEvent, - movingEvent, - distanceX, - distanceY - ) - - player.windowManager!!.updateViewLayout(player.rootView, player.popupLayoutParams) - return true - } - - // /////////////////////////////////////////////////////////////////// - // Multi double tapping - // /////////////////////////////////////////////////////////////////// - - var doubleTapControls: DoubleTapListener? = null - private set - - private val isDoubleTapEnabled: Boolean - get() = doubleTapDelay > 0 - - var isDoubleTapping = false - private set - - fun doubleTapControls(listener: DoubleTapListener) = apply { - doubleTapControls = listener - } - - private var doubleTapDelay = DOUBLE_TAP_DELAY - private val doubleTapHandler: Handler = Handler() - private val doubleTapRunnable = Runnable { - if (DEBUG) - Log.d(TAG, "doubleTapRunnable called") - - isDoubleTapping = false - doubleTapControls?.onDoubleTapFinished() - } - - fun startMultiDoubleTap(e: MotionEvent) { - if (!isDoubleTapping) { - if (DEBUG) - Log.d(TAG, "startMultiDoubleTap called with e = [$e]") - - keepInDoubleTapMode() - doubleTapControls?.onDoubleTapStarted(getDisplayPortion(e)) - } - } - - fun keepInDoubleTapMode() { - if (DEBUG) - Log.d(TAG, "keepInDoubleTapMode called") - - isDoubleTapping = true - doubleTapHandler.removeCallbacks(doubleTapRunnable) - doubleTapHandler.postDelayed(doubleTapRunnable, doubleTapDelay) - } - - fun endMultiDoubleTap() { - if (DEBUG) - Log.d(TAG, "endMultiDoubleTap called") - - isDoubleTapping = false - doubleTapHandler.removeCallbacks(doubleTapRunnable) - doubleTapControls?.onDoubleTapFinished() - } - - // /////////////////////////////////////////////////////////////////// - // Utils - // /////////////////////////////////////////////////////////////////// - - private fun getDisplayPortion(e: MotionEvent): DisplayPortion { - return if (player.playerType == MainPlayer.PlayerType.POPUP && player.popupLayoutParams != null) { - when { - e.x < player.popupLayoutParams!!.width / 3.0 -> DisplayPortion.LEFT - e.x > player.popupLayoutParams!!.width * 2.0 / 3.0 -> DisplayPortion.RIGHT - else -> DisplayPortion.MIDDLE - } - } else /* MainPlayer.PlayerType.VIDEO */ { - when { - e.x < player.rootView.width / 3.0 -> DisplayPortion.LEFT - e.x > player.rootView.width * 2.0 / 3.0 -> DisplayPortion.RIGHT - else -> DisplayPortion.MIDDLE - } - } - } - - // Currently needed for scrolling since there is no action more the middle portion - private fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion { - return if (player.playerType == MainPlayer.PlayerType.POPUP) { - when { - e.x < player.popupLayoutParams!!.width / 2.0 -> DisplayPortion.LEFT_HALF - else -> DisplayPortion.RIGHT_HALF - } - } else /* MainPlayer.PlayerType.VIDEO */ { - when { - e.x < player.rootView.width / 2.0 -> DisplayPortion.LEFT_HALF - else -> DisplayPortion.RIGHT_HALF - } - } - } - - private fun getNavigationBarHeight(context: Context): Int { - val resId = context.resources - .getIdentifier("navigation_bar_height", "dimen", "android") - return if (resId > 0) { - context.resources.getDimensionPixelSize(resId) - } else 0 - } - - private fun getStatusBarHeight(context: Context): Int { - val resId = context.resources - .getIdentifier("status_bar_height", "dimen", "android") - return if (resId > 0) { - context.resources.getDimensionPixelSize(resId) - } else 0 - } - - companion object { - private const val TAG = "BasePlayerGestListener" - private val DEBUG = Player.DEBUG - - private const val DOUBLE_TAP_DELAY = 550L - private const val MOVEMENT_THRESHOLD = 40 - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/event/DoubleTapListener.kt b/app/src/main/java/org/schabi/newpipe/player/event/DoubleTapListener.kt deleted file mode 100644 index 84cfb9b8d..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/event/DoubleTapListener.kt +++ /dev/null @@ -1,7 +0,0 @@ -package org.schabi.newpipe.player.event - -interface DoubleTapListener { - fun onDoubleTapStarted(portion: DisplayPortion) {} - fun onDoubleTapProgressDown(portion: DisplayPortion) {} - fun onDoubleTapFinished() {} -} diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java index b5520e8be..2cca259c2 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java +++ b/app/src/main/java/org/schabi/newpipe/player/event/PlayerEventListener.java @@ -1,6 +1,5 @@ package org.schabi.newpipe.player.event; - import com.google.android.exoplayer2.PlaybackParameters; import org.schabi.newpipe.extractor.stream.StreamInfo; @@ -12,5 +11,6 @@ public interface PlayerEventListener { PlaybackParameters parameters); void onProgressUpdate(int currentProgress, int duration, int bufferPercent); void onMetadataUpdate(StreamInfo info, PlayQueue queue); + default void onAudioTrackUpdate() { } void onServiceStopped(); } diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerGestureListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerGestureListener.java deleted file mode 100644 index 794fe9b3c..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerGestureListener.java +++ /dev/null @@ -1,248 +0,0 @@ -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; -import android.view.View; -import android.view.Window; -import android.view.WindowManager; -import android.widget.ProgressBar; - -import androidx.annotation.NonNull; -import androidx.appcompat.content.res.AppCompatResources; - -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; - -/** - * GestureListener for the player - * - * While {@link BasePlayerGestureListener} contains the logic behind the single gestures - * this class focuses on the visual aspect like hiding and showing the controls or changing - * volume/brightness during scrolling for specific events. - */ -public class PlayerGestureListener - extends BasePlayerGestureListener - implements View.OnTouchListener { - private static final String TAG = PlayerGestureListener.class.getSimpleName(); - private static final boolean DEBUG = MainActivity.DEBUG; - - private final int maxVolume; - - public PlayerGestureListener(final Player player, final MainPlayer service) { - super(player, service); - maxVolume = player.getAudioReactor().getMaxVolume(); - } - - @Override - public void onDoubleTap(@NonNull final MotionEvent event, - @NonNull final DisplayPortion portion) { - if (DEBUG) { - Log.d(TAG, "onDoubleTap called with playerType = [" - + player.getPlayerType() + "], portion = [" + portion + "]"); - } - if (player.isSomePopupMenuVisible()) { - player.hideControls(0, 0); - } - - if (portion == DisplayPortion.LEFT || portion == DisplayPortion.RIGHT) { - startMultiDoubleTap(event); - } else if (portion == DisplayPortion.MIDDLE) { - player.playPause(); - } - } - - @Override - public void onSingleTap(@NonNull final MainPlayer.PlayerType playerType) { - if (DEBUG) { - Log.d(TAG, "onSingleTap called with playerType = [" + player.getPlayerType() + "]"); - } - - if (player.isControlsVisible()) { - player.hideControls(150, 0); - return; - } - // -- Controls are not visible -- - - // When player is completed show controls and don't hide them later - if (player.getCurrentState() == Player.STATE_COMPLETED) { - player.showControls(0); - } else { - player.showControlsThenHide(); - } - } - - @Override - 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 = [" - + player.getPlayerType() + "], portion = [" + portion + "]"); - } - if (playerType == MainPlayer.PlayerType.VIDEO) { - - // -- Brightness and Volume control -- - final boolean isBrightnessGestureEnabled = - PlayerHelper.isBrightnessGestureEnabled(service); - final boolean isVolumeGestureEnabled = PlayerHelper.isVolumeGestureEnabled(service); - - if (isBrightnessGestureEnabled && isVolumeGestureEnabled) { - if (portion == DisplayPortion.LEFT_HALF) { - onScrollMainBrightness(distanceX, distanceY); - - } else /* DisplayPortion.RIGHT_HALF */ { - onScrollMainVolume(distanceX, distanceY); - } - } else if (isBrightnessGestureEnabled) { - onScrollMainBrightness(distanceX, distanceY); - } else if (isVolumeGestureEnabled) { - onScrollMainVolume(distanceX, distanceY); - } - - } else /* MainPlayer.PlayerType.POPUP */ { - - // -- Determine if the ClosingOverlayView (red X) has to be shown or hidden -- - final View closingOverlayView = player.getClosingOverlayView(); - final boolean showClosingOverlayView = player.isInsideClosingRadius(movingEvent); - // Check if an view is in expected state and if not animate it into the correct state - final int expectedVisibility = showClosingOverlayView ? View.VISIBLE : View.GONE; - if (closingOverlayView.getVisibility() != expectedVisibility) { - animate(closingOverlayView, showClosingOverlayView, 200); - } - } - } - - private void onScrollMainVolume(final float distanceX, final float distanceY) { - player.getVolumeProgressBar().incrementProgressBy((int) distanceY); - final float currentProgressPercent = (float) player - .getVolumeProgressBar().getProgress() / player.getMaxGestureLength(); - final int currentVolume = (int) (maxVolume * currentProgressPercent); - player.getAudioReactor().setVolume(currentVolume); - - if (DEBUG) { - Log.d(TAG, "onScroll().volumeControl, currentVolume = " + currentVolume); - } - - player.getVolumeImageView().setImageDrawable( - AppCompatResources.getDrawable(service, currentProgressPercent <= 0 - ? R.drawable.ic_volume_off - : currentProgressPercent < 0.25 ? R.drawable.ic_volume_mute - : currentProgressPercent < 0.75 ? R.drawable.ic_volume_down - : R.drawable.ic_volume_up) - ); - - if (player.getVolumeRelativeLayout().getVisibility() != View.VISIBLE) { - animate(player.getVolumeRelativeLayout(), true, 200, SCALE_AND_ALPHA); - } - if (player.getBrightnessRelativeLayout().getVisibility() == View.VISIBLE) { - player.getBrightnessRelativeLayout().setVisibility(View.GONE); - } - } - - private void onScrollMainBrightness(final float distanceX, final float distanceY) { - final Activity parent = player.getParentActivity(); - if (parent == null) { - return; - } - - final Window window = parent.getWindow(); - final WindowManager.LayoutParams layoutParams = window.getAttributes(); - final ProgressBar bar = player.getBrightnessProgressBar(); - final float oldBrightness = layoutParams.screenBrightness; - bar.setProgress((int) (bar.getMax() * Math.max(0, Math.min(1, oldBrightness)))); - bar.incrementProgressBy((int) distanceY); - - final float currentProgressPercent = (float) bar.getProgress() / bar.getMax(); - layoutParams.screenBrightness = currentProgressPercent; - window.setAttributes(layoutParams); - - // Save current brightness level - PlayerHelper.setScreenBrightness(parent, currentProgressPercent); - - if (DEBUG) { - Log.d(TAG, "onScroll().brightnessControl, " - + "currentBrightness = " + currentProgressPercent); - } - - player.getBrightnessImageView().setImageDrawable( - AppCompatResources.getDrawable(service, - currentProgressPercent < 0.25 - ? R.drawable.ic_brightness_low - : currentProgressPercent < 0.75 - ? R.drawable.ic_brightness_medium - : R.drawable.ic_brightness_high) - ); - - if (player.getBrightnessRelativeLayout().getVisibility() != View.VISIBLE) { - animate(player.getBrightnessRelativeLayout(), true, 200, SCALE_AND_ALPHA); - } - if (player.getVolumeRelativeLayout().getVisibility() == View.VISIBLE) { - player.getVolumeRelativeLayout().setVisibility(View.GONE); - } - } - - @Override - public void onScrollEnd(@NonNull final MainPlayer.PlayerType playerType, - @NonNull final MotionEvent event) { - if (DEBUG) { - Log.d(TAG, "onScrollEnd called with playerType = [" - + player.getPlayerType() + "]"); - } - - if (player.isControlsVisible() && player.getCurrentState() == STATE_PLAYING) { - player.hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); - } - - if (playerType == MainPlayer.PlayerType.VIDEO) { - if (player.getVolumeRelativeLayout().getVisibility() == View.VISIBLE) { - animate(player.getVolumeRelativeLayout(), false, 200, SCALE_AND_ALPHA, - 200); - } - if (player.getBrightnessRelativeLayout().getVisibility() == View.VISIBLE) { - animate(player.getBrightnessRelativeLayout(), false, 200, SCALE_AND_ALPHA, - 200); - } - } else /* Popup-Player */ { - if (player.isInsideClosingRadius(event)) { - player.closePopup(); - } else if (!player.isPopupClosing()) { - animate(player.getCloseOverlayButton(), false, 200); - animate(player.getClosingOverlayView(), false, 200); - } - } - } - - @Override - public void onPopupResizingStart() { - if (DEBUG) { - Log.d(TAG, "onPopupResizingStart called"); - } - player.getLoadingPanel().setVisibility(View.GONE); - - player.hideControls(0, 0); - animate(player.getFastSeekOverlay(), false, 0); - animate(player.getCurrentDisplaySeek(), false, 0, ALPHA, 0); - } - - @Override - public void onPopupResizingEnd() { - if (DEBUG) { - Log.d(TAG, "onPopupResizingEnd called"); - } - } -} - - diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java index 359eab8b2..8c18fd2ad 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java +++ b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceEventListener.java @@ -3,6 +3,8 @@ package org.schabi.newpipe.player.event; import com.google.android.exoplayer2.PlaybackException; public interface PlayerServiceEventListener extends PlayerEventListener { + void onViewCreated(); + void onFullscreenStateChanged(boolean fullscreen); void onScreenRotationButtonClicked(); diff --git a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java index f774c90a0..8effe2f0e 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java +++ b/app/src/main/java/org/schabi/newpipe/player/event/PlayerServiceExtendedEventListener.java @@ -1,11 +1,11 @@ package org.schabi.newpipe.player.event; -import org.schabi.newpipe.player.MainPlayer; +import org.schabi.newpipe.player.PlayerService; import org.schabi.newpipe.player.Player; public interface PlayerServiceExtendedEventListener extends PlayerServiceEventListener { void onServiceConnected(Player player, - MainPlayer playerService, + PlayerService playerService, boolean playAfterConnect); void onServiceDisconnected(); } diff --git a/app/src/main/java/org/schabi/newpipe/player/gesture/BasePlayerGestureListener.kt b/app/src/main/java/org/schabi/newpipe/player/gesture/BasePlayerGestureListener.kt new file mode 100644 index 000000000..0453f297a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/gesture/BasePlayerGestureListener.kt @@ -0,0 +1,188 @@ +package org.schabi.newpipe.player.gesture + +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.view.GestureDetector +import android.view.MotionEvent +import android.view.View +import androidx.core.os.postDelayed +import org.schabi.newpipe.databinding.PlayerBinding +import org.schabi.newpipe.player.Player +import org.schabi.newpipe.player.ui.VideoPlayerUi + +/** + * Base gesture handling for [Player] + * + * This class contains the logic for the player gestures like View preparations + * and provides some abstract methods to make it easier separating the logic from the UI. + */ +abstract class BasePlayerGestureListener( + private val playerUi: VideoPlayerUi, +) : GestureDetector.SimpleOnGestureListener(), View.OnTouchListener { + + protected val player: Player = playerUi.player + protected val binding: PlayerBinding = playerUi.binding + + override fun onTouch(v: View, event: MotionEvent): Boolean { + playerUi.gestureDetector.onTouchEvent(event) + return false + } + + private fun onDoubleTap( + event: MotionEvent, + portion: DisplayPortion + ) { + if (DEBUG) { + Log.d( + TAG, + "onDoubleTap called with playerType = [" + + player.playerType + "], portion = [" + portion + "]" + ) + } + if (playerUi.isSomePopupMenuVisible) { + playerUi.hideControls(0, 0) + } + if (portion === DisplayPortion.LEFT || portion === DisplayPortion.RIGHT) { + startMultiDoubleTap(event) + } else if (portion === DisplayPortion.MIDDLE) { + player.playPause() + } + } + + protected fun onSingleTap() { + if (playerUi.isControlsVisible) { + playerUi.hideControls(150, 0) + return + } + // -- Controls are not visible -- + + // When player is completed show controls and don't hide them later + if (player.currentState == Player.STATE_COMPLETED) { + playerUi.showControls(0) + } else { + playerUi.showControlsThenHide() + } + } + + open fun onScrollEnd(event: MotionEvent) { + if (DEBUG) { + Log.d( + TAG, + "onScrollEnd called with playerType = [" + + player.playerType + "]" + ) + } + if (playerUi.isControlsVisible && player.currentState == Player.STATE_PLAYING) { + playerUi.hideControls( + VideoPlayerUi.DEFAULT_CONTROLS_DURATION, + VideoPlayerUi.DEFAULT_CONTROLS_HIDE_TIME + ) + } + } + + // /////////////////////////////////////////////////////////////////// + // Simple gestures + // /////////////////////////////////////////////////////////////////// + + override fun onDown(e: MotionEvent): Boolean { + if (DEBUG) + Log.d(TAG, "onDown called with e = [$e]") + + if (isDoubleTapping && isDoubleTapEnabled) { + doubleTapControls?.onDoubleTapProgressDown(getDisplayPortion(e)) + return true + } + + if (onDownNotDoubleTapping(e)) { + return super.onDown(e) + } + return true + } + + /** + * @return true if `super.onDown(e)` should be called, false otherwise + */ + open fun onDownNotDoubleTapping(e: MotionEvent): Boolean { + return false // do not call super.onDown(e) by default, overridden for popup player + } + + override fun onDoubleTap(e: MotionEvent): Boolean { + if (DEBUG) + Log.d(TAG, "onDoubleTap called with e = [$e]") + + onDoubleTap(e, getDisplayPortion(e)) + return true + } + + // /////////////////////////////////////////////////////////////////// + // Multi double tapping + // /////////////////////////////////////////////////////////////////// + + private var doubleTapControls: DoubleTapListener? = null + + private val isDoubleTapEnabled: Boolean + get() = doubleTapDelay > 0 + + var isDoubleTapping = false + private set + + fun doubleTapControls(listener: DoubleTapListener) = apply { + doubleTapControls = listener + } + + private var doubleTapDelay = DOUBLE_TAP_DELAY + private val doubleTapHandler: Handler = Handler(Looper.getMainLooper()) + + private fun startMultiDoubleTap(e: MotionEvent) { + if (!isDoubleTapping) { + if (DEBUG) + Log.d(TAG, "startMultiDoubleTap called with e = [$e]") + + keepInDoubleTapMode() + doubleTapControls?.onDoubleTapStarted(getDisplayPortion(e)) + } + } + + fun keepInDoubleTapMode() { + if (DEBUG) + Log.d(TAG, "keepInDoubleTapMode called") + + isDoubleTapping = true + doubleTapHandler.removeCallbacksAndMessages(DOUBLE_TAP) + doubleTapHandler.postDelayed(DOUBLE_TAP_DELAY, DOUBLE_TAP) { + if (DEBUG) { + Log.d(TAG, "doubleTapRunnable called") + } + + isDoubleTapping = false + doubleTapControls?.onDoubleTapFinished() + } + } + + fun endMultiDoubleTap() { + if (DEBUG) + Log.d(TAG, "endMultiDoubleTap called") + + isDoubleTapping = false + doubleTapHandler.removeCallbacksAndMessages(DOUBLE_TAP) + doubleTapControls?.onDoubleTapFinished() + } + + // /////////////////////////////////////////////////////////////////// + // Utils + // /////////////////////////////////////////////////////////////////// + + abstract fun getDisplayPortion(e: MotionEvent): DisplayPortion + + // Currently needed for scrolling since there is no action more the middle portion + abstract fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion + + companion object { + private const val TAG = "BasePlayerGestListener" + private val DEBUG = Player.DEBUG + + private const val DOUBLE_TAP = "doubleTap" + private const val DOUBLE_TAP_DELAY = 550L + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/event/CustomBottomSheetBehavior.java b/app/src/main/java/org/schabi/newpipe/player/gesture/CustomBottomSheetBehavior.java similarity index 87% rename from app/src/main/java/org/schabi/newpipe/player/event/CustomBottomSheetBehavior.java rename to app/src/main/java/org/schabi/newpipe/player/gesture/CustomBottomSheetBehavior.java index a5de56e75..0970dbeb6 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/CustomBottomSheetBehavior.java +++ b/app/src/main/java/org/schabi/newpipe/player/gesture/CustomBottomSheetBehavior.java @@ -1,4 +1,4 @@ -package org.schabi.newpipe.player.event; +package org.schabi.newpipe.player.gesture; import android.content.Context; import android.graphics.Rect; @@ -8,24 +8,25 @@ import android.view.View; import android.widget.FrameLayout; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.coordinatorlayout.widget.CoordinatorLayout; import com.google.android.material.bottomsheet.BottomSheetBehavior; import org.schabi.newpipe.R; -import java.util.Arrays; import java.util.List; public class CustomBottomSheetBehavior extends BottomSheetBehavior { - public CustomBottomSheetBehavior(final Context context, final AttributeSet attrs) { + public CustomBottomSheetBehavior(@NonNull final Context context, + @Nullable final AttributeSet attrs) { super(context, attrs); } Rect globalRect = new Rect(); private boolean skippingInterception = false; - private final List skipInterceptionOfElements = Arrays.asList( + private final List skipInterceptionOfElements = List.of( R.id.detail_content_root_layout, R.id.relatedItemsLayout, R.id.itemsListPanel, R.id.view_pager, R.id.tab_layout, R.id.bottomControls, R.id.playPauseButton, R.id.playPreviousButton, R.id.playNextButton); @@ -33,7 +34,7 @@ public class CustomBottomSheetBehavior extends BottomSheetBehavior @Override public boolean onInterceptTouchEvent(@NonNull final CoordinatorLayout parent, @NonNull final FrameLayout child, - final MotionEvent event) { + @NonNull final MotionEvent event) { // Drop following when action ends if (event.getAction() == MotionEvent.ACTION_CANCEL || event.getAction() == MotionEvent.ACTION_UP) { @@ -57,7 +58,7 @@ public class CustomBottomSheetBehavior extends BottomSheetBehavior if (getState() == BottomSheetBehavior.STATE_EXPANDED && event.getAction() == MotionEvent.ACTION_DOWN) { // Without overriding scrolling will not work when user touches these elements - for (final Integer element : skipInterceptionOfElements) { + for (final int element : skipInterceptionOfElements) { final View view = child.findViewById(element); if (view != null) { final boolean visible = view.getGlobalVisibleRect(globalRect); diff --git a/app/src/main/java/org/schabi/newpipe/player/event/DisplayPortion.kt b/app/src/main/java/org/schabi/newpipe/player/gesture/DisplayPortion.kt similarity index 65% rename from app/src/main/java/org/schabi/newpipe/player/event/DisplayPortion.kt rename to app/src/main/java/org/schabi/newpipe/player/gesture/DisplayPortion.kt index f15e42897..684f6d326 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/DisplayPortion.kt +++ b/app/src/main/java/org/schabi/newpipe/player/gesture/DisplayPortion.kt @@ -1,4 +1,4 @@ -package org.schabi.newpipe.player.event +package org.schabi.newpipe.player.gesture enum class DisplayPortion { LEFT, MIDDLE, RIGHT, LEFT_HALF, RIGHT_HALF diff --git a/app/src/main/java/org/schabi/newpipe/player/gesture/DoubleTapListener.kt b/app/src/main/java/org/schabi/newpipe/player/gesture/DoubleTapListener.kt new file mode 100644 index 000000000..fc026abd9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/gesture/DoubleTapListener.kt @@ -0,0 +1,7 @@ +package org.schabi.newpipe.player.gesture + +interface DoubleTapListener { + fun onDoubleTapStarted(portion: DisplayPortion) + fun onDoubleTapProgressDown(portion: DisplayPortion) + fun onDoubleTapFinished() +} diff --git a/app/src/main/java/org/schabi/newpipe/player/gesture/MainPlayerGestureListener.kt b/app/src/main/java/org/schabi/newpipe/player/gesture/MainPlayerGestureListener.kt new file mode 100644 index 000000000..ff0bb269d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/gesture/MainPlayerGestureListener.kt @@ -0,0 +1,233 @@ +package org.schabi.newpipe.player.gesture + +import android.util.Log +import android.view.MotionEvent +import android.view.View +import android.view.View.OnTouchListener +import android.widget.ProgressBar +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.content.res.AppCompatResources +import androidx.core.view.isVisible +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.ktx.AnimationType +import org.schabi.newpipe.ktx.animate +import org.schabi.newpipe.player.Player +import org.schabi.newpipe.player.helper.AudioReactor +import org.schabi.newpipe.player.helper.PlayerHelper +import org.schabi.newpipe.player.ui.MainPlayerUi +import org.schabi.newpipe.util.ThemeHelper.getAndroidDimenPx +import kotlin.math.abs + +/** + * GestureListener for the player + * + * While [BasePlayerGestureListener] contains the logic behind the single gestures + * this class focuses on the visual aspect like hiding and showing the controls or changing + * volume/brightness during scrolling for specific events. + */ +class MainPlayerGestureListener( + private val playerUi: MainPlayerUi +) : BasePlayerGestureListener(playerUi), OnTouchListener { + private var isMoving = false + + override fun onTouch(v: View, event: MotionEvent): Boolean { + super.onTouch(v, event) + if (event.action == MotionEvent.ACTION_UP && isMoving) { + isMoving = false + onScrollEnd(event) + } + return when (event.action) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> { + v.parent?.requestDisallowInterceptTouchEvent(playerUi.isFullscreen) + true + } + MotionEvent.ACTION_UP -> { + v.parent?.requestDisallowInterceptTouchEvent(false) + false + } + else -> true + } + } + + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + if (DEBUG) + Log.d(TAG, "onSingleTapConfirmed() called with: e = [$e]") + + if (isDoubleTapping) + return true + super.onSingleTapConfirmed(e) + + if (player.currentState != Player.STATE_BLOCKED) + onSingleTap() + return true + } + + private fun onScrollVolume(distanceY: Float) { + val bar: ProgressBar = binding.volumeProgressBar + val audioReactor: AudioReactor = player.audioReactor + + // If we just started sliding, change the progress bar to match the system volume + if (!binding.volumeRelativeLayout.isVisible) { + val volumePercent: Float = audioReactor.volume / audioReactor.maxVolume.toFloat() + bar.progress = (volumePercent * bar.max).toInt() + } + + // Update progress bar + binding.volumeProgressBar.incrementProgressBy(distanceY.toInt()) + + // Update volume + val currentProgressPercent: Float = bar.progress / bar.max.toFloat() + val currentVolume = (audioReactor.maxVolume * currentProgressPercent).toInt() + audioReactor.volume = currentVolume + if (DEBUG) { + Log.d(TAG, "onScroll().volumeControl, currentVolume = $currentVolume") + } + + // Update player center image + binding.volumeImageView.setImageDrawable( + AppCompatResources.getDrawable( + player.context, + when { + currentProgressPercent <= 0 -> R.drawable.ic_volume_off + currentProgressPercent < 0.25 -> R.drawable.ic_volume_mute + currentProgressPercent < 0.75 -> R.drawable.ic_volume_down + else -> R.drawable.ic_volume_up + } + ) + ) + + // Make sure the correct layout is visible + if (!binding.volumeRelativeLayout.isVisible) { + binding.volumeRelativeLayout.animate(true, 200, AnimationType.SCALE_AND_ALPHA) + } + binding.brightnessRelativeLayout.isVisible = false + } + + private fun onScrollBrightness(distanceY: Float) { + val parent: AppCompatActivity = playerUi.parentActivity.orElse(null) ?: return + val window = parent.window + val layoutParams = window.attributes + val bar: ProgressBar = binding.brightnessProgressBar + + // Update progress bar + val oldBrightness = layoutParams.screenBrightness + bar.progress = (bar.max * oldBrightness.coerceIn(0f, 1f)).toInt() + bar.incrementProgressBy(distanceY.toInt()) + + // Update brightness + val currentProgressPercent = bar.progress.toFloat() / bar.max + layoutParams.screenBrightness = currentProgressPercent + window.attributes = layoutParams + + // Save current brightness level + PlayerHelper.setScreenBrightness(parent, currentProgressPercent) + if (DEBUG) { + Log.d( + TAG, + "onScroll().brightnessControl, " + + "currentBrightness = " + currentProgressPercent + ) + } + + // Update player center image + binding.brightnessImageView.setImageDrawable( + AppCompatResources.getDrawable( + player.context, + when { + currentProgressPercent < 0.25 -> R.drawable.ic_brightness_low + currentProgressPercent < 0.75 -> R.drawable.ic_brightness_medium + else -> R.drawable.ic_brightness_high + } + ) + ) + + // Make sure the correct layout is visible + if (!binding.brightnessRelativeLayout.isVisible) { + binding.brightnessRelativeLayout.animate(true, 200, AnimationType.SCALE_AND_ALPHA) + } + binding.volumeRelativeLayout.isVisible = false + } + + override fun onScrollEnd(event: MotionEvent) { + super.onScrollEnd(event) + if (binding.volumeRelativeLayout.isVisible) { + binding.volumeRelativeLayout.animate(false, 200, AnimationType.SCALE_AND_ALPHA, 200) + } + if (binding.brightnessRelativeLayout.isVisible) { + binding.brightnessRelativeLayout.animate(false, 200, AnimationType.SCALE_AND_ALPHA, 200) + } + } + + override fun onScroll( + initialEvent: MotionEvent?, + movingEvent: MotionEvent, + distanceX: Float, + distanceY: Float + ): Boolean { + if (initialEvent == null || !playerUi.isFullscreen) { + return false + } + + // Calculate heights of status and navigation bars + val statusBarHeight = getAndroidDimenPx(player.context, "status_bar_height") + val navigationBarHeight = getAndroidDimenPx(player.context, "navigation_bar_height") + + // Do not handle this event if initially it started from status or navigation bars + val isTouchingStatusBar = initialEvent.y < statusBarHeight + val isTouchingNavigationBar = initialEvent.y > (binding.root.height - navigationBarHeight) + if (isTouchingStatusBar || isTouchingNavigationBar) { + return false + } + + val insideThreshold = abs(movingEvent.y - initialEvent.y) <= MOVEMENT_THRESHOLD + if ( + !isMoving && (insideThreshold || abs(distanceX) > abs(distanceY)) || + player.currentState == Player.STATE_COMPLETED + ) { + return false + } + + isMoving = true + + // -- Brightness and Volume control -- + if (getDisplayHalfPortion(initialEvent) == DisplayPortion.RIGHT_HALF) { + when (PlayerHelper.getActionForRightGestureSide(player.context)) { + player.context.getString(R.string.volume_control_key) -> + onScrollVolume(distanceY) + player.context.getString(R.string.brightness_control_key) -> + onScrollBrightness(distanceY) + } + } else { + when (PlayerHelper.getActionForLeftGestureSide(player.context)) { + player.context.getString(R.string.volume_control_key) -> + onScrollVolume(distanceY) + player.context.getString(R.string.brightness_control_key) -> + onScrollBrightness(distanceY) + } + } + + return true + } + + override fun getDisplayPortion(e: MotionEvent): DisplayPortion { + return when { + e.x < binding.root.width / 3.0 -> DisplayPortion.LEFT + e.x > binding.root.width * 2.0 / 3.0 -> DisplayPortion.RIGHT + else -> DisplayPortion.MIDDLE + } + } + + override fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion { + return when { + e.x < binding.root.width / 2.0 -> DisplayPortion.LEFT_HALF + else -> DisplayPortion.RIGHT_HALF + } + } + + companion object { + private val TAG = MainPlayerGestureListener::class.java.simpleName + private val DEBUG = MainActivity.DEBUG + private const val MOVEMENT_THRESHOLD = 40 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/gesture/PopupPlayerGestureListener.kt b/app/src/main/java/org/schabi/newpipe/player/gesture/PopupPlayerGestureListener.kt new file mode 100644 index 000000000..0b94bf364 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/gesture/PopupPlayerGestureListener.kt @@ -0,0 +1,287 @@ +package org.schabi.newpipe.player.gesture + +import android.util.Log +import android.view.MotionEvent +import android.view.View +import android.view.ViewConfiguration +import androidx.core.view.isVisible +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.ktx.AnimationType +import org.schabi.newpipe.ktx.animate +import org.schabi.newpipe.player.ui.PopupPlayerUi +import kotlin.math.abs +import kotlin.math.hypot +import kotlin.math.max +import kotlin.math.min + +class PopupPlayerGestureListener( + private val playerUi: PopupPlayerUi, +) : BasePlayerGestureListener(playerUi) { + + private var isMoving = false + + private var initialPopupX: Int = -1 + private var initialPopupY: Int = -1 + private var isResizing = false + + // initial coordinates and distance between fingers + private var initPointerDistance = -1.0 + private var initFirstPointerX = -1f + private var initFirstPointerY = -1f + private var initSecPointerX = -1f + private var initSecPointerY = -1f + + override fun onTouch(v: View, event: MotionEvent): Boolean { + super.onTouch(v, event) + if (event.pointerCount == 2 && !isMoving && !isResizing) { + if (DEBUG) { + Log.d(TAG, "onTouch() 2 finger pointer detected, enabling resizing.") + } + onPopupResizingStart() + + // record coordinates of fingers + initFirstPointerX = event.getX(0) + initFirstPointerY = event.getY(0) + initSecPointerX = event.getX(1) + initSecPointerY = event.getY(1) + // record distance between fingers + initPointerDistance = hypot( + initFirstPointerX - initSecPointerX.toDouble(), + initFirstPointerY - initSecPointerY.toDouble() + ) + + isResizing = true + } + if (event.action == MotionEvent.ACTION_MOVE && !isMoving && isResizing) { + if (DEBUG) { + Log.d( + TAG, + "onTouch() ACTION_MOVE > v = [$v], e1.getRaw =" + + "[${event.rawX}, ${event.rawY}]" + ) + } + return handleMultiDrag(event) + } + if (event.action == MotionEvent.ACTION_UP) { + if (DEBUG) { + Log.d( + TAG, + "onTouch() ACTION_UP > v = [$v], e1.getRaw =" + + " [${event.rawX}, ${event.rawY}]" + ) + } + if (isMoving) { + isMoving = false + onScrollEnd(event) + } + if (isResizing) { + isResizing = false + + initPointerDistance = (-1).toDouble() + initFirstPointerX = (-1).toFloat() + initFirstPointerY = (-1).toFloat() + initSecPointerX = (-1).toFloat() + initSecPointerY = (-1).toFloat() + + onPopupResizingEnd() + player.changeState(player.currentState) + } + if (!playerUi.isPopupClosing) { + playerUi.savePopupPositionAndSizeToPrefs() + } + } + + v.performClick() + return true + } + + override fun onScrollEnd(event: MotionEvent) { + super.onScrollEnd(event) + if (playerUi.isInsideClosingRadius(event)) { + playerUi.closePopup() + } else if (!playerUi.isPopupClosing) { + playerUi.closeOverlayBinding.closeButton.animate(false, 200) + binding.closingOverlay.animate(false, 200) + } + } + + private fun handleMultiDrag(event: MotionEvent): Boolean { + if (initPointerDistance == -1.0 || event.pointerCount != 2) { + return false + } + + // get the movements of the fingers + val firstPointerMove = hypot( + event.getX(0) - initFirstPointerX.toDouble(), + event.getY(0) - initFirstPointerY.toDouble() + ) + val secPointerMove = hypot( + event.getX(1) - initSecPointerX.toDouble(), + event.getY(1) - initSecPointerY.toDouble() + ) + + // minimum threshold beyond which pinch gesture will work + val minimumMove = ViewConfiguration.get(player.context).scaledTouchSlop + if (max(firstPointerMove, secPointerMove) <= minimumMove) { + return false + } + + // calculate current distance between the pointers + val currentPointerDistance = hypot( + event.getX(0) - event.getX(1).toDouble(), + event.getY(0) - event.getY(1).toDouble() + ) + + val popupWidth = playerUi.popupLayoutParams.width.toDouble() + // change co-ordinates of popup so the center stays at the same position + val newWidth = popupWidth * currentPointerDistance / initPointerDistance + initPointerDistance = currentPointerDistance + playerUi.popupLayoutParams.x += ((popupWidth - newWidth) / 2.0).toInt() + + playerUi.checkPopupPositionBounds() + playerUi.updateScreenSize() + playerUi.changePopupSize(min(playerUi.screenWidth.toDouble(), newWidth).toInt()) + return true + } + + private fun onPopupResizingStart() { + if (DEBUG) { + Log.d(TAG, "onPopupResizingStart called") + } + binding.loadingPanel.visibility = View.GONE + playerUi.hideControls(0, 0) + binding.fastSeekOverlay.animate(false, 0) + binding.currentDisplaySeek.animate(false, 0, AnimationType.ALPHA, 0) + } + + private fun onPopupResizingEnd() { + if (DEBUG) { + Log.d(TAG, "onPopupResizingEnd called") + } + } + + override fun onLongPress(e: MotionEvent) { + playerUi.updateScreenSize() + playerUi.checkPopupPositionBounds() + playerUi.changePopupSize(playerUi.screenWidth) + } + + override fun onFling( + e1: MotionEvent?, + e2: MotionEvent, + velocityX: Float, + velocityY: Float + ): Boolean { + return if (player.popupPlayerSelected()) { + val absVelocityX = abs(velocityX) + val absVelocityY = abs(velocityY) + if (absVelocityX.coerceAtLeast(absVelocityY) > TOSS_FLING_VELOCITY) { + if (absVelocityX > TOSS_FLING_VELOCITY) { + playerUi.popupLayoutParams.x = velocityX.toInt() + } + if (absVelocityY > TOSS_FLING_VELOCITY) { + playerUi.popupLayoutParams.y = velocityY.toInt() + } + playerUi.checkPopupPositionBounds() + playerUi.windowManager.updateViewLayout(binding.root, playerUi.popupLayoutParams) + return true + } + return false + } else { + true + } + } + + override fun onDownNotDoubleTapping(e: MotionEvent): Boolean { + // Fix popup position when the user touch it, it may have the wrong one + // because the soft input is visible (the draggable area is currently resized). + playerUi.updateScreenSize() + playerUi.checkPopupPositionBounds() + playerUi.popupLayoutParams.let { + initialPopupX = it.x + initialPopupY = it.y + } + return true // we want `super.onDown(e)` to be called + } + + override fun onSingleTapConfirmed(e: MotionEvent): Boolean { + if (DEBUG) + Log.d(TAG, "onSingleTapConfirmed() called with: e = [$e]") + + if (isDoubleTapping) + return true + if (player.exoPlayerIsNull()) + return false + + onSingleTap() + return true + } + + override fun onScroll( + initialEvent: MotionEvent?, + movingEvent: MotionEvent, + distanceX: Float, + distanceY: Float + ): Boolean { + if (initialEvent == null) { + return false + } + + if (isResizing) { + return super.onScroll(initialEvent, movingEvent, distanceX, distanceY) + } + + if (!isMoving) { + playerUi.closeOverlayBinding.closeButton.animate(true, 200) + } + + isMoving = true + + val diffX = (movingEvent.rawX - initialEvent.rawX) + val posX = (initialPopupX + diffX).coerceIn( + 0f, + (playerUi.screenWidth - playerUi.popupLayoutParams.width).toFloat() + .coerceAtLeast(0f) + ) + val diffY = (movingEvent.rawY - initialEvent.rawY) + val posY = (initialPopupY + diffY).coerceIn( + 0f, + (playerUi.screenHeight - playerUi.popupLayoutParams.height).toFloat() + .coerceAtLeast(0f) + ) + + playerUi.popupLayoutParams.x = posX.toInt() + playerUi.popupLayoutParams.y = posY.toInt() + + // -- Determine if the ClosingOverlayView (red X) has to be shown or hidden -- + val showClosingOverlayView: Boolean = playerUi.isInsideClosingRadius(movingEvent) + // Check if an view is in expected state and if not animate it into the correct state + if (binding.closingOverlay.isVisible != showClosingOverlayView) { + binding.closingOverlay.animate(showClosingOverlayView, 200) + } + + playerUi.windowManager.updateViewLayout(binding.root, playerUi.popupLayoutParams) + return true + } + + override fun getDisplayPortion(e: MotionEvent): DisplayPortion { + return when { + e.x < playerUi.popupLayoutParams.width / 3.0 -> DisplayPortion.LEFT + e.x > playerUi.popupLayoutParams.width * 2.0 / 3.0 -> DisplayPortion.RIGHT + else -> DisplayPortion.MIDDLE + } + } + + override fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion { + return when { + e.x < playerUi.popupLayoutParams.width / 2.0 -> DisplayPortion.LEFT_HALF + else -> DisplayPortion.RIGHT_HALF + } + } + + companion object { + private val TAG = PopupPlayerGestureListener::class.java.simpleName + private val DEBUG = MainActivity.DEBUG + private const val TOSS_FLING_VELOCITY = 2500 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java index 98e04d466..41fcc823a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/CacheFactory.java @@ -1,96 +1,46 @@ package org.schabi.newpipe.player.helper; import android.content.Context; -import android.util.Log; -import com.google.android.exoplayer2.database.StandaloneDatabaseProvider; +import androidx.annotation.NonNull; + import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSource; -import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; import com.google.android.exoplayer2.upstream.FileDataSource; import com.google.android.exoplayer2.upstream.TransferListener; import com.google.android.exoplayer2.upstream.cache.CacheDataSink; import com.google.android.exoplayer2.upstream.cache.CacheDataSource; -import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; import com.google.android.exoplayer2.upstream.cache.SimpleCache; -import java.io.File; +final class CacheFactory implements DataSource.Factory { + private static final int CACHE_FLAGS = CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR; -import androidx.annotation.NonNull; + private final Context context; + private final TransferListener transferListener; + private final DataSource.Factory upstreamDataSourceFactory; + private final SimpleCache cache; -/* package-private */ class CacheFactory implements DataSource.Factory { - private static final String TAG = "CacheFactory"; - - private static final String CACHE_FOLDER_NAME = "exoplayer"; - private static final int CACHE_FLAGS = CacheDataSource.FLAG_BLOCK_ON_CACHE - | CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR; - - private final DataSource.Factory dataSourceFactory; - private final File cacheDir; - private final long maxFileSize; - - // Creating cache on every instance may cause problems with multiple players when - // sources are not ExtractorMediaSource - // see: https://stackoverflow.com/questions/28700391/using-cache-in-exoplayer - // todo: make this a singleton? - private static SimpleCache cache; - - CacheFactory(@NonNull final Context context, - @NonNull final String userAgent, - @NonNull final TransferListener transferListener) { - this(context, userAgent, transferListener, PlayerHelper.getPreferredCacheSize(), - PlayerHelper.getPreferredFileSize()); - } - - private CacheFactory(@NonNull final Context context, - @NonNull final String userAgent, - @NonNull final TransferListener transferListener, - final long maxCacheSize, - final long maxFileSize) { - this.maxFileSize = maxFileSize; - - dataSourceFactory = new DefaultDataSource - .Factory(context, new DefaultHttpDataSource.Factory().setUserAgent(userAgent)) - .setTransferListener(transferListener); - cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); - if (!cacheDir.exists()) { - //noinspection ResultOfMethodCallIgnored - cacheDir.mkdir(); - } - - if (cache == null) { - final LeastRecentlyUsedCacheEvictor evictor - = new LeastRecentlyUsedCacheEvictor(maxCacheSize); - cache = new SimpleCache(cacheDir, evictor, new StandaloneDatabaseProvider(context)); - } + CacheFactory(final Context context, + final TransferListener transferListener, + final SimpleCache cache, + final DataSource.Factory upstreamDataSourceFactory) { + this.context = context; + this.transferListener = transferListener; + this.cache = cache; + this.upstreamDataSourceFactory = upstreamDataSourceFactory; } @NonNull @Override public DataSource createDataSource() { - Log.d(TAG, "initExoPlayerCache: cacheDir = " + cacheDir.getAbsolutePath()); + final DefaultDataSource dataSource = new DefaultDataSource.Factory(context, + upstreamDataSourceFactory) + .setTransferListener(transferListener) + .createDataSource(); - final DataSource dataSource = dataSourceFactory.createDataSource(); final FileDataSource fileSource = new FileDataSource(); - final CacheDataSink dataSink = new CacheDataSink(cache, maxFileSize); - + final CacheDataSink dataSink = + new CacheDataSink(cache, PlayerHelper.getPreferredFileSize()); return new CacheDataSource(cache, dataSource, fileSource, dataSink, CACHE_FLAGS, null); } - - public void tryDeleteCacheFiles() { - if (!cacheDir.exists() || !cacheDir.isDirectory()) { - return; - } - - try { - for (final File file : cacheDir.listFiles()) { - final String filePath = file.getAbsolutePath(); - final boolean deleteSuccessful = file.delete(); - - Log.d(TAG, "tryDeleteCacheFiles: " + filePath + " deleted = " + deleteSuccessful); - } - } catch (final Exception e) { - Log.e(TAG, "Failed to delete file.", e); - } - } } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CustomMediaCodecVideoRenderer.java b/app/src/main/java/org/schabi/newpipe/player/helper/CustomMediaCodecVideoRenderer.java new file mode 100644 index 000000000..66ac6d50b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/CustomMediaCodecVideoRenderer.java @@ -0,0 +1,54 @@ +package org.schabi.newpipe.player.helper; + +import android.content.Context; +import android.os.Handler; + +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.mediacodec.MediaCodecAdapter; +import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; +import com.google.android.exoplayer2.video.MediaCodecVideoRenderer; +import com.google.android.exoplayer2.video.VideoRendererEventListener; + +/** + * A {@link MediaCodecVideoRenderer} which always enable the output surface workaround that + * ExoPlayer enables on several devices which are known to implement + * {@link android.media.MediaCodec#setOutputSurface(android.view.Surface) + * MediaCodec.setOutputSurface(Surface)} incorrectly. + * + *

+ * See {@link MediaCodecVideoRenderer#codecNeedsSetOutputSurfaceWorkaround(String)} for more + * details. + *

+ * + *

+ * This custom {@link MediaCodecVideoRenderer} may be useful in the case a device is affected by + * this issue but is not present in ExoPlayer's list. + *

+ * + *

+ * This class has only effect on devices with Android 6 and higher, as the {@code setOutputSurface} + * method is only implemented in these Android versions and the method used as a workaround is + * always applied on older Android versions (releasing and re-instantiating video codec instances). + *

+ */ +public final class CustomMediaCodecVideoRenderer extends MediaCodecVideoRenderer { + + @SuppressWarnings({"checkstyle:ParameterNumber", "squid:S107"}) + public CustomMediaCodecVideoRenderer(final Context context, + final MediaCodecAdapter.Factory codecAdapterFactory, + final MediaCodecSelector mediaCodecSelector, + final long allowedJoiningTimeMs, + final boolean enableDecoderFallback, + @Nullable final Handler eventHandler, + @Nullable final VideoRendererEventListener eventListener, + final int maxDroppedFramesToNotify) { + super(context, codecAdapterFactory, mediaCodecSelector, allowedJoiningTimeMs, + enableDecoderFallback, eventHandler, eventListener, maxDroppedFramesToNotify); + } + + @Override + protected boolean codecNeedsSetOutputSurfaceWorkaround(final String name) { + return true; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/CustomRenderersFactory.java b/app/src/main/java/org/schabi/newpipe/player/helper/CustomRenderersFactory.java new file mode 100644 index 000000000..668b48c30 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/CustomRenderersFactory.java @@ -0,0 +1,43 @@ +package org.schabi.newpipe.player.helper; + +import android.content.Context; +import android.os.Handler; + +import com.google.android.exoplayer2.DefaultRenderersFactory; +import com.google.android.exoplayer2.Renderer; +import com.google.android.exoplayer2.mediacodec.MediaCodecSelector; +import com.google.android.exoplayer2.video.VideoRendererEventListener; + +import java.util.ArrayList; + +/** + * A {@link DefaultRenderersFactory} which only uses {@link CustomMediaCodecVideoRenderer} as an + * implementation of video codec renders. + * + *

+ * As no ExoPlayer extension is currently used, the reflection code used by ExoPlayer to try to + * load video extension libraries is not needed in our case and has been removed. This should be + * changed in the case an extension is shipped with the app, such as the AV1 one. + *

+ */ +public final class CustomRenderersFactory extends DefaultRenderersFactory { + + public CustomRenderersFactory(final Context context) { + super(context); + } + + @SuppressWarnings("checkstyle:ParameterNumber") + @Override + protected void buildVideoRenderers(final Context context, + @ExtensionRendererMode final int extensionRendererMode, + final MediaCodecSelector mediaCodecSelector, + final boolean enableDecoderFallback, + final Handler eventHandler, + final VideoRendererEventListener eventListener, + final long allowedVideoJoiningTimeMs, + final ArrayList out) { + out.add(new CustomMediaCodecVideoRenderer(context, getCodecAdapterFactory(), + mediaCodecSelector, allowedVideoJoiningTimeMs, enableDecoderFallback, eventHandler, + eventListener, MAX_DROPPED_VIDEO_FRAME_COUNT_TO_NOTIFY)); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java b/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java deleted file mode 100644 index c12ba754a..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java +++ /dev/null @@ -1,215 +0,0 @@ -package org.schabi.newpipe.player.helper; - -import android.content.Context; -import android.content.Intent; -import android.graphics.Bitmap; -import android.support.v4.media.MediaMetadataCompat; -import android.support.v4.media.session.MediaSessionCompat; -import android.support.v4.media.session.PlaybackStateCompat; -import android.util.Log; -import android.view.KeyEvent; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.media.session.MediaButtonReceiver; - -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; - -import org.schabi.newpipe.MainActivity; -import org.schabi.newpipe.player.mediasession.MediaSessionCallback; -import org.schabi.newpipe.player.mediasession.PlayQueueNavigator; - -import java.util.Optional; - -public class MediaSessionManager { - private static final String TAG = MediaSessionManager.class.getSimpleName(); - public static final boolean DEBUG = MainActivity.DEBUG; - - @NonNull - private final MediaSessionCompat mediaSession; - @NonNull - private final MediaSessionConnector sessionConnector; - - private int lastTitleHashCode; - private int lastArtistHashCode; - private long lastDuration; - private int lastAlbumArtHashCode; - - public MediaSessionManager(@NonNull final Context context, - @NonNull final Player player, - @NonNull final MediaSessionCallback callback) { - mediaSession = new MediaSessionCompat(context, TAG); - mediaSession.setActive(true); - - mediaSession.setPlaybackState(new PlaybackStateCompat.Builder() - .setState(PlaybackStateCompat.STATE_NONE, -1, 1) - .setActions(PlaybackStateCompat.ACTION_SEEK_TO - | PlaybackStateCompat.ACTION_PLAY - | PlaybackStateCompat.ACTION_PAUSE // was play and pause now play/pause - | PlaybackStateCompat.ACTION_SKIP_TO_NEXT - | PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS - | PlaybackStateCompat.ACTION_SET_REPEAT_MODE - | PlaybackStateCompat.ACTION_STOP) - .build()); - - sessionConnector = new MediaSessionConnector(mediaSession); - sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, callback)); - sessionConnector.setPlayer(player); - } - - @Nullable - @SuppressWarnings("UnusedReturnValue") - public KeyEvent handleMediaButtonIntent(final Intent intent) { - return MediaButtonReceiver.handleIntent(mediaSession, intent); - } - - public MediaSessionCompat.Token getSessionToken() { - return mediaSession.getSessionToken(); - } - - /** - * sets the Metadata - if required. - * - * @param title {@link MediaMetadataCompat#METADATA_KEY_TITLE} - * @param artist {@link MediaMetadataCompat#METADATA_KEY_ARTIST} - * @param optAlbumArt {@link MediaMetadataCompat#METADATA_KEY_ALBUM_ART} - * @param duration {@link MediaMetadataCompat#METADATA_KEY_DURATION} - * - should be a negative value for unknown durations, e.g. for livestreams - */ - public void setMetadata(@NonNull final String title, - @NonNull final String artist, - @NonNull final Optional optAlbumArt, - final long duration - ) { - if (DEBUG) { - Log.d(TAG, "setMetadata called:" - + " t: " + title - + " a: " + artist - + " thumb: " + ( - optAlbumArt.isPresent() - ? optAlbumArt.get().hashCode() - : "") - + " d: " + duration); - } - - if (!mediaSession.isActive()) { - if (DEBUG) { - Log.d(TAG, "setMetadata: mediaSession not active - exiting"); - } - return; - } - - if (!checkIfMetadataShouldBeSet(title, artist, optAlbumArt, duration)) { - if (DEBUG) { - Log.d(TAG, "setMetadata: No update required - exiting"); - } - return; - } - - if (DEBUG) { - Log.d(TAG, "setMetadata: N_Metadata update:" - + " t: " + title - + " a: " + artist - + " thumb: " + ( - optAlbumArt.isPresent() - ? optAlbumArt.get().hashCode() - : "") - + " d: " + duration); - } - - final MediaMetadataCompat.Builder builder = new MediaMetadataCompat.Builder() - .putString(MediaMetadataCompat.METADATA_KEY_TITLE, title) - .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist) - .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration); - - if (optAlbumArt.isPresent()) { - builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, optAlbumArt.get()); - builder.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, optAlbumArt.get()); - } - - mediaSession.setMetadata(builder.build()); - - lastTitleHashCode = title.hashCode(); - lastArtistHashCode = artist.hashCode(); - lastDuration = duration; - optAlbumArt.ifPresent(bitmap -> lastAlbumArtHashCode = bitmap.hashCode()); - } - - private boolean checkIfMetadataShouldBeSet( - @NonNull final String title, - @NonNull final String artist, - @NonNull final Optional optAlbumArt, - final long duration - ) { - // Check if the values have changed since the last time - if (title.hashCode() != lastTitleHashCode - || artist.hashCode() != lastArtistHashCode - || duration != lastDuration - || (optAlbumArt.isPresent() && optAlbumArt.get().hashCode() != lastAlbumArtHashCode) - ) { - if (DEBUG) { - Log.d(TAG, - "checkIfMetadataShouldBeSet: true - reason: changed values since last"); - } - return true; - } - - // Check if the currently set metadata is valid - if (getMetadataTitle() == null - || getMetadataArtist() == null - // Note that the duration can be <= 0 for live streams - ) { - if (DEBUG) { - if (getMetadataTitle() == null) { - Log.d(TAG, - "N_getMetadataTitle: title == null"); - } else if (getMetadataArtist() == null) { - Log.d(TAG, - "N_getMetadataArtist: artist == null"); - } - } - return true; - } - - // If we got an album art check if the current set AlbumArt is null - if (optAlbumArt.isPresent() && getMetadataAlbumArt() == null) { - if (DEBUG) { - Log.d(TAG, "N_getMetadataAlbumArt: thumb == null"); - } - return true; - } - - // Default - no update required - return false; - } - - - @Nullable - private Bitmap getMetadataAlbumArt() { - return mediaSession.getController().getMetadata() - .getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART); - } - - @Nullable - private String getMetadataTitle() { - return mediaSession.getController().getMetadata() - .getString(MediaMetadataCompat.METADATA_KEY_TITLE); - } - - @Nullable - private String getMetadataArtist() { - return mediaSession.getController().getMetadata() - .getString(MediaMetadataCompat.METADATA_KEY_ARTIST); - } - - /** - * Should be called on player destruction to prevent leakage. - */ - public void dispose() { - sessionConnector.setPlayer(null); - sessionConnector.setQueueNavigator(null); - mediaSession.setActive(false); - mediaSession.release(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java index 1a55c21c3..796208a04 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java @@ -1,123 +1,120 @@ package org.schabi.newpipe.player.helper; +import static org.schabi.newpipe.ktx.ViewUtils.animateRotation; import static org.schabi.newpipe.player.Player.DEBUG; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; +import static org.schabi.newpipe.util.ThemeHelper.resolveDrawable; import android.app.Dialog; import android.content.Context; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.LayerDrawable; import android.os.Bundle; import android.util.Log; import android.view.View; import android.widget.CheckBox; -import android.widget.RelativeLayout; import android.widget.SeekBar; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; +import androidx.core.math.MathUtils; import androidx.fragment.app.DialogFragment; import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.DialogPlaybackParameterBinding; +import org.schabi.newpipe.player.ui.VideoPlayerUi; import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener; import org.schabi.newpipe.util.SliderStrategy; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.function.DoubleConsumer; +import java.util.function.DoubleFunction; +import java.util.function.DoubleSupplier; + +import icepick.Icepick; +import icepick.State; + public class PlaybackParameterDialog extends DialogFragment { + private static final String TAG = "PlaybackParameterDialog"; + // Minimum allowable range in ExoPlayer - private static final double MINIMUM_PLAYBACK_VALUE = 0.10f; - private static final double MAXIMUM_PLAYBACK_VALUE = 3.00f; + private static final double MIN_PITCH_OR_SPEED = 0.10f; + private static final double MAX_PITCH_OR_SPEED = 3.00f; - private static final char STEP_UP_SIGN = '+'; - private static final char STEP_DOWN_SIGN = '-'; + private static final boolean PITCH_CTRL_MODE_PERCENT = false; + private static final boolean PITCH_CTRL_MODE_SEMITONE = true; - private static final double STEP_ONE_PERCENT_VALUE = 0.01f; - private static final double STEP_FIVE_PERCENT_VALUE = 0.05f; - private static final double STEP_TEN_PERCENT_VALUE = 0.10f; - private static final double STEP_TWENTY_FIVE_PERCENT_VALUE = 0.25f; - private static final double STEP_ONE_HUNDRED_PERCENT_VALUE = 1.00f; + private static final double STEP_1_PERCENT_VALUE = 0.01f; + private static final double STEP_5_PERCENT_VALUE = 0.05f; + private static final double STEP_10_PERCENT_VALUE = 0.10f; + private static final double STEP_25_PERCENT_VALUE = 0.25f; + private static final double STEP_100_PERCENT_VALUE = 1.00f; private static final double DEFAULT_TEMPO = 1.00f; - private static final double DEFAULT_PITCH = 1.00f; - private static final int DEFAULT_SEMITONES = 0; - private static final double DEFAULT_STEP = STEP_TWENTY_FIVE_PERCENT_VALUE; + private static final double DEFAULT_PITCH_PERCENT = 1.00f; + private static final double DEFAULT_STEP = STEP_25_PERCENT_VALUE; private static final boolean DEFAULT_SKIP_SILENCE = false; - @NonNull - private static final String TAG = "PlaybackParameterDialog"; - @NonNull - private static final String INITIAL_TEMPO_KEY = "initial_tempo_key"; - @NonNull - private static final String INITIAL_PITCH_KEY = "initial_pitch_key"; + private static final SliderStrategy QUADRATIC_STRATEGY = new SliderStrategy.Quadratic( + MIN_PITCH_OR_SPEED, + MAX_PITCH_OR_SPEED, + 1.00f, + 10_000); - @NonNull - private static final String TEMPO_KEY = "tempo_key"; - @NonNull - private static final String PITCH_KEY = "pitch_key"; - @NonNull - private static final String STEP_SIZE_KEY = "step_size_key"; + private static final SliderStrategy SEMITONE_STRATEGY = new SliderStrategy() { + @Override + public int progressOf(final double value) { + return PlayerSemitoneHelper.percentToSemitones(value) + 12; + } - @NonNull - private final SliderStrategy strategy = new SliderStrategy.Quadratic( - MINIMUM_PLAYBACK_VALUE, MAXIMUM_PLAYBACK_VALUE, - /*centerAt=*/1.00f, /*sliderGranularity=*/10000); + @Override + public double valueOf(final int progress) { + return PlayerSemitoneHelper.semitonesToPercent(progress - 12); + } + }; @Nullable private Callback callback; - private double initialTempo = DEFAULT_TEMPO; - private double initialPitch = DEFAULT_PITCH; - private int initialSemitones = DEFAULT_SEMITONES; - private boolean initialSkipSilence = DEFAULT_SKIP_SILENCE; - private double tempo = DEFAULT_TEMPO; - private double pitch = DEFAULT_PITCH; - private int semitones = DEFAULT_SEMITONES; + @State + double initialTempo = DEFAULT_TEMPO; + @State + double initialPitchPercent = DEFAULT_PITCH_PERCENT; + @State + boolean initialSkipSilence = DEFAULT_SKIP_SILENCE; - @Nullable - private SeekBar tempoSlider; - @Nullable - private TextView tempoCurrentText; - @Nullable - private TextView tempoStepDownText; - @Nullable - private TextView tempoStepUpText; - @Nullable - private SeekBar pitchSlider; - @Nullable - private TextView pitchCurrentText; - @Nullable - private TextView pitchStepDownText; - @Nullable - private TextView pitchStepUpText; - @Nullable - private SeekBar semitoneSlider; - @Nullable - private TextView semitoneCurrentText; - @Nullable - private TextView semitoneStepDownText; - @Nullable - private TextView semitoneStepUpText; - @Nullable - private CheckBox unhookingCheckbox; - @Nullable - private CheckBox skipSilenceCheckbox; - @Nullable - private CheckBox adjustBySemitonesCheckbox; + @State + double tempo = DEFAULT_TEMPO; + @State + double pitchPercent = DEFAULT_PITCH_PERCENT; + @State + boolean skipSilence = DEFAULT_SKIP_SILENCE; - public static PlaybackParameterDialog newInstance(final double playbackTempo, - final double playbackPitch, - final boolean playbackSkipSilence, - final Callback callback) { + private DialogPlaybackParameterBinding binding; + + public static PlaybackParameterDialog newInstance( + final double playbackTempo, + final double playbackPitch, + final boolean playbackSkipSilence, + final Callback callback + ) { final PlaybackParameterDialog dialog = new PlaybackParameterDialog(); dialog.callback = callback; + dialog.initialTempo = playbackTempo; - dialog.initialPitch = playbackPitch; - - dialog.tempo = playbackTempo; - dialog.pitch = playbackPitch; - dialog.semitones = dialog.percentToSemitones(playbackPitch); - + dialog.initialPitchPercent = playbackPitch; dialog.initialSkipSilence = playbackSkipSilence; + + dialog.tempo = dialog.initialTempo; + dialog.pitchPercent = dialog.initialPitchPercent; + dialog.skipSilence = dialog.initialSkipSilence; + return dialog; } @@ -135,29 +132,10 @@ public class PlaybackParameterDialog extends DialogFragment { } } - @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - assureCorrectAppLanguage(getContext()); - super.onCreate(savedInstanceState); - if (savedInstanceState != null) { - initialTempo = savedInstanceState.getDouble(INITIAL_TEMPO_KEY, DEFAULT_TEMPO); - initialPitch = savedInstanceState.getDouble(INITIAL_PITCH_KEY, DEFAULT_PITCH); - initialSemitones = percentToSemitones(initialPitch); - - tempo = savedInstanceState.getDouble(TEMPO_KEY, DEFAULT_TEMPO); - pitch = savedInstanceState.getDouble(PITCH_KEY, DEFAULT_PITCH); - semitones = percentToSemitones(pitch); - } - } - @Override public void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); - outState.putDouble(INITIAL_TEMPO_KEY, initialTempo); - outState.putDouble(INITIAL_PITCH_KEY, initialPitch); - - outState.putDouble(TEMPO_KEY, getCurrentTempo()); - outState.putDouble(PITCH_KEY, getCurrentPitch()); + Icepick.saveInstanceState(this, outState); } /*////////////////////////////////////////////////////////////////////////// @@ -168,327 +146,341 @@ public class PlaybackParameterDialog extends DialogFragment { @Override public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { assureCorrectAppLanguage(getContext()); - final View view = View.inflate(getContext(), R.layout.dialog_playback_parameter, null); - setupControlViews(view); + Icepick.restoreInstanceState(this, savedInstanceState); + + binding = DialogPlaybackParameterBinding.inflate(getLayoutInflater()); + initUI(); final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(requireActivity()) - .setView(view) + .setView(binding.getRoot()) .setCancelable(true) - .setNegativeButton(R.string.cancel, (dialogInterface, i) -> - setPlaybackParameters(initialTempo, initialPitch, - initialSemitones, initialSkipSilence)) - .setNeutralButton(R.string.playback_reset, (dialogInterface, i) -> - setPlaybackParameters(DEFAULT_TEMPO, DEFAULT_PITCH, - DEFAULT_SEMITONES, DEFAULT_SKIP_SILENCE)) - .setPositiveButton(R.string.ok, (dialogInterface, i) -> - setCurrentPlaybackParameters()); + .setNegativeButton(R.string.cancel, (dialogInterface, i) -> { + setAndUpdateTempo(initialTempo); + setAndUpdatePitch(initialPitchPercent); + setAndUpdateSkipSilence(initialSkipSilence); + updateCallback(); + }) + .setNeutralButton(R.string.playback_reset, (dialogInterface, i) -> { + setAndUpdateTempo(DEFAULT_TEMPO); + setAndUpdatePitch(DEFAULT_PITCH_PERCENT); + setAndUpdateSkipSilence(DEFAULT_SKIP_SILENCE); + updateCallback(); + }) + .setPositiveButton(R.string.ok, (dialogInterface, i) -> updateCallback()); return dialogBuilder.create(); } /*////////////////////////////////////////////////////////////////////////// - // Control Views + // UI Initialization and Control //////////////////////////////////////////////////////////////////////////*/ - private void setupControlViews(@NonNull final View rootView) { - setupHookingControl(rootView); - setupSkipSilenceControl(rootView); - setupAdjustBySemitonesControl(rootView); + private void initUI() { + // Tempo + setText(binding.tempoMinimumText, PlayerHelper::formatSpeed, MIN_PITCH_OR_SPEED); + setText(binding.tempoMaximumText, PlayerHelper::formatSpeed, MAX_PITCH_OR_SPEED); - setupTempoControl(rootView); - setupPitchControl(rootView); - setupSemitoneControl(rootView); + binding.tempoSeekbar.setMax(QUADRATIC_STRATEGY.progressOf(MAX_PITCH_OR_SPEED)); + setAndUpdateTempo(tempo); + binding.tempoSeekbar.setOnSeekBarChangeListener( + getTempoOrPitchSeekbarChangeListener( + QUADRATIC_STRATEGY, + this::onTempoSliderUpdated)); - togglePitchSliderType(rootView); + registerOnStepClickListener( + binding.tempoStepDown, + () -> tempo, + -1, + this::onTempoSliderUpdated); + registerOnStepClickListener( + binding.tempoStepUp, + () -> tempo, + 1, + this::onTempoSliderUpdated); - setupStepSizeSelector(rootView); + // Pitch + binding.pitchToogleControlModes.setOnClickListener(v -> { + final boolean isCurrentlyVisible = + binding.pitchControlModeTabs.getVisibility() == View.GONE; + binding.pitchControlModeTabs.setVisibility(isCurrentlyVisible + ? View.VISIBLE + : View.GONE); + animateRotation(binding.pitchToogleControlModes, + VideoPlayerUi.DEFAULT_CONTROLS_DURATION, + isCurrentlyVisible ? 180 : 0); + }); + + getPitchControlModeComponentMappings() + .forEach(this::setupPitchControlModeTextView); + // Initialization is done at the end + + // Pitch - Percent + setText(binding.pitchPercentMinimumText, PlayerHelper::formatPitch, MIN_PITCH_OR_SPEED); + setText(binding.pitchPercentMaximumText, PlayerHelper::formatPitch, MAX_PITCH_OR_SPEED); + + binding.pitchPercentSeekbar.setMax(QUADRATIC_STRATEGY.progressOf(MAX_PITCH_OR_SPEED)); + setAndUpdatePitch(pitchPercent); + binding.pitchPercentSeekbar.setOnSeekBarChangeListener( + getTempoOrPitchSeekbarChangeListener( + QUADRATIC_STRATEGY, + this::onPitchPercentSliderUpdated)); + + registerOnStepClickListener( + binding.pitchPercentStepDown, + () -> pitchPercent, + -1, + this::onPitchPercentSliderUpdated); + registerOnStepClickListener( + binding.pitchPercentStepUp, + () -> pitchPercent, + 1, + this::onPitchPercentSliderUpdated); + + // Pitch - Semitone + binding.pitchSemitoneSeekbar.setOnSeekBarChangeListener( + getTempoOrPitchSeekbarChangeListener( + SEMITONE_STRATEGY, + this::onPitchPercentSliderUpdated)); + + registerOnSemitoneStepClickListener( + binding.pitchSemitoneStepDown, + -1, + this::onPitchPercentSliderUpdated); + registerOnSemitoneStepClickListener( + binding.pitchSemitoneStepUp, + 1, + this::onPitchPercentSliderUpdated); + + // Steps + getStepSizeComponentMappings() + .forEach(this::setupStepTextView); + // Initialize UI + setStepSizeToUI(getCurrentStepSize()); + + // Bottom controls + bindCheckboxWithBoolPref( + binding.unhookCheckbox, + R.string.playback_unhook_key, + true, + isChecked -> { + if (!isChecked) { + // when unchecked, slide back to the minimum of current tempo or pitch + ensureHookIsValidAndUpdateCallBack(); + } + }); + + setAndUpdateSkipSilence(skipSilence); + binding.skipSilenceCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> { + skipSilence = isChecked; + updateCallback(); + }); + + // PitchControlMode has to be initialized at the end because it requires the unhookCheckbox + changePitchControlMode(isCurrentPitchControlModeSemitone()); } - private void togglePitchSliderType(@NonNull final View rootView) { - final RelativeLayout pitchControl = rootView.findViewById(R.id.pitchControl); - final RelativeLayout semitoneControl = rootView.findViewById(R.id.semitoneControl); + // -- General formatting -- - final View separatorStepSizeSelector = - rootView.findViewById(R.id.separatorStepSizeSelector); - final RelativeLayout.LayoutParams params = - (RelativeLayout.LayoutParams) separatorStepSizeSelector.getLayoutParams(); - if (pitchControl != null && semitoneControl != null && unhookingCheckbox != null) { - if (getCurrentAdjustBySemitones()) { - // replaces pitchControl slider with semitoneControl slider - pitchControl.setVisibility(View.GONE); - semitoneControl.setVisibility(View.VISIBLE); - params.addRule(RelativeLayout.BELOW, R.id.semitoneControl); - - // forces unhook for semitones - unhookingCheckbox.setChecked(true); - unhookingCheckbox.setEnabled(false); - - setupTempoStepSizeSelector(rootView); - } else { - semitoneControl.setVisibility(View.GONE); - pitchControl.setVisibility(View.VISIBLE); - params.addRule(RelativeLayout.BELOW, R.id.pitchControl); - - // (re)enables hooking selection - unhookingCheckbox.setEnabled(true); - setupCombinedStepSizeSelector(rootView); - } - } + private void setText( + final TextView textView, + final DoubleFunction formatter, + final double value + ) { + Objects.requireNonNull(textView).setText(formatter.apply(value)); } - private void setupTempoControl(@NonNull final View rootView) { - tempoSlider = rootView.findViewById(R.id.tempoSeekbar); - final TextView tempoMinimumText = rootView.findViewById(R.id.tempoMinimumText); - final TextView tempoMaximumText = rootView.findViewById(R.id.tempoMaximumText); - tempoCurrentText = rootView.findViewById(R.id.tempoCurrentText); - tempoStepUpText = rootView.findViewById(R.id.tempoStepUp); - tempoStepDownText = rootView.findViewById(R.id.tempoStepDown); + // -- Steps -- - if (tempoCurrentText != null) { - tempoCurrentText.setText(PlayerHelper.formatSpeed(tempo)); - } - if (tempoMaximumText != null) { - tempoMaximumText.setText(PlayerHelper.formatSpeed(MAXIMUM_PLAYBACK_VALUE)); - } - if (tempoMinimumText != null) { - tempoMinimumText.setText(PlayerHelper.formatSpeed(MINIMUM_PLAYBACK_VALUE)); - } - - if (tempoSlider != null) { - tempoSlider.setMax(strategy.progressOf(MAXIMUM_PLAYBACK_VALUE)); - tempoSlider.setProgress(strategy.progressOf(tempo)); - tempoSlider.setOnSeekBarChangeListener(getOnTempoChangedListener()); - } + private void registerOnStepClickListener( + final TextView stepTextView, + final DoubleSupplier currentValueSupplier, + final double direction, // -1 for step down, +1 for step up + final DoubleConsumer newValueConsumer + ) { + stepTextView.setOnClickListener(view -> { + newValueConsumer.accept( + currentValueSupplier.getAsDouble() + 1 * getCurrentStepSize() * direction); + updateCallback(); + }); } - private void setupPitchControl(@NonNull final View rootView) { - pitchSlider = rootView.findViewById(R.id.pitchSeekbar); - final TextView pitchMinimumText = rootView.findViewById(R.id.pitchMinimumText); - final TextView pitchMaximumText = rootView.findViewById(R.id.pitchMaximumText); - pitchCurrentText = rootView.findViewById(R.id.pitchCurrentText); - pitchStepDownText = rootView.findViewById(R.id.pitchStepDown); - pitchStepUpText = rootView.findViewById(R.id.pitchStepUp); - - if (pitchCurrentText != null) { - pitchCurrentText.setText(PlayerHelper.formatPitch(pitch)); - } - if (pitchMaximumText != null) { - pitchMaximumText.setText(PlayerHelper.formatPitch(MAXIMUM_PLAYBACK_VALUE)); - } - if (pitchMinimumText != null) { - pitchMinimumText.setText(PlayerHelper.formatPitch(MINIMUM_PLAYBACK_VALUE)); - } - - if (pitchSlider != null) { - pitchSlider.setMax(strategy.progressOf(MAXIMUM_PLAYBACK_VALUE)); - pitchSlider.setProgress(strategy.progressOf(pitch)); - pitchSlider.setOnSeekBarChangeListener(getOnPitchChangedListener()); - } + private void registerOnSemitoneStepClickListener( + final TextView stepTextView, + final int direction, // -1 for step down, +1 for step up + final DoubleConsumer newValueConsumer + ) { + stepTextView.setOnClickListener(view -> { + newValueConsumer.accept(PlayerSemitoneHelper.semitonesToPercent( + PlayerSemitoneHelper.percentToSemitones(this.pitchPercent) + direction)); + updateCallback(); + }); } - private void setupSemitoneControl(@NonNull final View rootView) { - semitoneSlider = rootView.findViewById(R.id.semitoneSeekbar); - semitoneCurrentText = rootView.findViewById(R.id.semitoneCurrentText); - semitoneStepDownText = rootView.findViewById(R.id.semitoneStepDown); - semitoneStepUpText = rootView.findViewById(R.id.semitoneStepUp); + // -- Pitch -- - if (semitoneCurrentText != null) { - semitoneCurrentText.setText(getSignedSemitonesString(semitones)); - } - - if (semitoneSlider != null) { - setSemitoneSlider(semitones); - semitoneSlider.setOnSeekBarChangeListener(getOnSemitoneChangedListener()); - } - - } - - private void setupHookingControl(@NonNull final View rootView) { - unhookingCheckbox = rootView.findViewById(R.id.unhookCheckbox); - if (unhookingCheckbox != null) { - // restores whether pitch and tempo are unhooked or not - unhookingCheckbox.setChecked(PreferenceManager - .getDefaultSharedPreferences(requireContext()) - .getBoolean(getString(R.string.playback_unhook_key), true)); - - unhookingCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> { - // saves whether pitch and tempo are unhooked or not - PreferenceManager.getDefaultSharedPreferences(requireContext()) - .edit() - .putBoolean(getString(R.string.playback_unhook_key), isChecked) - .apply(); - - if (!isChecked) { - // when unchecked, slides back to the minimum of current tempo or pitch - final double minimum = Math.min(getCurrentPitch(), getCurrentTempo()); - setSliders(minimum); - setCurrentPlaybackParameters(); - } - }); - } - } - - private void setupSkipSilenceControl(@NonNull final View rootView) { - skipSilenceCheckbox = rootView.findViewById(R.id.skipSilenceCheckbox); - if (skipSilenceCheckbox != null) { - skipSilenceCheckbox.setChecked(initialSkipSilence); - skipSilenceCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> - setCurrentPlaybackParameters()); - } - } - - private void setupAdjustBySemitonesControl(@NonNull final View rootView) { - adjustBySemitonesCheckbox = rootView.findViewById(R.id.adjustBySemitonesCheckbox); - if (adjustBySemitonesCheckbox != null) { - // restores whether semitone adjustment is used or not - adjustBySemitonesCheckbox.setChecked(PreferenceManager - .getDefaultSharedPreferences(requireContext()) - .getBoolean(getString(R.string.playback_adjust_by_semitones_key), true)); - - // stores whether semitone adjustment is used or not - adjustBySemitonesCheckbox.setOnCheckedChangeListener((compoundButton, isChecked) -> { - PreferenceManager.getDefaultSharedPreferences(requireContext()) + private void setupPitchControlModeTextView( + final boolean semitones, + final TextView textView + ) { + textView.setOnClickListener(view -> { + PreferenceManager.getDefaultSharedPreferences(requireContext()) .edit() - .putBoolean(getString(R.string.playback_adjust_by_semitones_key), isChecked) + .putBoolean(getString(R.string.playback_adjust_by_semitones_key), semitones) .apply(); - togglePitchSliderType(rootView); - if (isChecked) { - setPlaybackParameters( - getCurrentTempo(), - getCurrentPitch(), - Integer.min(12, - Integer.max(-12, percentToSemitones(getCurrentPitch()) - )), - getCurrentSkipSilence() + + changePitchControlMode(semitones); + }); + } + + private Map getPitchControlModeComponentMappings() { + return Map.of(PITCH_CTRL_MODE_PERCENT, binding.pitchControlModePercent, + PITCH_CTRL_MODE_SEMITONE, binding.pitchControlModeSemitone); + } + + private void changePitchControlMode(final boolean semitones) { + // Bring all textviews into a normal state + final Map pitchCtrlModeComponentMapping = + getPitchControlModeComponentMappings(); + pitchCtrlModeComponentMapping.forEach((v, textView) -> textView.setBackground( + resolveDrawable(requireContext(), R.attr.selectableItemBackground))); + + // Mark the selected textview + final TextView textView = pitchCtrlModeComponentMapping.get(semitones); + if (textView != null) { + textView.setBackground(new LayerDrawable(new Drawable[]{ + resolveDrawable(requireContext(), R.attr.dashed_border), + resolveDrawable(requireContext(), R.attr.selectableItemBackground) + })); + } + + // Show or hide component + binding.pitchPercentControl.setVisibility(semitones ? View.GONE : View.VISIBLE); + binding.pitchSemitoneControl.setVisibility(semitones ? View.VISIBLE : View.GONE); + + if (semitones) { + // Recalculate pitch percent when changing to semitone + // (as it could be an invalid semitone value) + final double newPitchPercent = calcValidPitch(pitchPercent); + + // If the values differ set the new pitch + if (this.pitchPercent != newPitchPercent) { + if (DEBUG) { + Log.d(TAG, "Bringing pitchPercent to correct corresponding semitone: " + + "currentPitchPercent = " + pitchPercent + ", " + + "newPitchPercent = " + newPitchPercent ); - setSemitoneSlider(Integer.min(12, - Integer.max(-12, percentToSemitones(getCurrentPitch())) - )); - } else { - setPlaybackParameters( - getCurrentTempo(), - semitonesToPercent(getCurrentSemitones()), - getCurrentSemitones(), - getCurrentSkipSilence() - ); - setPitchSlider(semitonesToPercent(getCurrentSemitones())); } - }); + this.onPitchPercentSliderUpdated(newPitchPercent); + updateCallback(); + } + } else if (!binding.unhookCheckbox.isChecked()) { + // When changing to percent it's possible that tempo is != pitch + ensureHookIsValidAndUpdateCallBack(); } } - private void setupStepSizeSelector(@NonNull final View rootView) { - setStepSize(PreferenceManager + private boolean isCurrentPitchControlModeSemitone() { + return PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getBoolean( + getString(R.string.playback_adjust_by_semitones_key), + PITCH_CTRL_MODE_PERCENT); + } + + // -- Steps (Set) -- + + private void setupStepTextView( + final double stepSizeValue, + final TextView textView + ) { + setText(textView, PlaybackParameterDialog::getPercentString, stepSizeValue); + textView.setOnClickListener(view -> { + PreferenceManager.getDefaultSharedPreferences(requireContext()) + .edit() + .putFloat(getString(R.string.adjustment_step_key), (float) stepSizeValue) + .apply(); + + setStepSizeToUI(stepSizeValue); + }); + } + + private Map getStepSizeComponentMappings() { + return Map.of(STEP_1_PERCENT_VALUE, binding.stepSizeOnePercent, + STEP_5_PERCENT_VALUE, binding.stepSizeFivePercent, + STEP_10_PERCENT_VALUE, binding.stepSizeTenPercent, + STEP_25_PERCENT_VALUE, binding.stepSizeTwentyFivePercent, + STEP_100_PERCENT_VALUE, binding.stepSizeOneHundredPercent); + } + + private void setStepSizeToUI(final double newStepSize) { + // Bring all textviews into a normal state + final Map stepSiteComponentMapping = getStepSizeComponentMappings(); + stepSiteComponentMapping.forEach((v, textView) -> textView.setBackground( + resolveDrawable(requireContext(), R.attr.selectableItemBackground))); + + // Mark the selected textview + final TextView textView = stepSiteComponentMapping.get(newStepSize); + if (textView != null) { + textView.setBackground(new LayerDrawable(new Drawable[]{ + resolveDrawable(requireContext(), R.attr.dashed_border), + resolveDrawable(requireContext(), R.attr.selectableItemBackground) + })); + } + + // Bind to the corresponding control components + binding.tempoStepUp.setText(getStepUpPercentString(newStepSize)); + binding.tempoStepDown.setText(getStepDownPercentString(newStepSize)); + + binding.pitchPercentStepUp.setText(getStepUpPercentString(newStepSize)); + binding.pitchPercentStepDown.setText(getStepDownPercentString(newStepSize)); + } + + private double getCurrentStepSize() { + return PreferenceManager.getDefaultSharedPreferences(requireContext()) + .getFloat(getString(R.string.adjustment_step_key), (float) DEFAULT_STEP); + } + + // -- Additional options -- + + private void setAndUpdateSkipSilence(final boolean newSkipSilence) { + this.skipSilence = newSkipSilence; + binding.skipSilenceCheckbox.setChecked(newSkipSilence); + } + + @SuppressWarnings("SameParameterValue") // this method was written to be reusable + private void bindCheckboxWithBoolPref( + @NonNull final CheckBox checkBox, + @StringRes final int resId, + final boolean defaultValue, + @NonNull final Consumer onInitialValueOrValueChange + ) { + final boolean prefValue = PreferenceManager .getDefaultSharedPreferences(requireContext()) - .getFloat(getString(R.string.adjustment_step_key), (float) DEFAULT_STEP)); + .getBoolean(getString(resId), defaultValue); - final TextView stepSizeOnePercentText = rootView.findViewById(R.id.stepSizeOnePercent); - final TextView stepSizeFivePercentText = rootView.findViewById(R.id.stepSizeFivePercent); - final TextView stepSizeTenPercentText = rootView.findViewById(R.id.stepSizeTenPercent); - final TextView stepSizeTwentyFivePercentText = rootView - .findViewById(R.id.stepSizeTwentyFivePercent); - final TextView stepSizeOneHundredPercentText = rootView - .findViewById(R.id.stepSizeOneHundredPercent); + checkBox.setChecked(prefValue); - if (stepSizeOnePercentText != null) { - stepSizeOnePercentText.setText(getPercentString(STEP_ONE_PERCENT_VALUE)); - stepSizeOnePercentText - .setOnClickListener(view -> setStepSize(STEP_ONE_PERCENT_VALUE)); - } + onInitialValueOrValueChange.accept(prefValue); - if (stepSizeFivePercentText != null) { - stepSizeFivePercentText.setText(getPercentString(STEP_FIVE_PERCENT_VALUE)); - stepSizeFivePercentText - .setOnClickListener(view -> setStepSize(STEP_FIVE_PERCENT_VALUE)); - } + checkBox.setOnCheckedChangeListener((compoundButton, isChecked) -> { + // save whether pitch and tempo are unhooked or not + PreferenceManager.getDefaultSharedPreferences(requireContext()) + .edit() + .putBoolean(getString(resId), isChecked) + .apply(); - if (stepSizeTenPercentText != null) { - stepSizeTenPercentText.setText(getPercentString(STEP_TEN_PERCENT_VALUE)); - stepSizeTenPercentText - .setOnClickListener(view -> setStepSize(STEP_TEN_PERCENT_VALUE)); - } - - if (stepSizeTwentyFivePercentText != null) { - stepSizeTwentyFivePercentText - .setText(getPercentString(STEP_TWENTY_FIVE_PERCENT_VALUE)); - stepSizeTwentyFivePercentText - .setOnClickListener(view -> setStepSize(STEP_TWENTY_FIVE_PERCENT_VALUE)); - } - - if (stepSizeOneHundredPercentText != null) { - stepSizeOneHundredPercentText - .setText(getPercentString(STEP_ONE_HUNDRED_PERCENT_VALUE)); - stepSizeOneHundredPercentText - .setOnClickListener(view -> setStepSize(STEP_ONE_HUNDRED_PERCENT_VALUE)); - } + onInitialValueOrValueChange.accept(isChecked); + }); } - private void setupTempoStepSizeSelector(@NonNull final View rootView) { - final TextView playbackStepTypeText = rootView.findViewById(R.id.playback_step_type); - if (playbackStepTypeText != null) { - playbackStepTypeText.setText(R.string.playback_tempo_step); - } - setupStepSizeSelector(rootView); - } - - private void setupCombinedStepSizeSelector(@NonNull final View rootView) { - final TextView playbackStepTypeText = rootView.findViewById(R.id.playback_step_type); - if (playbackStepTypeText != null) { - playbackStepTypeText.setText(R.string.playback_step); - } - setupStepSizeSelector(rootView); - } - - private void setStepSize(final double stepSize) { - PreferenceManager.getDefaultSharedPreferences(requireContext()) - .edit() - .putFloat(getString(R.string.adjustment_step_key), (float) stepSize) - .apply(); - - if (tempoStepUpText != null) { - tempoStepUpText.setText(getStepUpPercentString(stepSize)); - tempoStepUpText.setOnClickListener(view -> { - onTempoSliderUpdated(getCurrentTempo() + stepSize); - setCurrentPlaybackParameters(); - }); - } - - if (tempoStepDownText != null) { - tempoStepDownText.setText(getStepDownPercentString(stepSize)); - tempoStepDownText.setOnClickListener(view -> { - onTempoSliderUpdated(getCurrentTempo() - stepSize); - setCurrentPlaybackParameters(); - }); - } - - if (pitchStepUpText != null) { - pitchStepUpText.setText(getStepUpPercentString(stepSize)); - pitchStepUpText.setOnClickListener(view -> { - onPitchSliderUpdated(getCurrentPitch() + stepSize); - setCurrentPlaybackParameters(); - }); - } - - if (pitchStepDownText != null) { - pitchStepDownText.setText(getStepDownPercentString(stepSize)); - pitchStepDownText.setOnClickListener(view -> { - onPitchSliderUpdated(getCurrentPitch() - stepSize); - setCurrentPlaybackParameters(); - }); - } - - if (semitoneStepDownText != null) { - semitoneStepDownText.setOnClickListener(view -> { - onSemitoneSliderUpdated(getCurrentSemitones() - 1); - setCurrentPlaybackParameters(); - }); - } - - if (semitoneStepUpText != null) { - semitoneStepUpText.setOnClickListener(view -> { - onSemitoneSliderUpdated(getCurrentSemitones() + 1); - setCurrentPlaybackParameters(); - }); + /** + * Ensures that the slider hook is valid and if not sets and updates the sliders accordingly. + *
+ * You have to ensure by yourself that the hooking is active. + */ + private void ensureHookIsValidAndUpdateCallBack() { + if (tempo != pitchPercent) { + setSliders(Math.min(tempo, pitchPercent)); + updateCallback(); } } @@ -496,166 +488,101 @@ public class PlaybackParameterDialog extends DialogFragment { // Sliders //////////////////////////////////////////////////////////////////////////*/ - private SimpleOnSeekBarChangeListener getOnTempoChangedListener() { + private SeekBar.OnSeekBarChangeListener getTempoOrPitchSeekbarChangeListener( + final SliderStrategy sliderStrategy, + final DoubleConsumer newValueConsumer + ) { return new SimpleOnSeekBarChangeListener() { @Override - public void onProgressChanged(@NonNull final SeekBar seekBar, final int progress, + public void onProgressChanged(@NonNull final SeekBar seekBar, + final int progress, final boolean fromUser) { - final double currentTempo = strategy.valueOf(progress); - if (fromUser) { - onTempoSliderUpdated(currentTempo); - setCurrentPlaybackParameters(); - } - } - }; - } - - private SimpleOnSeekBarChangeListener getOnPitchChangedListener() { - return new SimpleOnSeekBarChangeListener() { - @Override - public void onProgressChanged(@NonNull final SeekBar seekBar, final int progress, - final boolean fromUser) { - final double currentPitch = strategy.valueOf(progress); - if (fromUser) { // this change is first in chain - onPitchSliderUpdated(currentPitch); - setCurrentPlaybackParameters(); - } - } - }; - } - - private SimpleOnSeekBarChangeListener getOnSemitoneChangedListener() { - return new SimpleOnSeekBarChangeListener() { - @Override - public void onProgressChanged(@NonNull final SeekBar seekBar, final int progress, - final boolean fromUser) { - // semitone slider supplies values 0 to 24, subtraction by 12 is required - final int currentSemitones = progress - 12; - if (fromUser) { // this change is first in chain - onSemitoneSliderUpdated(currentSemitones); - // line below also saves semitones as pitch percentages - onPitchSliderUpdated(semitonesToPercent(currentSemitones)); - setCurrentPlaybackParameters(); + if (fromUser) { // ensure that the user triggered the change + newValueConsumer.accept(sliderStrategy.valueOf(progress)); + updateCallback(); } } }; } private void onTempoSliderUpdated(final double newTempo) { - if (!unhookingCheckbox.isChecked()) { + if (!binding.unhookCheckbox.isChecked()) { setSliders(newTempo); } else { - setTempoSlider(newTempo); + setAndUpdateTempo(newTempo); } } - private void onPitchSliderUpdated(final double newPitch) { - if (!unhookingCheckbox.isChecked()) { + private void onPitchPercentSliderUpdated(final double newPitch) { + if (!binding.unhookCheckbox.isChecked()) { setSliders(newPitch); } else { - setPitchSlider(newPitch); + setAndUpdatePitch(newPitch); } } - private void onSemitoneSliderUpdated(final int newSemitone) { - setSemitoneSlider(newSemitone); - } - private void setSliders(final double newValue) { - setTempoSlider(newValue); - setPitchSlider(newValue); + setAndUpdateTempo(newValue); + setAndUpdatePitch(newValue); } - private void setTempoSlider(final double newTempo) { - if (tempoSlider == null) { - return; - } - tempoSlider.setProgress(strategy.progressOf(newTempo)); + private void setAndUpdateTempo(final double newTempo) { + this.tempo = MathUtils.clamp(newTempo, MIN_PITCH_OR_SPEED, MAX_PITCH_OR_SPEED); + + binding.tempoSeekbar.setProgress(QUADRATIC_STRATEGY.progressOf(tempo)); + setText(binding.tempoCurrentText, PlayerHelper::formatSpeed, tempo); } - private void setPitchSlider(final double newPitch) { - if (pitchSlider == null) { - return; - } - pitchSlider.setProgress(strategy.progressOf(newPitch)); + private void setAndUpdatePitch(final double newPitch) { + this.pitchPercent = calcValidPitch(newPitch); + + binding.pitchPercentSeekbar.setProgress(QUADRATIC_STRATEGY.progressOf(pitchPercent)); + binding.pitchSemitoneSeekbar.setProgress(SEMITONE_STRATEGY.progressOf(pitchPercent)); + setText(binding.pitchPercentCurrentText, + PlayerHelper::formatPitch, + pitchPercent); + setText(binding.pitchSemitoneCurrentText, + PlayerSemitoneHelper::formatPitchSemitones, + pitchPercent); } - private void setSemitoneSlider(final int newSemitone) { - if (semitoneSlider == null) { - return; + private double calcValidPitch(final double newPitch) { + final double calcPitch = MathUtils.clamp(newPitch, MIN_PITCH_OR_SPEED, MAX_PITCH_OR_SPEED); + + if (!isCurrentPitchControlModeSemitone()) { + return calcPitch; } - semitoneSlider.setProgress(newSemitone + 12); + + return PlayerSemitoneHelper.semitonesToPercent( + PlayerSemitoneHelper.percentToSemitones(calcPitch)); } /*////////////////////////////////////////////////////////////////////////// // Helper //////////////////////////////////////////////////////////////////////////*/ - private void setCurrentPlaybackParameters() { - if (getCurrentAdjustBySemitones()) { - setPlaybackParameters( - getCurrentTempo(), - semitonesToPercent(getCurrentSemitones()), - getCurrentSemitones(), - getCurrentSkipSilence() - ); - } else { - setPlaybackParameters( - getCurrentTempo(), - getCurrentPitch(), - percentToSemitones(getCurrentPitch()), - getCurrentSkipSilence() + private void updateCallback() { + if (callback == null) { + return; + } + if (DEBUG) { + Log.d(TAG, "Updating callback: " + + "tempo = " + tempo + ", " + + "pitchPercent = " + pitchPercent + ", " + + "skipSilence = " + skipSilence ); } - } - - private void setPlaybackParameters(final double newTempo, final double newPitch, - final int newSemitones, final boolean skipSilence) { - if (callback != null && tempoCurrentText != null - && pitchCurrentText != null && semitoneCurrentText != null) { - if (DEBUG) { - Log.d(TAG, "Setting playback parameters to " - + "tempo=[" + newTempo + "], " - + "pitch=[" + newPitch + "], " - + "semitones=[" + newSemitones + "]"); - } - - tempoCurrentText.setText(PlayerHelper.formatSpeed(newTempo)); - pitchCurrentText.setText(PlayerHelper.formatPitch(newPitch)); - semitoneCurrentText.setText(getSignedSemitonesString(newSemitones)); - callback.onPlaybackParameterChanged((float) newTempo, (float) newPitch, skipSilence); - } - } - - private double getCurrentTempo() { - return tempoSlider == null ? tempo : strategy.valueOf(tempoSlider.getProgress()); - } - - private double getCurrentPitch() { - return pitchSlider == null ? pitch : strategy.valueOf(pitchSlider.getProgress()); - } - - private int getCurrentSemitones() { - // semitoneSlider is absolute, that's why - 12 - return semitoneSlider == null ? semitones : semitoneSlider.getProgress() - 12; - } - - private boolean getCurrentSkipSilence() { - return skipSilenceCheckbox != null && skipSilenceCheckbox.isChecked(); - } - - private boolean getCurrentAdjustBySemitones() { - return adjustBySemitonesCheckbox != null && adjustBySemitonesCheckbox.isChecked(); + callback.onPlaybackParameterChanged((float) tempo, (float) pitchPercent, skipSilence); } @NonNull private static String getStepUpPercentString(final double percent) { - return STEP_UP_SIGN + getPercentString(percent); + return '+' + getPercentString(percent); } @NonNull private static String getStepDownPercentString(final double percent) { - return STEP_DOWN_SIGN + getPercentString(percent); + return '-' + getPercentString(percent); } @NonNull @@ -663,21 +590,8 @@ public class PlaybackParameterDialog extends DialogFragment { return PlayerHelper.formatPitch(percent); } - @NonNull - private static String getSignedSemitonesString(final int semitones) { - return semitones > 0 ? "+" + semitones : "" + semitones; - } - public interface Callback { void onPlaybackParameterChanged(float playbackTempo, float playbackPitch, boolean playbackSkipSilence); } - - public double semitonesToPercent(final int inSemitones) { - return Math.pow(2, inSemitones / 12.0); - } - - public int percentToSemitones(final double inPercent) { - return (int) Math.round(12 * Math.log(inPercent) / Math.log(2)); - } } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java index 405f6fd37..0530d56e9 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerDataSource.java @@ -1,7 +1,13 @@ package org.schabi.newpipe.player.helper; -import android.content.Context; +import static org.schabi.newpipe.MainActivity.DEBUG; +import android.content.Context; +import android.util.Log; + +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.database.StandaloneDatabaseProvider; import com.google.android.exoplayer2.source.ProgressiveMediaSource; import com.google.android.exoplayer2.source.SingleSampleMediaSource; import com.google.android.exoplayer2.source.dash.DashMediaSource; @@ -13,12 +19,21 @@ import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; import com.google.android.exoplayer2.upstream.DataSource; import com.google.android.exoplayer2.upstream.DefaultDataSource; import com.google.android.exoplayer2.upstream.DefaultHttpDataSource; -import com.google.android.exoplayer2.upstream.DefaultLoadErrorHandlingPolicy; import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor; +import com.google.android.exoplayer2.upstream.cache.SimpleCache; -import androidx.annotation.NonNull; +import org.schabi.newpipe.DownloaderImpl; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeOtfDashManifestCreator; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubePostLiveStreamDvrDashManifestCreator; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeProgressiveDashManifestCreator; +import org.schabi.newpipe.player.datasource.NonUriHlsDataSourceFactory; +import org.schabi.newpipe.player.datasource.YoutubeHttpDataSource; + +import java.io.File; public class PlayerDataSource { + public static final String TAG = PlayerDataSource.class.getSimpleName(); public static final int LIVE_STREAM_EDGE_GAP_MILLIS = 10000; @@ -29,79 +44,174 @@ public class PlayerDataSource { * 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; - private final int continueLoadingCheckIntervalBytes; - private final DataSource.Factory cacheDataSourceFactory; + /** + * The maximum number of generated manifests per cache, in + * {@link YoutubeProgressiveDashManifestCreator}, {@link YoutubeOtfDashManifestCreator} and + * {@link YoutubePostLiveStreamDvrDashManifestCreator}. + */ + private static final int MAX_MANIFEST_CACHE_SIZE = 500; + + /** + * The folder name in which the ExoPlayer cache will be written. + */ + private static final String CACHE_FOLDER_NAME = "exoplayer"; + + /** + * The {@link SimpleCache} instance which will be used to build + * {@link com.google.android.exoplayer2.upstream.cache.CacheDataSource}s instances (with + * {@link CacheFactory}). + */ + private static SimpleCache cache; + + + private final int progressiveLoadIntervalBytes; + + // Generic Data Source Factories (without or with cache) private final DataSource.Factory cachelessDataSourceFactory; + private final CacheFactory cacheDataSourceFactory; - public PlayerDataSource(@NonNull final Context context, - @NonNull final String userAgent, - @NonNull final TransferListener transferListener) { - continueLoadingCheckIntervalBytes = PlayerHelper.getProgressiveLoadIntervalBytes(context); - cacheDataSourceFactory = new CacheFactory(context, userAgent, transferListener); - cachelessDataSourceFactory = new DefaultDataSource - .Factory(context, new DefaultHttpDataSource.Factory().setUserAgent(userAgent)) + // YouTube-specific Data Source Factories (with cache) + // They use YoutubeHttpDataSource.Factory, with different parameters each + private final CacheFactory ytHlsCacheDataSourceFactory; + private final CacheFactory ytDashCacheDataSourceFactory; + private final CacheFactory ytProgressiveDashCacheDataSourceFactory; + + + public PlayerDataSource(final Context context, + final TransferListener transferListener) { + + progressiveLoadIntervalBytes = PlayerHelper.getProgressiveLoadIntervalBytes(context); + + // make sure the static cache was created: needed by CacheFactories below + instantiateCacheIfNeeded(context); + + // generic data source factories use DefaultHttpDataSource.Factory + cachelessDataSourceFactory = new DefaultDataSource.Factory(context, + new DefaultHttpDataSource.Factory().setUserAgent(DownloaderImpl.USER_AGENT)) .setTransferListener(transferListener); + cacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + new DefaultHttpDataSource.Factory().setUserAgent(DownloaderImpl.USER_AGENT)); + + // YouTube-specific data source factories use getYoutubeHttpDataSourceFactory() + ytHlsCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + getYoutubeHttpDataSourceFactory(false, false)); + ytDashCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + getYoutubeHttpDataSourceFactory(true, true)); + ytProgressiveDashCacheDataSourceFactory = new CacheFactory(context, transferListener, cache, + getYoutubeHttpDataSourceFactory(false, true)); + + // set the maximum size to manifest creators + YoutubeProgressiveDashManifestCreator.getCache().setMaximumSize(MAX_MANIFEST_CACHE_SIZE); + YoutubeOtfDashManifestCreator.getCache().setMaximumSize(MAX_MANIFEST_CACHE_SIZE); + YoutubePostLiveStreamDvrDashManifestCreator.getCache().setMaximumSize( + MAX_MANIFEST_CACHE_SIZE); } + + //region Live media source factories public SsMediaSource.Factory getLiveSsMediaSourceFactory() { - return new SsMediaSource.Factory( - new DefaultSsChunkSource.Factory(cachelessDataSourceFactory), - cachelessDataSourceFactory - ) - .setLoadErrorHandlingPolicy( - new DefaultLoadErrorHandlingPolicy(MANIFEST_MINIMUM_RETRY)) - .setLivePresentationDelayMs(LIVE_STREAM_EDGE_GAP_MILLIS); + return getSSMediaSourceFactory().setLivePresentationDelayMs(LIVE_STREAM_EDGE_GAP_MILLIS); } public HlsMediaSource.Factory getLiveHlsMediaSourceFactory() { return new HlsMediaSource.Factory(cachelessDataSourceFactory) .setAllowChunklessPreparation(true) - .setLoadErrorHandlingPolicy(new DefaultLoadErrorHandlingPolicy( - MANIFEST_MINIMUM_RETRY)) .setPlaylistTrackerFactory((dataSourceFactory, loadErrorHandlingPolicy, playlistParserFactory) -> new DefaultHlsPlaylistTracker(dataSourceFactory, loadErrorHandlingPolicy, - playlistParserFactory, PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT) - ); + playlistParserFactory, + PLAYLIST_STUCK_TARGET_DURATION_COEFFICIENT)); } public DashMediaSource.Factory getLiveDashMediaSourceFactory() { return new DashMediaSource.Factory( getDefaultDashChunkSourceFactory(cachelessDataSourceFactory), - cachelessDataSourceFactory - ) - .setLoadErrorHandlingPolicy( - new DefaultLoadErrorHandlingPolicy(MANIFEST_MINIMUM_RETRY)); + cachelessDataSourceFactory); } + //endregion - private DefaultDashChunkSource.Factory getDefaultDashChunkSourceFactory( - final DataSource.Factory dataSourceFactory - ) { - return new DefaultDashChunkSource.Factory(dataSourceFactory); - } - public HlsMediaSource.Factory getHlsMediaSourceFactory() { + //region Generic media source factories + public HlsMediaSource.Factory getHlsMediaSourceFactory( + @Nullable final NonUriHlsDataSourceFactory.Builder hlsDataSourceFactoryBuilder) { + if (hlsDataSourceFactoryBuilder != null) { + hlsDataSourceFactoryBuilder.setDataSourceFactory(cacheDataSourceFactory); + return new HlsMediaSource.Factory(hlsDataSourceFactoryBuilder.build()); + } + return new HlsMediaSource.Factory(cacheDataSourceFactory); } public DashMediaSource.Factory getDashMediaSourceFactory() { return new DashMediaSource.Factory( getDefaultDashChunkSourceFactory(cacheDataSourceFactory), - cacheDataSourceFactory - ); + cacheDataSourceFactory); } - public ProgressiveMediaSource.Factory getExtractorMediaSourceFactory() { + public ProgressiveMediaSource.Factory getProgressiveMediaSourceFactory() { return new ProgressiveMediaSource.Factory(cacheDataSourceFactory) - .setContinueLoadingCheckIntervalBytes(continueLoadingCheckIntervalBytes) - .setLoadErrorHandlingPolicy( - new DefaultLoadErrorHandlingPolicy(EXTRACTOR_MINIMUM_RETRY)); + .setContinueLoadingCheckIntervalBytes(progressiveLoadIntervalBytes); } - public SingleSampleMediaSource.Factory getSampleMediaSourceFactory() { + public SsMediaSource.Factory getSSMediaSourceFactory() { + return new SsMediaSource.Factory( + new DefaultSsChunkSource.Factory(cachelessDataSourceFactory), + cachelessDataSourceFactory); + } + + public SingleSampleMediaSource.Factory getSingleSampleMediaSourceFactory() { return new SingleSampleMediaSource.Factory(cacheDataSourceFactory); } + //endregion + + + //region YouTube media source factories + public HlsMediaSource.Factory getYoutubeHlsMediaSourceFactory() { + return new HlsMediaSource.Factory(ytHlsCacheDataSourceFactory); + } + + public DashMediaSource.Factory getYoutubeDashMediaSourceFactory() { + return new DashMediaSource.Factory( + getDefaultDashChunkSourceFactory(ytDashCacheDataSourceFactory), + ytDashCacheDataSourceFactory); + } + + public ProgressiveMediaSource.Factory getYoutubeProgressiveMediaSourceFactory() { + return new ProgressiveMediaSource.Factory(ytProgressiveDashCacheDataSourceFactory) + .setContinueLoadingCheckIntervalBytes(progressiveLoadIntervalBytes); + } + //endregion + + + //region Static methods + private static DefaultDashChunkSource.Factory getDefaultDashChunkSourceFactory( + final DataSource.Factory dataSourceFactory) { + return new DefaultDashChunkSource.Factory(dataSourceFactory); + } + + private static YoutubeHttpDataSource.Factory getYoutubeHttpDataSourceFactory( + final boolean rangeParameterEnabled, + final boolean rnParameterEnabled) { + return new YoutubeHttpDataSource.Factory() + .setRangeParameterEnabled(rangeParameterEnabled) + .setRnParameterEnabled(rnParameterEnabled); + } + + private static void instantiateCacheIfNeeded(final Context context) { + if (cache == null) { + final File cacheDir = new File(context.getExternalCacheDir(), CACHE_FOLDER_NAME); + if (DEBUG) { + Log.d(TAG, "instantiateCacheIfNeeded: cacheDir = " + cacheDir.getAbsolutePath()); + } + if (!cacheDir.exists() && !cacheDir.mkdir()) { + Log.w(TAG, "instantiateCacheIfNeeded: could not create cache dir"); + } + + final LeastRecentlyUsedCacheEvictor evictor = + new LeastRecentlyUsedCacheEvictor(PlayerHelper.getPreferredCacheSize()); + cache = new SimpleCache(cacheDir, evictor, new StandaloneDatabaseProvider(context)); + } + } + //endregion } diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java index 6a7c27bdc..a110a80d6 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java @@ -3,8 +3,6 @@ package org.schabi.newpipe.player.helper; import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF; import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; -import static org.schabi.newpipe.player.Player.IDLE_WINDOW_FLAGS; -import static org.schabi.newpipe.player.Player.PLAYER_TYPE; import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_ALWAYS; import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_NEVER; import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_WIFI; @@ -15,14 +13,9 @@ import static java.lang.annotation.RetentionPolicy.SOURCE; import android.annotation.SuppressLint; import android.content.Context; -import android.content.Intent; import android.content.SharedPreferences; -import android.graphics.PixelFormat; -import android.os.Build; +import android.content.pm.PackageManager; import android.provider.Settings; -import android.view.Gravity; -import android.view.ViewGroup; -import android.view.WindowManager; import android.view.accessibility.CaptioningManager; import androidx.annotation.IntDef; @@ -45,13 +38,10 @@ import com.google.android.exoplayer2.util.MimeTypes; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.MediaFormat; -import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.SubtitlesStream; -import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.extractor.utils.Utils; -import org.schabi.newpipe.player.MainPlayer; import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueueItem; @@ -73,8 +63,8 @@ import java.util.concurrent.TimeUnit; public final class PlayerHelper { private static final StringBuilder STRING_BUILDER = new StringBuilder(); - private static final Formatter STRING_FORMATTER - = new Formatter(STRING_BUILDER, Locale.getDefault()); + private static final Formatter STRING_FORMATTER = + new Formatter(STRING_BUILDER, Locale.getDefault()); private static final NumberFormat SPEED_FORMATTER = new DecimalFormat("0.##x"); private static final NumberFormat PITCH_FORMATTER = new DecimalFormat("##%"); @@ -96,12 +86,14 @@ public final class PlayerHelper { int MINIMIZE_ON_EXIT_MODE_POPUP = 2; } - private PlayerHelper() { } + private PlayerHelper() { + } //////////////////////////////////////////////////////////////////////////// // Exposed helpers //////////////////////////////////////////////////////////////////////////// + @NonNull public static String getTimeString(final int milliSeconds) { final int seconds = (milliSeconds % 60000) / 1000; final int minutes = (milliSeconds % 3600000) / 60000; @@ -117,15 +109,18 @@ public final class PlayerHelper { ).toString(); } + @NonNull public static String formatSpeed(final double speed) { return SPEED_FORMATTER.format(speed); } + @NonNull public static String formatPitch(final double pitch) { return PITCH_FORMATTER.format(pitch); } - public static String subtitleMimeTypesOf(final MediaFormat format) { + @NonNull + public static String subtitleMimeTypesOf(@NonNull final MediaFormat format) { switch (format) { case VTT: return MimeTypes.TEXT_VTT; @@ -144,6 +139,21 @@ public final class PlayerHelper { ? " (" + context.getString(R.string.caption_auto_generated) + ")" : ""); } + @NonNull + public static String captionLanguageStemOf(@NonNull final String language) { + if (!language.contains("(") || !language.contains(")")) { + return language; + } + + if (language.startsWith("(")) { + // language text is right-to-left + final String[] parts = language.split("\\)"); + return parts[parts.length - 1].trim(); + } + + return language.split("\\(")[0].trim(); + } + @NonNull public static String resizeTypeOf(@NonNull final Context context, @ResizeMode final int resizeMode) { @@ -161,18 +171,6 @@ public final class PlayerHelper { } } - @NonNull - public static String cacheKeyOf(@NonNull final StreamInfo info, - @NonNull final VideoStream video) { - return info.getUrl() + video.getResolution() + video.getFormat().getName(); - } - - @NonNull - public static String cacheKeyOf(@NonNull final StreamInfo info, - @NonNull final AudioStream audio) { - return info.getUrl() + audio.getAverageBitrate() + audio.getFormat().getName(); - } - /** * Given a {@link StreamInfo} and the existing queue items, * provide the {@link SinglePlayQueue} consisting of the next video for auto queueing. @@ -204,7 +202,7 @@ public final class PlayerHelper { return null; } - if (relatedItems.get(0) != null && relatedItems.get(0) instanceof StreamInfoItem + if (relatedItems.get(0) instanceof StreamInfoItem && !urls.contains(relatedItems.get(0).getUrl())) { return getAutoQueuedSinglePlayQueue((StreamInfoItem) relatedItems.get(0)); } @@ -230,14 +228,16 @@ public final class PlayerHelper { .getBoolean(context.getString(R.string.resume_on_audio_focus_gain_key), false); } - public static boolean isVolumeGestureEnabled(@NonNull final Context context) { + public static String getActionForRightGestureSide(@NonNull final Context context) { return getPreferences(context) - .getBoolean(context.getString(R.string.volume_gesture_control_key), true); + .getString(context.getString(R.string.right_gesture_control_key), + context.getString(R.string.default_right_gesture_control_value)); } - public static boolean isBrightnessGestureEnabled(@NonNull final Context context) { + public static String getActionForLeftGestureSide(@NonNull final Context context) { return getPreferences(context) - .getBoolean(context.getString(R.string.brightness_gesture_control_key), true); + .getString(context.getString(R.string.left_gesture_control_key), + context.getString(R.string.default_left_gesture_control_value)); } public static boolean isStartMainPlayerFullscreenEnabled(@NonNull final Context context) { @@ -306,6 +306,7 @@ public final class PlayerHelper { return 2 * 1024 * 1024L; // ExoPlayer CacheDataSink.MIN_RECOMMENDED_FRAGMENT_SIZE } + @NonNull public static ExoTrackSelection.Factory getQualitySelector() { return new AdaptiveTrackSelection.Factory( 1000, @@ -318,10 +319,6 @@ public final class PlayerHelper { return true; } - public static int getTossFlingVelocity() { - return 2500; - } - @NonNull public static CaptionStyleCompat getCaptionStyle(@NonNull final Context context) { final CaptioningManager captioningManager = ContextCompat.getSystemService(context, @@ -360,7 +357,7 @@ public final class PlayerHelper { /** * @param context the Android context * @return the screen brightness to use. A value less than 0 (the default) means to use the - * preferred screen brightness + * preferred screen brightness */ public static float getScreenBrightness(@NonNull final Context context) { final SharedPreferences sp = getPreferences(context); @@ -388,8 +385,11 @@ public final class PlayerHelper { public static boolean globalScreenOrientationLocked(final Context context) { // 1: Screen orientation changes using accelerometer // 0: Screen orientation is locked + // if the accelerometer sensor is missing completely, assume locked orientation return android.provider.Settings.System.getInt( - context.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 0) == 0; + context.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 0) == 0 + || !context.getPackageManager() + .hasSystemFeature(PackageManager.FEATURE_SENSOR_ACCELEROMETER); } public static int getProgressiveLoadIntervalBytes(@NonNull final Context context) { @@ -397,7 +397,7 @@ public final class PlayerHelper { context.getString(R.string.progressive_load_interval_key), context.getString(R.string.progressive_load_interval_default_value)); - if (context.getString(R.string.progressive_load_interval_default_value) + if (context.getString(R.string.progressive_load_interval_exoplayer_default_value) .equals(preferredIntervalBytes)) { return ProgressiveMediaSource.DEFAULT_LOADING_CHECK_INTERVAL_BYTES; } @@ -431,19 +431,6 @@ public final class PlayerHelper { // Utils used by player //////////////////////////////////////////////////////////////////////////// - public static MainPlayer.PlayerType retrievePlayerTypeFromIntent(final Intent intent) { - // If you want to open popup from the app just include Constants.POPUP_ONLY into an extra - return MainPlayer.PlayerType.values()[ - intent.getIntExtra(PLAYER_TYPE, MainPlayer.PlayerType.VIDEO.ordinal())]; - } - - public static boolean isPlaybackResumeEnabled(final Player player) { - return player.getPrefs().getBoolean( - player.getContext().getString(R.string.enable_watch_history_key), true) - && player.getPrefs().getBoolean( - player.getContext().getString(R.string.enable_playback_resume_key), true); - } - @RepeatMode public static int nextRepeatMode(@RepeatMode final int repeatMode) { switch (repeatMode) { @@ -451,7 +438,8 @@ public final class PlayerHelper { return REPEAT_MODE_ONE; case REPEAT_MODE_ONE: return REPEAT_MODE_ALL; - case REPEAT_MODE_ALL: default: + case REPEAT_MODE_ALL: + default: return REPEAT_MODE_OFF; } } @@ -506,84 +494,10 @@ public final class PlayerHelper { .apply(); } - /** - * @param player {@code screenWidth} and {@code screenHeight} must have been initialized - * @return the popup starting layout params - */ - @SuppressLint("RtlHardcoded") - public static WindowManager.LayoutParams retrievePopupLayoutParamsFromPrefs( - final Player player) { - final boolean popupRememberSizeAndPos = player.getPrefs().getBoolean( - player.getContext().getString(R.string.popup_remember_size_pos_key), true); - final float defaultSize = - player.getContext().getResources().getDimension(R.dimen.popup_default_width); - final float popupWidth = popupRememberSizeAndPos - ? player.getPrefs().getFloat(player.getContext().getString( - R.string.popup_saved_width_key), defaultSize) - : defaultSize; - final float popupHeight = getMinimumVideoHeight(popupWidth); - - final WindowManager.LayoutParams popupLayoutParams = new WindowManager.LayoutParams( - (int) popupWidth, (int) popupHeight, - popupLayoutParamType(), - IDLE_WINDOW_FLAGS, - PixelFormat.TRANSLUCENT); - popupLayoutParams.gravity = Gravity.LEFT | Gravity.TOP; - popupLayoutParams.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE; - - final int centerX = (int) (player.getScreenWidth() / 2f - popupWidth / 2f); - final int centerY = (int) (player.getScreenHeight() / 2f - popupHeight / 2f); - popupLayoutParams.x = popupRememberSizeAndPos - ? player.getPrefs().getInt(player.getContext().getString( - R.string.popup_saved_x_key), centerX) : centerX; - popupLayoutParams.y = popupRememberSizeAndPos - ? player.getPrefs().getInt(player.getContext().getString( - R.string.popup_saved_y_key), centerY) : centerY; - - return popupLayoutParams; - } - - public static void savePopupPositionAndSizeToPrefs(final Player player) { - if (player.getPopupLayoutParams() != null) { - player.getPrefs().edit() - .putFloat(player.getContext().getString(R.string.popup_saved_width_key), - player.getPopupLayoutParams().width) - .putInt(player.getContext().getString(R.string.popup_saved_x_key), - player.getPopupLayoutParams().x) - .putInt(player.getContext().getString(R.string.popup_saved_y_key), - player.getPopupLayoutParams().y) - .apply(); - } - } - public static float getMinimumVideoHeight(final float width) { return width / (16.0f / 9.0f); // Respect the 16:9 ratio that most videos have } - @SuppressLint("RtlHardcoded") - public static WindowManager.LayoutParams buildCloseOverlayLayoutParams() { - final int flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE - | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE - | WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM; - - final WindowManager.LayoutParams closeOverlayLayoutParams = new WindowManager.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, - popupLayoutParamType(), - flags, - PixelFormat.TRANSLUCENT); - - closeOverlayLayoutParams.gravity = Gravity.LEFT | Gravity.TOP; - closeOverlayLayoutParams.softInputMode = - WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE; - return closeOverlayLayoutParams; - } - - public static int popupLayoutParamType() { - return Build.VERSION.SDK_INT < Build.VERSION_CODES.O - ? WindowManager.LayoutParams.TYPE_PHONE - : WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; - } - public static int retrieveSeekDurationFromPreferences(final Player player) { return Integer.parseInt(Objects.requireNonNull(player.getPrefs().getString( player.getContext().getString(R.string.seek_duration_key), diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java index 4c09ed3c1..b55a6547a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java @@ -16,8 +16,9 @@ import com.google.android.exoplayer2.PlaybackParameters; import org.schabi.newpipe.App; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.player.MainPlayer; +import org.schabi.newpipe.player.PlayerService; import org.schabi.newpipe.player.Player; +import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.player.event.PlayerServiceEventListener; import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener; import org.schabi.newpipe.player.playqueue.PlayQueue; @@ -42,17 +43,17 @@ public final class PlayerHolder { private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection(); private boolean bound; - @Nullable private MainPlayer playerService; + @Nullable private PlayerService playerService; @Nullable private Player player; /** - * Returns the current {@link MainPlayer.PlayerType} of the {@link MainPlayer} service, - * otherwise `null` if no service running. + * Returns the current {@link PlayerType} of the {@link PlayerService} service, + * otherwise `null` if no service is running. * * @return Current PlayerType */ @Nullable - public MainPlayer.PlayerType getType() { + public PlayerType getType() { if (player == null) { return null; } @@ -91,6 +92,13 @@ public final class PlayerHolder { return player.getPlayQueue().size(); } + public int getQueuePosition() { + if (player == null || player.getPlayQueue() == null) { + return 0; + } + return player.getPlayQueue().getIndex(); + } + public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) { listener = newListener; @@ -122,7 +130,7 @@ public final class PlayerHolder { // and NullPointerExceptions inside the service because the service will be // bound twice. Prevent it with unbinding first unbind(context); - ContextCompat.startForegroundService(context, new Intent(context, MainPlayer.class)); + ContextCompat.startForegroundService(context, new Intent(context, PlayerService.class)); serviceConnection.doPlayAfterConnect(playAfterConnect); bind(context); } @@ -130,7 +138,7 @@ public final class PlayerHolder { public void stopService() { final Context context = getCommonContext(); unbind(context); - context.stopService(new Intent(context, MainPlayer.class)); + context.stopService(new Intent(context, PlayerService.class)); } class PlayerServiceConnection implements ServiceConnection { @@ -156,7 +164,7 @@ public final class PlayerHolder { if (DEBUG) { Log.d(TAG, "Player service is connected"); } - final MainPlayer.LocalBinder localBinder = (MainPlayer.LocalBinder) service; + final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service; playerService = localBinder.getService(); player = localBinder.getPlayer(); @@ -172,7 +180,7 @@ public final class PlayerHolder { Log.d(TAG, "bind() called"); } - final Intent serviceIntent = new Intent(context, MainPlayer.class); + final Intent serviceIntent = new Intent(context, PlayerService.class); bound = context.bindService(serviceIntent, serviceConnection, Context.BIND_AUTO_CREATE); if (!bound) { @@ -211,6 +219,13 @@ public final class PlayerHolder { private final PlayerServiceEventListener internalListener = new PlayerServiceEventListener() { + @Override + public void onViewCreated() { + if (listener != null) { + listener.onViewCreated(); + } + } + @Override public void onFullscreenStateChanged(final boolean fullscreen) { if (listener != null) { diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerSemitoneHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerSemitoneHelper.java new file mode 100644 index 000000000..f1ba90f8e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerSemitoneHelper.java @@ -0,0 +1,40 @@ +package org.schabi.newpipe.player.helper; + +import androidx.core.math.MathUtils; + +/** + * Converts between percent and 12-tone equal temperament semitones. + *
+ * @see + * + * Wikipedia: Equal temperament#Twelve-tone equal temperament + * + */ +public final class PlayerSemitoneHelper { + public static final int SEMITONE_COUNT = 12; + + private PlayerSemitoneHelper() { + // No impl + } + + public static String formatPitchSemitones(final double percent) { + return formatPitchSemitones(percentToSemitones(percent)); + } + + public static String formatPitchSemitones(final int semitones) { + return semitones > 0 ? "+" + semitones : "" + semitones; + } + + public static double semitonesToPercent(final int semitones) { + return Math.pow(2, ensureSemitonesInRange(semitones) / (double) SEMITONE_COUNT); + } + + public static int percentToSemitones(final double percent) { + return ensureSemitonesInRange( + (int) Math.round(SEMITONE_COUNT * Math.log(percent) / Math.log(2))); + } + + private static int ensureSemitonesInRange(final int semitones) { + return MathUtils.clamp(semitones, -SEMITONE_COUNT, SEMITONE_COUNT); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/listeners/view/PlaybackSpeedClickListener.kt b/app/src/main/java/org/schabi/newpipe/player/listeners/view/PlaybackSpeedClickListener.kt deleted file mode 100644 index 52eff5a1c..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/listeners/view/PlaybackSpeedClickListener.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.schabi.newpipe.player.listeners.view - -import android.util.Log -import android.view.View -import androidx.appcompat.widget.PopupMenu -import org.schabi.newpipe.MainActivity -import org.schabi.newpipe.player.Player -import org.schabi.newpipe.player.helper.PlaybackParameterDialog - -/** - * Click listener for the playbackSpeed textview of the player - */ -class PlaybackSpeedClickListener( - private val player: Player, - private val playbackSpeedPopupMenu: PopupMenu -) : View.OnClickListener { - - companion object { - private const val TAG: String = "PlaybSpeedClickListener" - } - - override fun onClick(v: View) { - if (MainActivity.DEBUG) { - Log.d(TAG, "onPlaybackSpeedClicked() called") - } - - if (player.videoPlayerSelected()) { - PlaybackParameterDialog.newInstance( - player.playbackSpeed.toDouble(), - player.playbackPitch.toDouble(), - player.playbackSkipSilence - ) { speed: Float, pitch: Float, skipSilence: Boolean -> - player.setPlaybackParameters( - speed, - pitch, - skipSilence - ) - } - .show(player.parentActivity!!.supportFragmentManager, null) - } else { - playbackSpeedPopupMenu.show() - player.isSomePopupMenuVisible = true - } - - player.manageControlsAfterOnClick(v) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/listeners/view/QualityClickListener.kt b/app/src/main/java/org/schabi/newpipe/player/listeners/view/QualityClickListener.kt deleted file mode 100644 index b103ac0e6..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/listeners/view/QualityClickListener.kt +++ /dev/null @@ -1,41 +0,0 @@ -package org.schabi.newpipe.player.listeners.view - -import android.annotation.SuppressLint -import android.util.Log -import android.view.View -import androidx.appcompat.widget.PopupMenu -import org.schabi.newpipe.MainActivity -import org.schabi.newpipe.extractor.MediaFormat -import org.schabi.newpipe.player.Player - -/** - * Click listener for the qualityTextView of the player - */ -class QualityClickListener( - private val player: Player, - private val qualityPopupMenu: PopupMenu -) : View.OnClickListener { - - companion object { - private const val TAG: String = "QualityClickListener" - } - - @SuppressLint("SetTextI18n") // we don't need I18N because of a " " - override fun onClick(v: View) { - if (MainActivity.DEBUG) { - Log.d(TAG, "onQualitySelectorClicked() called") - } - - qualityPopupMenu.show() - player.isSomePopupMenuVisible = true - - val videoStream = player.selectedVideoStream - if (videoStream != null) { - player.binding.qualityTextView.text = - MediaFormat.getNameById(videoStream.formatId) + " " + videoStream.resolution - } - - player.saveWasPlaying() - player.manageControlsAfterOnClick(v) - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.java index ebedf8c71..95a4f74af 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediaitem/ExceptionTag.java @@ -3,6 +3,7 @@ package org.schabi.newpipe.player.mediaitem; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.player.playqueue.PlayQueueItem; +import org.schabi.newpipe.util.image.ImageStrategy; import java.util.List; import java.util.Optional; @@ -74,7 +75,7 @@ public final class ExceptionTag implements MediaItemTag { @Override public String getThumbnailUrl() { - return item.getThumbnailUrl(); + return ImageStrategy.choosePreferredImage(item.getThumbnails()); } @Override diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java index f84b0383a..346bb92fa 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediaitem/MediaItemTag.java @@ -3,9 +3,11 @@ package org.schabi.newpipe.player.mediaitem; import android.net.Uri; import com.google.android.exoplayer2.MediaItem; +import com.google.android.exoplayer2.MediaItem.RequestMetadata; import com.google.android.exoplayer2.MediaMetadata; import com.google.android.exoplayer2.Player; +import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.VideoStream; @@ -54,18 +56,22 @@ public interface MediaItemTag { return Optional.empty(); } + @NonNull + default Optional getMaybeAudioTrack() { + return Optional.empty(); + } + Optional getMaybeExtras(@NonNull Class type); MediaItemTag withExtras(@NonNull T extra); @NonNull static Optional from(@Nullable final MediaItem mediaItem) { - if (mediaItem == null || mediaItem.localConfiguration == null - || !(mediaItem.localConfiguration.tag instanceof MediaItemTag)) { - return Optional.empty(); - } - - return Optional.of((MediaItemTag) mediaItem.localConfiguration.tag); + return Optional.ofNullable(mediaItem) + .map(item -> item.localConfiguration) + .map(localConfiguration -> localConfiguration.tag) + .filter(MediaItemTag.class::isInstance) + .map(MediaItemTag.class::cast); } @NonNull @@ -75,19 +81,24 @@ public interface MediaItemTag { @NonNull default MediaItem asMediaItem() { + final String thumbnailUrl = getThumbnailUrl(); final MediaMetadata mediaMetadata = new MediaMetadata.Builder() - .setMediaUri(Uri.parse(getStreamUrl())) - .setArtworkUri(Uri.parse(getThumbnailUrl())) + .setArtworkUri(thumbnailUrl == null ? null : Uri.parse(thumbnailUrl)) .setArtist(getUploaderName()) .setDescription(getTitle()) .setDisplayTitle(getTitle()) .setTitle(getTitle()) .build(); + final RequestMetadata requestMetaData = new RequestMetadata.Builder() + .setMediaUri(Uri.parse(getStreamUrl())) + .build(); + return MediaItem.fromUri(getStreamUrl()) .buildUpon() .setMediaId(makeMediaId()) .setMediaMetadata(mediaMetadata) + .setRequestMetadata(requestMetaData) .setTag(this) .build(); } @@ -124,4 +135,37 @@ public interface MediaItemTag { ? null : sortedVideoStreams.get(selectedVideoStreamIndex); } } + + final class AudioTrack { + @NonNull + private final List audioStreams; + private final int selectedAudioStreamIndex; + + private AudioTrack(@NonNull final List audioStreams, + final int selectedAudioStreamIndex) { + this.audioStreams = audioStreams; + this.selectedAudioStreamIndex = selectedAudioStreamIndex; + } + + static AudioTrack of(@NonNull final List audioStreams, + final int selectedAudioStreamIndex) { + return new AudioTrack(audioStreams, selectedAudioStreamIndex); + } + + @NonNull + public List getAudioStreams() { + return audioStreams; + } + + public int getSelectedAudioStreamIndex() { + return selectedAudioStreamIndex; + } + + @Nullable + public AudioStream getSelectedAudioStream() { + return selectedAudioStreamIndex < 0 + || selectedAudioStreamIndex >= audioStreams.size() + ? null : audioStreams.get(selectedAudioStreamIndex); + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java b/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java index 4095f2bc8..e24a93615 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediaitem/StreamInfoTag.java @@ -2,9 +2,11 @@ package org.schabi.newpipe.player.mediaitem; import com.google.android.exoplayer2.MediaItem; +import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.util.image.ImageStrategy; import java.util.Collections; import java.util.List; @@ -25,25 +27,41 @@ public final class StreamInfoTag implements MediaItemTag { @Nullable private final MediaItemTag.Quality quality; @Nullable + private final MediaItemTag.AudioTrack audioTrack; + @Nullable private final Object extras; private StreamInfoTag(@NonNull final StreamInfo streamInfo, @Nullable final MediaItemTag.Quality quality, + @Nullable final MediaItemTag.AudioTrack audioTrack, @Nullable final Object extras) { this.streamInfo = streamInfo; this.quality = quality; + this.audioTrack = audioTrack; this.extras = extras; } public static StreamInfoTag of(@NonNull final StreamInfo streamInfo, @NonNull final List sortedVideoStreams, - final int selectedVideoStreamIndex) { + final int selectedVideoStreamIndex, + @NonNull final List audioStreams, + final int selectedAudioStreamIndex) { final Quality quality = Quality.of(sortedVideoStreams, selectedVideoStreamIndex); - return new StreamInfoTag(streamInfo, quality, null); + final AudioTrack audioTrack = + AudioTrack.of(audioStreams, selectedAudioStreamIndex); + return new StreamInfoTag(streamInfo, quality, audioTrack, null); + } + + public static StreamInfoTag of(@NonNull final StreamInfo streamInfo, + @NonNull final List audioStreams, + final int selectedAudioStreamIndex) { + final AudioTrack audioTrack = + AudioTrack.of(audioStreams, selectedAudioStreamIndex); + return new StreamInfoTag(streamInfo, null, audioTrack, null); } public static StreamInfoTag of(@NonNull final StreamInfo streamInfo) { - return new StreamInfoTag(streamInfo, null, null); + return new StreamInfoTag(streamInfo, null, null, null); } @Override @@ -78,7 +96,7 @@ public final class StreamInfoTag implements MediaItemTag { @Override public String getThumbnailUrl() { - return streamInfo.getThumbnailUrl(); + return ImageStrategy.choosePreferredImage(streamInfo.getThumbnails()); } @Override @@ -103,6 +121,12 @@ public final class StreamInfoTag implements MediaItemTag { return Optional.ofNullable(quality); } + @NonNull + @Override + public Optional getMaybeAudioTrack() { + return Optional.ofNullable(audioTrack); + } + @Override public Optional getMaybeExtras(@NonNull final Class type) { return Optional.ofNullable(extras).map(type::cast); @@ -110,6 +134,6 @@ public final class StreamInfoTag implements MediaItemTag { @Override public StreamInfoTag withExtras(@NonNull final Object extra) { - return new StreamInfoTag(streamInfo, quality, extra); + return new StreamInfoTag(streamInfo, quality, audioTrack, extra); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionCallback.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionCallback.java deleted file mode 100644 index c4b02d985..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionCallback.java +++ /dev/null @@ -1,21 +0,0 @@ -package org.schabi.newpipe.player.mediasession; - -import android.support.v4.media.MediaDescriptionCompat; - -public interface MediaSessionCallback { - void playPrevious(); - - void playNext(); - - void playItemAtIndex(int index); - - int getCurrentPlayingIndex(); - - int getQueueSize(); - - MediaDescriptionCompat getQueueMetadata(int index); - - void play(); - - void pause(); -} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java new file mode 100644 index 000000000..737ebc5dd --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java @@ -0,0 +1,290 @@ +package org.schabi.newpipe.player.mediasession; + +import static org.schabi.newpipe.MainActivity.DEBUG; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_RECREATE_NOTIFICATION; + +import android.content.Intent; +import android.content.SharedPreferences; +import android.graphics.Bitmap; +import android.os.Build; +import android.support.v4.media.MediaMetadataCompat; +import android.support.v4.media.session.MediaSessionCompat; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.media.session.MediaButtonReceiver; + +import com.google.android.exoplayer2.ForwardingPlayer; +import com.google.android.exoplayer2.Player.RepeatMode; +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.player.Player; +import org.schabi.newpipe.player.notification.NotificationActionData; +import org.schabi.newpipe.player.notification.NotificationConstants; +import org.schabi.newpipe.player.ui.PlayerUi; +import org.schabi.newpipe.player.ui.VideoPlayerUi; +import org.schabi.newpipe.util.StreamTypeUtil; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +public class MediaSessionPlayerUi extends PlayerUi + implements SharedPreferences.OnSharedPreferenceChangeListener { + private static final String TAG = "MediaSessUi"; + + private MediaSessionCompat mediaSession; + private MediaSessionConnector sessionConnector; + + private final String ignoreHardwareMediaButtonsKey; + private boolean shouldIgnoreHardwareMediaButtons = false; + + // used to check whether any notification action changed, before sending costly updates + private List prevNotificationActions = List.of(); + + + public MediaSessionPlayerUi(@NonNull final Player player) { + super(player); + ignoreHardwareMediaButtonsKey = + context.getString(R.string.ignore_hardware_media_buttons_key); + } + + @Override + public void initPlayer() { + super.initPlayer(); + destroyPlayer(); // release previously used resources + + mediaSession = new MediaSessionCompat(context, TAG); + mediaSession.setActive(true); + + sessionConnector = new MediaSessionConnector(mediaSession); + sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, player)); + sessionConnector.setPlayer(getForwardingPlayer()); + + // It seems like events from the Media Control UI in the notification area don't go through + // this function, so it's safe to just ignore all events in case we want to ignore the + // hardware media buttons. Returning true stops all further event processing of the system. + sessionConnector.setMediaButtonEventHandler((p, i) -> shouldIgnoreHardwareMediaButtons); + + // listen to changes to ignore_hardware_media_buttons_key + updateShouldIgnoreHardwareMediaButtons(player.getPrefs()); + player.getPrefs().registerOnSharedPreferenceChangeListener(this); + + sessionConnector.setMetadataDeduplicationEnabled(true); + sessionConnector.setMediaMetadataProvider(exoPlayer -> buildMediaMetadata()); + + // force updating media session actions by resetting the previous ones + prevNotificationActions = List.of(); + updateMediaSessionActions(); + } + + @Override + public void destroyPlayer() { + super.destroyPlayer(); + player.getPrefs().unregisterOnSharedPreferenceChangeListener(this); + if (sessionConnector != null) { + sessionConnector.setMediaButtonEventHandler(null); + sessionConnector.setPlayer(null); + sessionConnector.setQueueNavigator(null); + sessionConnector = null; + } + if (mediaSession != null) { + mediaSession.setActive(false); + mediaSession.release(); + mediaSession = null; + } + prevNotificationActions = List.of(); + } + + @Override + public void onThumbnailLoaded(@Nullable final Bitmap bitmap) { + super.onThumbnailLoaded(bitmap); + if (sessionConnector != null) { + // the thumbnail is now loaded: invalidate the metadata to trigger a metadata update + sessionConnector.invalidateMediaSessionMetadata(); + } + } + + + @Override + public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences, + final String key) { + if (key == null || key.equals(ignoreHardwareMediaButtonsKey)) { + updateShouldIgnoreHardwareMediaButtons(sharedPreferences); + } + } + + public void updateShouldIgnoreHardwareMediaButtons(final SharedPreferences sharedPreferences) { + shouldIgnoreHardwareMediaButtons = + sharedPreferences.getBoolean(ignoreHardwareMediaButtonsKey, false); + } + + + public void handleMediaButtonIntent(final Intent intent) { + MediaButtonReceiver.handleIntent(mediaSession, intent); + } + + public Optional getSessionToken() { + return Optional.ofNullable(mediaSession).map(MediaSessionCompat::getSessionToken); + } + + + private ForwardingPlayer getForwardingPlayer() { + // ForwardingPlayer means that all media session actions called on this player are + // forwarded directly to the connected exoplayer, except for the overridden methods. So + // override play and pause since our player adds more functionality to them over exoplayer. + return new ForwardingPlayer(player.getExoPlayer()) { + @Override + public void play() { + player.play(); + // hide the player controls even if the play command came from the media session + player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0)); + } + + @Override + public void pause() { + player.pause(); + } + }; + } + + private MediaMetadataCompat buildMediaMetadata() { + if (DEBUG) { + Log.d(TAG, "buildMediaMetadata called"); + } + + // set title and artist + final MediaMetadataCompat.Builder builder = new MediaMetadataCompat.Builder() + .putString(MediaMetadataCompat.METADATA_KEY_TITLE, player.getVideoTitle()) + .putString(MediaMetadataCompat.METADATA_KEY_ARTIST, player.getUploaderName()); + + // set duration (-1 for livestreams or if unknown, see the METADATA_KEY_DURATION docs) + final long duration = player.getCurrentStreamInfo() + .filter(info -> !StreamTypeUtil.isLiveStream(info.getStreamType())) + .map(info -> info.getDuration() * 1000L) + .orElse(-1L); + builder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration); + + // set album art, unless the user asked not to, or there is no thumbnail available + final boolean showThumbnail = player.getPrefs().getBoolean( + context.getString(R.string.show_thumbnail_key), true); + Optional.ofNullable(player.getThumbnail()) + .filter(bitmap -> showThumbnail) + .ifPresent(bitmap -> { + builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap); + builder.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, bitmap); + }); + + return builder.build(); + } + + + private void updateMediaSessionActions() { + // On Android 13+ (or Android T or API 33+) the actions in the player notification can't be + // controlled directly anymore, but are instead derived from custom media session actions. + // However the system allows customizing only two of these actions, since the other three + // are fixed to play-pause-buffering, previous, next. + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + // Although setting media session actions on older android versions doesn't seem to + // cause any trouble, it also doesn't seem to do anything, so we don't do anything to + // save battery. Check out NotificationUtil.updateActions() to see what happens on + // older android versions. + return; + } + + // only use the fourth and fifth actions (the settings page also shows only the last 2 on + // Android 13+) + final List newNotificationActions = IntStream.of(3, 4) + .map(i -> player.getPrefs().getInt( + player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]), + NotificationConstants.SLOT_DEFAULTS[i])) + .mapToObj(action -> NotificationActionData + .fromNotificationActionEnum(player, action)) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + + // avoid costly notification actions update, if nothing changed from last time + if (!newNotificationActions.equals(prevNotificationActions)) { + prevNotificationActions = newNotificationActions; + sessionConnector.setCustomActionProviders( + newNotificationActions.stream() + .map(data -> new SessionConnectorActionProvider(data, context)) + .toArray(SessionConnectorActionProvider[]::new)); + } + } + + @Override + public void onBlocked() { + super.onBlocked(); + updateMediaSessionActions(); + } + + @Override + public void onPlaying() { + super.onPlaying(); + updateMediaSessionActions(); + } + + @Override + public void onBuffering() { + super.onBuffering(); + updateMediaSessionActions(); + } + + @Override + public void onPaused() { + super.onPaused(); + updateMediaSessionActions(); + } + + @Override + public void onPausedSeek() { + super.onPausedSeek(); + updateMediaSessionActions(); + } + + @Override + public void onCompleted() { + super.onCompleted(); + updateMediaSessionActions(); + } + + @Override + public void onRepeatModeChanged(@RepeatMode final int repeatMode) { + super.onRepeatModeChanged(repeatMode); + updateMediaSessionActions(); + } + + @Override + public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) { + super.onShuffleModeEnabledChanged(shuffleModeEnabled); + updateMediaSessionActions(); + } + + @Override + public void onBroadcastReceived(final Intent intent) { + super.onBroadcastReceived(intent); + if (ACTION_RECREATE_NOTIFICATION.equals(intent.getAction())) { + // the notification actions changed + updateMediaSessionActions(); + } + } + + @Override + public void onMetadataChanged(@NonNull final StreamInfo info) { + super.onMetadataChanged(info); + updateMediaSessionActions(); + } + + @Override + public void onPlayQueueEdited() { + super.onPlayQueueEdited(); + updateMediaSessionActions(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java index 92cd425c5..3339869c1 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/PlayQueueNavigator.java @@ -1,106 +1,156 @@ package org.schabi.newpipe.player.mediasession; -import android.os.Bundle; -import android.os.ResultReceiver; -import android.support.v4.media.session.MediaSessionCompat; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; -import com.google.android.exoplayer2.util.Util; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_NEXT; import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS; import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM; +import android.net.Uri; +import android.os.Bundle; +import android.os.ResultReceiver; +import android.support.v4.media.MediaDescriptionCompat; +import android.support.v4.media.MediaMetadataCompat; +import android.support.v4.media.session.MediaSessionCompat; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; +import com.google.android.exoplayer2.util.Util; + +import org.schabi.newpipe.player.Player; +import org.schabi.newpipe.player.playqueue.PlayQueue; +import org.schabi.newpipe.player.playqueue.PlayQueueItem; +import org.schabi.newpipe.util.image.ImageStrategy; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; + public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator { - public static final int DEFAULT_MAX_QUEUE_SIZE = 10; + private static final int MAX_QUEUE_SIZE = 10; private final MediaSessionCompat mediaSession; - private final MediaSessionCallback callback; - private final int maxQueueSize; + private final Player player; private long activeQueueItemId; public PlayQueueNavigator(@NonNull final MediaSessionCompat mediaSession, - @NonNull final MediaSessionCallback callback) { + @NonNull final Player player) { this.mediaSession = mediaSession; - this.callback = callback; - this.maxQueueSize = DEFAULT_MAX_QUEUE_SIZE; + this.player = player; this.activeQueueItemId = MediaSessionCompat.QueueItem.UNKNOWN_ID; } @Override - public long getSupportedQueueNavigatorActions(@Nullable final Player player) { + public long getSupportedQueueNavigatorActions( + @Nullable final com.google.android.exoplayer2.Player exoPlayer) { return ACTION_SKIP_TO_NEXT | ACTION_SKIP_TO_PREVIOUS | ACTION_SKIP_TO_QUEUE_ITEM; } @Override - public void onTimelineChanged(@NonNull final Player player) { + public void onTimelineChanged(@NonNull final com.google.android.exoplayer2.Player exoPlayer) { publishFloatingQueueWindow(); } @Override - public void onCurrentMediaItemIndexChanged(@NonNull final Player player) { + public void onCurrentMediaItemIndexChanged( + @NonNull final com.google.android.exoplayer2.Player exoPlayer) { if (activeQueueItemId == MediaSessionCompat.QueueItem.UNKNOWN_ID - || player.getCurrentTimeline().getWindowCount() > maxQueueSize) { + || exoPlayer.getCurrentTimeline().getWindowCount() > MAX_QUEUE_SIZE) { publishFloatingQueueWindow(); - } else if (!player.getCurrentTimeline().isEmpty()) { - activeQueueItemId = player.getCurrentMediaItemIndex(); + } else if (!exoPlayer.getCurrentTimeline().isEmpty()) { + activeQueueItemId = exoPlayer.getCurrentMediaItemIndex(); } } @Override - public long getActiveQueueItemId(@Nullable final Player player) { - return callback.getCurrentPlayingIndex(); + public long getActiveQueueItemId( + @Nullable final com.google.android.exoplayer2.Player exoPlayer) { + return Optional.ofNullable(player.getPlayQueue()).map(PlayQueue::getIndex).orElse(-1); } @Override - public void onSkipToPrevious(@NonNull final Player player) { - callback.playPrevious(); + public void onSkipToPrevious(@NonNull final com.google.android.exoplayer2.Player exoPlayer) { + player.playPrevious(); } @Override - public void onSkipToQueueItem(@NonNull final Player player, final long id) { - callback.playItemAtIndex((int) id); + public void onSkipToQueueItem(@NonNull final com.google.android.exoplayer2.Player exoPlayer, + final long id) { + if (player.getPlayQueue() != null) { + player.selectQueueItem(player.getPlayQueue().getItem((int) id)); + } } @Override - public void onSkipToNext(@NonNull final Player player) { - callback.playNext(); + public void onSkipToNext(@NonNull final com.google.android.exoplayer2.Player exoPlayer) { + player.playNext(); } private void publishFloatingQueueWindow() { - if (callback.getQueueSize() == 0) { + final int windowCount = Optional.ofNullable(player.getPlayQueue()) + .map(PlayQueue::size) + .orElse(0); + if (windowCount == 0) { mediaSession.setQueue(Collections.emptyList()); activeQueueItemId = MediaSessionCompat.QueueItem.UNKNOWN_ID; return; } // Yes this is almost a copypasta, got a problem with that? =\ - final int windowCount = callback.getQueueSize(); - final int currentWindowIndex = callback.getCurrentPlayingIndex(); - final int queueSize = Math.min(maxQueueSize, windowCount); + final int currentWindowIndex = player.getPlayQueue().getIndex(); + final int queueSize = Math.min(MAX_QUEUE_SIZE, windowCount); final int startIndex = Util.constrainValue(currentWindowIndex - ((queueSize - 1) / 2), 0, windowCount - queueSize); final List queue = new ArrayList<>(); for (int i = startIndex; i < startIndex + queueSize; i++) { - queue.add(new MediaSessionCompat.QueueItem(callback.getQueueMetadata(i), i)); + queue.add(new MediaSessionCompat.QueueItem(getQueueMetadata(i), i)); } mediaSession.setQueue(queue); activeQueueItemId = currentWindowIndex; } + public MediaDescriptionCompat getQueueMetadata(final int index) { + if (player.getPlayQueue() == null) { + return null; + } + final PlayQueueItem item = player.getPlayQueue().getItem(index); + if (item == null) { + return null; + } + + final MediaDescriptionCompat.Builder descBuilder = new MediaDescriptionCompat.Builder() + .setMediaId(String.valueOf(index)) + .setTitle(item.getTitle()) + .setSubtitle(item.getUploader()); + + // set additional metadata for A2DP/AVRCP (Audio/Video Bluetooth profiles) + final Bundle additionalMetadata = new Bundle(); + additionalMetadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, item.getTitle()); + additionalMetadata.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, item.getUploader()); + additionalMetadata + .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, item.getDuration() * 1000); + additionalMetadata.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, index + 1L); + additionalMetadata + .putLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS, player.getPlayQueue().size()); + descBuilder.setExtras(additionalMetadata); + + try { + descBuilder.setIconUri(Uri.parse( + ImageStrategy.choosePreferredImage(item.getThumbnails()))); + } catch (final Throwable e) { + // no thumbnail available at all, or the user disabled image loading, + // or the obtained url is not a valid `Uri` + } + + return descBuilder.build(); + } + @Override - public boolean onCommand(@NonNull final Player player, + public boolean onCommand(@NonNull final com.google.android.exoplayer2.Player exoPlayer, @NonNull final String command, @Nullable final Bundle extras, @Nullable final ResultReceiver cb) { diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java new file mode 100644 index 000000000..a5c9fccc9 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/SessionConnectorActionProvider.java @@ -0,0 +1,47 @@ +package org.schabi.newpipe.player.mediasession; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.support.v4.media.session.PlaybackStateCompat; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector; + +import org.schabi.newpipe.player.notification.NotificationActionData; + +import java.lang.ref.WeakReference; + +public class SessionConnectorActionProvider implements MediaSessionConnector.CustomActionProvider { + + private final NotificationActionData data; + @NonNull + private final WeakReference context; + + public SessionConnectorActionProvider(final NotificationActionData notificationActionData, + @NonNull final Context context) { + this.data = notificationActionData; + this.context = new WeakReference<>(context); + } + + @Override + public void onCustomAction(@NonNull final Player player, + @NonNull final String action, + @Nullable final Bundle extras) { + final Context actualContext = context.get(); + if (actualContext != null) { + actualContext.sendBroadcast(new Intent(action)); + } + } + + @Nullable + @Override + public PlaybackStateCompat.CustomAction getCustomAction(@NonNull final Player player) { + return new PlaybackStateCompat.CustomAction.Builder( + data.action(), data.name(), data.icon() + ).build(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java index 8aad356d0..b9ca90d89 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/FailedMediaSource.java @@ -16,7 +16,7 @@ import org.schabi.newpipe.player.mediaitem.ExceptionTag; import org.schabi.newpipe.player.playqueue.PlayQueueItem; import java.io.IOException; -import java.util.Collections; +import java.util.List; import java.util.concurrent.TimeUnit; import androidx.annotation.NonNull; @@ -56,9 +56,7 @@ public class FailedMediaSource extends BaseMediaSource implements ManagedMediaSo this.playQueueItem = playQueueItem; this.error = error; this.retryTimestamp = retryTimestamp; - this.mediaItem = ExceptionTag - .of(playQueueItem, Collections.singletonList(error)) - .withExtras(this) + this.mediaItem = ExceptionTag.of(playQueueItem, List.of(error)).withExtras(this) .asMediaItem(); } diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java index 95524cf69..3bf7c09d9 100644 --- a/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java +++ b/app/src/main/java/org/schabi/newpipe/player/mediasource/LoadedMediaSource.java @@ -1,27 +1,21 @@ package org.schabi.newpipe.player.mediasource; +import androidx.annotation.NonNull; + import com.google.android.exoplayer2.MediaItem; -import com.google.android.exoplayer2.Timeline; -import com.google.android.exoplayer2.source.CompositeMediaSource; -import com.google.android.exoplayer2.source.MediaPeriod; import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.upstream.Allocator; -import com.google.android.exoplayer2.upstream.TransferListener; +import com.google.android.exoplayer2.source.WrappingMediaSource; import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.playqueue.PlayQueueItem; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -public class LoadedMediaSource extends CompositeMediaSource implements ManagedMediaSource { - private final MediaSource source; +public class LoadedMediaSource extends WrappingMediaSource implements ManagedMediaSource { private final PlayQueueItem stream; private final MediaItem mediaItem; private final long expireTimestamp; /** - * Uses a {@link CompositeMediaSource} to wrap one or more child {@link MediaSource}s + * Uses a {@link WrappingMediaSource} to wrap one child {@link MediaSource}s * containing actual media. This wrapper {@link LoadedMediaSource} holds the expiration * timestamp as a {@link ManagedMediaSource} to allow explicit playlist management under * {@link ManagedMediaSourcePlaylist}. @@ -36,7 +30,7 @@ public class LoadedMediaSource extends CompositeMediaSource implements @NonNull final MediaItemTag tag, @NonNull final PlayQueueItem stream, final long expireTimestamp) { - this.source = source; + super(source); this.stream = stream; this.expireTimestamp = expireTimestamp; @@ -51,51 +45,6 @@ public class LoadedMediaSource extends CompositeMediaSource implements return System.currentTimeMillis() >= expireTimestamp; } - /** - * Delegates the preparation of child {@link MediaSource}s to the - * {@link CompositeMediaSource} wrapper. Since all {@link LoadedMediaSource}s use only - * a single child media, the child id of 0 is always used (sonar doesn't like null as id here). - * - * @param mediaTransferListener A data transfer listener that will be registered by the - * {@link CompositeMediaSource} for child source preparation. - */ - @Override - protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) { - super.prepareSourceInternal(mediaTransferListener); - prepareChildSource(0, source); - } - - /** - * When any child {@link MediaSource} is prepared, the refreshed {@link Timeline} can - * be listened to here. But since {@link LoadedMediaSource} has only a single child source, - * this method is called only once until {@link #releaseSourceInternal()} is called. - *

- * On refresh, the {@link CompositeMediaSource} delegate will be notified with the - * new {@link Timeline}, otherwise {@link #createPeriod(MediaPeriodId, Allocator, long)} - * will not be called and playback may be stalled. - * - * @param id The unique id used to prepare the child source. - * @param mediaSource The child source whose source info has been refreshed. - * @param timeline The new timeline of the child source. - */ - @Override - protected void onChildSourceInfoRefreshed(final Integer id, - final MediaSource mediaSource, - final Timeline timeline) { - refreshSourceInfo(timeline); - } - - @Override - public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator, - final long startPositionUs) { - return source.createPeriod(id, allocator, startPositionUs); - } - - @Override - public void releasePeriod(final MediaPeriod mediaPeriod) { - source.releasePeriod(mediaPeriod); - } - @NonNull @Override public MediaItem getMediaItem() { diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java new file mode 100644 index 000000000..b3abcd0b5 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationActionData.java @@ -0,0 +1,187 @@ +package org.schabi.newpipe.player.notification; + +import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; +import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_FORWARD; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_REWIND; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_NEXT; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PREVIOUS; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_REPEAT; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE; + +import android.annotation.SuppressLint; +import android.content.Context; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.player.Player; + +import java.util.Objects; + +public final class NotificationActionData { + + @NonNull + private final String action; + @NonNull + private final String name; + @DrawableRes + private final int icon; + + + public NotificationActionData(@NonNull final String action, @NonNull final String name, + @DrawableRes final int icon) { + this.action = action; + this.name = name; + this.icon = icon; + } + + @NonNull + public String action() { + return action; + } + + @NonNull + public String name() { + return name; + } + + @DrawableRes + public int icon() { + return icon; + } + + + @SuppressLint("PrivateResource") // we currently use Exoplayer's internal strings and icons + @Nullable + public static NotificationActionData fromNotificationActionEnum( + @NonNull final Player player, + @NotificationConstants.Action final int selectedAction + ) { + + final int baseActionIcon = NotificationConstants.ACTION_ICONS[selectedAction]; + final Context ctx = player.getContext(); + + switch (selectedAction) { + case NotificationConstants.PREVIOUS: + return new NotificationActionData(ACTION_PLAY_PREVIOUS, + ctx.getString(R.string.exo_controls_previous_description), baseActionIcon); + + case NotificationConstants.NEXT: + return new NotificationActionData(ACTION_PLAY_NEXT, + ctx.getString(R.string.exo_controls_next_description), baseActionIcon); + + case NotificationConstants.REWIND: + return new NotificationActionData(ACTION_FAST_REWIND, + ctx.getString(R.string.exo_controls_rewind_description), baseActionIcon); + + case NotificationConstants.FORWARD: + return new NotificationActionData(ACTION_FAST_FORWARD, + ctx.getString(R.string.exo_controls_fastforward_description), + baseActionIcon); + + case NotificationConstants.SMART_REWIND_PREVIOUS: + if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) { + return new NotificationActionData(ACTION_PLAY_PREVIOUS, + ctx.getString(R.string.exo_controls_previous_description), + R.drawable.exo_notification_previous); + } else { + return new NotificationActionData(ACTION_FAST_REWIND, + ctx.getString(R.string.exo_controls_rewind_description), + R.drawable.exo_controls_rewind); + } + + case NotificationConstants.SMART_FORWARD_NEXT: + if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) { + return new NotificationActionData(ACTION_PLAY_NEXT, + ctx.getString(R.string.exo_controls_next_description), + R.drawable.exo_notification_next); + } else { + return new NotificationActionData(ACTION_FAST_FORWARD, + ctx.getString(R.string.exo_controls_fastforward_description), + R.drawable.exo_controls_fastforward); + } + + case NotificationConstants.PLAY_PAUSE_BUFFERING: + if (player.getCurrentState() == Player.STATE_PREFLIGHT + || player.getCurrentState() == Player.STATE_BLOCKED + || player.getCurrentState() == Player.STATE_BUFFERING) { + return new NotificationActionData(ACTION_PLAY_PAUSE, + ctx.getString(R.string.notification_action_buffering), + R.drawable.ic_hourglass_top); + } + + // fallthrough + case NotificationConstants.PLAY_PAUSE: + if (player.getCurrentState() == Player.STATE_COMPLETED) { + return new NotificationActionData(ACTION_PLAY_PAUSE, + ctx.getString(R.string.exo_controls_pause_description), + R.drawable.ic_replay); + } else if (player.isPlaying() + || player.getCurrentState() == Player.STATE_PREFLIGHT + || player.getCurrentState() == Player.STATE_BLOCKED + || player.getCurrentState() == Player.STATE_BUFFERING) { + return new NotificationActionData(ACTION_PLAY_PAUSE, + ctx.getString(R.string.exo_controls_pause_description), + R.drawable.exo_notification_pause); + } else { + return new NotificationActionData(ACTION_PLAY_PAUSE, + ctx.getString(R.string.exo_controls_play_description), + R.drawable.exo_notification_play); + } + + case NotificationConstants.REPEAT: + if (player.getRepeatMode() == REPEAT_MODE_ALL) { + return new NotificationActionData(ACTION_REPEAT, + ctx.getString(R.string.exo_controls_repeat_all_description), + R.drawable.exo_media_action_repeat_all); + } else if (player.getRepeatMode() == REPEAT_MODE_ONE) { + return new NotificationActionData(ACTION_REPEAT, + ctx.getString(R.string.exo_controls_repeat_one_description), + R.drawable.exo_media_action_repeat_one); + } else /* player.getRepeatMode() == REPEAT_MODE_OFF */ { + return new NotificationActionData(ACTION_REPEAT, + ctx.getString(R.string.exo_controls_repeat_off_description), + R.drawable.exo_media_action_repeat_off); + } + + case NotificationConstants.SHUFFLE: + if (player.getPlayQueue() != null && player.getPlayQueue().isShuffled()) { + return new NotificationActionData(ACTION_SHUFFLE, + ctx.getString(R.string.exo_controls_shuffle_on_description), + R.drawable.exo_controls_shuffle_on); + } else { + return new NotificationActionData(ACTION_SHUFFLE, + ctx.getString(R.string.exo_controls_shuffle_off_description), + R.drawable.exo_controls_shuffle_off); + } + + case NotificationConstants.CLOSE: + return new NotificationActionData(ACTION_CLOSE, ctx.getString(R.string.close), + R.drawable.ic_close); + + case NotificationConstants.NOTHING: + default: + // do nothing + return null; + } + } + + + @Override + public boolean equals(@Nullable final Object obj) { + return (obj instanceof NotificationActionData other) + && this.action.equals(other.action) + && this.name.equals(other.name) + && this.icon == other.icon; + } + + @Override + public int hashCode() { + return Objects.hash(action, name, icon); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/NotificationConstants.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java similarity index 69% rename from app/src/main/java/org/schabi/newpipe/player/NotificationConstants.java rename to app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java index 6c9858d1b..b9607f7ea 100644 --- a/app/src/main/java/org/schabi/newpipe/player/NotificationConstants.java +++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationConstants.java @@ -1,4 +1,4 @@ -package org.schabi.newpipe.player; +package org.schabi.newpipe.player.notification; import android.content.Context; import android.content.SharedPreferences; @@ -7,20 +7,48 @@ import androidx.annotation.DrawableRes; import androidx.annotation.IntDef; import androidx.annotation.NonNull; +import org.schabi.newpipe.App; import org.schabi.newpipe.R; import org.schabi.newpipe.util.Localization; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; -import java.util.ArrayList; -import java.util.Arrays; +import java.util.Collection; import java.util.List; import java.util.SortedSet; import java.util.TreeSet; public final class NotificationConstants { - private NotificationConstants() { } + private NotificationConstants() { + } + + + + /*////////////////////////////////////////////////////////////////////////// + // Intent actions + //////////////////////////////////////////////////////////////////////////*/ + + private static final String BASE_ACTION = + App.PACKAGE_NAME + ".player.MainPlayer."; + public static final String ACTION_CLOSE = + BASE_ACTION + "CLOSE"; + public static final String ACTION_PLAY_PAUSE = + BASE_ACTION + ".player.MainPlayer.PLAY_PAUSE"; + public static final String ACTION_REPEAT = + BASE_ACTION + ".player.MainPlayer.REPEAT"; + public static final String ACTION_PLAY_NEXT = + BASE_ACTION + ".player.MainPlayer.ACTION_PLAY_NEXT"; + public static final String ACTION_PLAY_PREVIOUS = + BASE_ACTION + ".player.MainPlayer.ACTION_PLAY_PREVIOUS"; + public static final String ACTION_FAST_REWIND = + BASE_ACTION + ".player.MainPlayer.ACTION_FAST_REWIND"; + public static final String ACTION_FAST_FORWARD = + BASE_ACTION + ".player.MainPlayer.ACTION_FAST_FORWARD"; + public static final String ACTION_SHUFFLE = + BASE_ACTION + ".player.MainPlayer.ACTION_SHUFFLE"; + public static final String ACTION_RECREATE_NOTIFICATION = + BASE_ACTION + ".player.MainPlayer.ACTION_RECREATE_NOTIFICATION"; public static final int NOTHING = 0; @@ -37,10 +65,16 @@ public final class NotificationConstants { public static final int CLOSE = 11; @Retention(RetentionPolicy.SOURCE) - @IntDef({NOTHING, PREVIOUS, NEXT, REWIND, FORWARD, SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT, - PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT, SHUFFLE, CLOSE}) + @IntDef({NOTHING, PREVIOUS, NEXT, REWIND, FORWARD, + SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT, + SHUFFLE, CLOSE}) public @interface Action { } + @Action + public static final int[] ALL_ACTIONS = {NOTHING, PREVIOUS, NEXT, REWIND, FORWARD, + SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT, + SHUFFLE, CLOSE}; + @DrawableRes public static final int[] ACTION_ICONS = { 0, @@ -67,16 +101,6 @@ public final class NotificationConstants { CLOSE, }; - @Action - public static final int[][] SLOT_ALLOWED_ACTIONS = { - new int[] {PREVIOUS, REWIND, SMART_REWIND_PREVIOUS}, - new int[] {REWIND, PLAY_PAUSE, PLAY_PAUSE_BUFFERING}, - new int[] {NEXT, FORWARD, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING}, - new int[] {NOTHING, PREVIOUS, NEXT, REWIND, FORWARD, SMART_REWIND_PREVIOUS, - SMART_FORWARD_NEXT, REPEAT, SHUFFLE, CLOSE}, - new int[] {NOTHING, NEXT, FORWARD, SMART_FORWARD_NEXT, REPEAT, SHUFFLE, CLOSE}, - }; - public static final int[] SLOT_PREF_KEYS = { R.string.notification_slot_0_key, R.string.notification_slot_1_key, @@ -86,7 +110,7 @@ public final class NotificationConstants { }; - public static final Integer[] SLOT_COMPACT_DEFAULTS = {0, 1, 2}; + public static final List SLOT_COMPACT_DEFAULTS = List.of(0, 1, 2); public static final int[] SLOT_COMPACT_PREF_KEYS = { R.string.notification_slot_compact_0_key, @@ -137,14 +161,11 @@ public final class NotificationConstants { /** * @param context the context to use * @param sharedPreferences the shared preferences to query values from - * @param slotCount remove indices >= than this value (set to {@code 5} to do nothing, or make - * it lower if there are slots with empty actions) * @return a sorted list of the indices of the slots to use as compact slots */ - public static List getCompactSlotsFromPreferences( + public static Collection getCompactSlotsFromPreferences( @NonNull final Context context, - final SharedPreferences sharedPreferences, - final int slotCount) { + final SharedPreferences sharedPreferences) { final SortedSet compactSlots = new TreeSet<>(); for (int i = 0; i < 3; i++) { final int compactSlot = sharedPreferences.getInt( @@ -152,14 +173,14 @@ public final class NotificationConstants { if (compactSlot == Integer.MAX_VALUE) { // settings not yet populated, return default values - return new ArrayList<>(Arrays.asList(SLOT_COMPACT_DEFAULTS)); + return SLOT_COMPACT_DEFAULTS; } - // a negative value (-1) is set when the user does not want a particular compact slot - if (compactSlot >= 0 && compactSlot < slotCount) { + if (compactSlot >= 0) { + // compact slot is < 0 if there are less than 3 checked checkboxes compactSlots.add(compactSlot); } } - return new ArrayList<>(compactSlots); + return compactSlots; } } diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationPlayerUi.java new file mode 100644 index 000000000..75b27545c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationPlayerUi.java @@ -0,0 +1,119 @@ +package org.schabi.newpipe.player.notification; + +import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_NONE; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_RECREATE_NOTIFICATION; + +import android.content.Intent; +import android.graphics.Bitmap; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.Player.RepeatMode; + +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.player.Player; +import org.schabi.newpipe.player.helper.PlayerHelper; +import org.schabi.newpipe.player.ui.PlayerUi; + +public final class NotificationPlayerUi extends PlayerUi { + private final NotificationUtil notificationUtil; + + public NotificationPlayerUi(@NonNull final Player player) { + super(player); + notificationUtil = new NotificationUtil(player); + } + + @Override + public void destroy() { + super.destroy(); + notificationUtil.cancelNotificationAndStopForeground(); + } + + @Override + public void onThumbnailLoaded(@Nullable final Bitmap bitmap) { + super.onThumbnailLoaded(bitmap); + notificationUtil.updateThumbnail(); + } + + @Override + public void onBlocked() { + super.onBlocked(); + notificationUtil.createNotificationIfNeededAndUpdate(false); + } + + @Override + public void onPlaying() { + super.onPlaying(); + notificationUtil.createNotificationIfNeededAndUpdate(false); + } + + @Override + public void onBuffering() { + super.onBuffering(); + if (notificationUtil.shouldUpdateBufferingSlot()) { + notificationUtil.createNotificationIfNeededAndUpdate(false); + } + } + + @Override + public void onPaused() { + super.onPaused(); + + // Remove running notification when user does not want minimization to background or popup + if (PlayerHelper.getMinimizeOnExitAction(context) == MINIMIZE_ON_EXIT_MODE_NONE + && player.videoPlayerSelected()) { + notificationUtil.cancelNotificationAndStopForeground(); + } else { + notificationUtil.createNotificationIfNeededAndUpdate(false); + } + } + + @Override + public void onPausedSeek() { + super.onPausedSeek(); + notificationUtil.createNotificationIfNeededAndUpdate(false); + } + + @Override + public void onCompleted() { + super.onCompleted(); + notificationUtil.createNotificationIfNeededAndUpdate(false); + } + + @Override + public void onRepeatModeChanged(@RepeatMode final int repeatMode) { + super.onRepeatModeChanged(repeatMode); + notificationUtil.createNotificationIfNeededAndUpdate(false); + } + + @Override + public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) { + super.onShuffleModeEnabledChanged(shuffleModeEnabled); + notificationUtil.createNotificationIfNeededAndUpdate(false); + } + + @Override + public void onBroadcastReceived(final Intent intent) { + super.onBroadcastReceived(intent); + if (ACTION_RECREATE_NOTIFICATION.equals(intent.getAction())) { + notificationUtil.createNotificationIfNeededAndUpdate(true); + } + } + + @Override + public void onMetadataChanged(@NonNull final StreamInfo info) { + super.onMetadataChanged(info); + notificationUtil.createNotificationIfNeededAndUpdate(true); + } + + @Override + public void onPlayQueueEdited() { + super.onPlayQueueEdited(); + notificationUtil.createNotificationIfNeededAndUpdate(false); + } + + public void createNotificationAndStartForeground() { + notificationUtil.createNotificationAndStartForeground(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java new file mode 100644 index 000000000..30420b0c7 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java @@ -0,0 +1,302 @@ +package org.schabi.newpipe.player.notification; + +import static android.app.PendingIntent.FLAG_UPDATE_CURRENT; +import static androidx.media.app.NotificationCompat.MediaStyle; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE; + +import android.annotation.SuppressLint; +import android.app.PendingIntent; +import android.content.Intent; +import android.content.pm.ServiceInfo; +import android.graphics.Bitmap; +import android.os.Build; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.app.PendingIntentCompat; +import androidx.core.app.ServiceCompat; +import androidx.core.content.ContextCompat; + +import org.schabi.newpipe.MainActivity; +import org.schabi.newpipe.R; +import org.schabi.newpipe.player.Player; +import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi; +import org.schabi.newpipe.util.NavigationHelper; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +/** + * This is a utility class for player notifications. + */ +public final class NotificationUtil { + private static final String TAG = NotificationUtil.class.getSimpleName(); + private static final boolean DEBUG = Player.DEBUG; + private static final int NOTIFICATION_ID = 123789; + + @NotificationConstants.Action + private final int[] notificationSlots = NotificationConstants.SLOT_DEFAULTS.clone(); + + private NotificationManagerCompat notificationManager; + private NotificationCompat.Builder notificationBuilder; + + private final Player player; + + public NotificationUtil(final Player player) { + this.player = player; + } + + + ///////////////////////////////////////////////////// + // NOTIFICATION + ///////////////////////////////////////////////////// + + /** + * Creates the notification if it does not exist already and recreates it if forceRecreate is + * true. Updates the notification with the data in the player. + * @param forceRecreate whether to force the recreation of the notification even if it already + * exists + */ + public synchronized void createNotificationIfNeededAndUpdate(final boolean forceRecreate) { + if (forceRecreate || notificationBuilder == null) { + notificationBuilder = createNotification(); + } + updateNotification(); + notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()); + } + + public synchronized void updateThumbnail() { + if (notificationBuilder != null) { + if (DEBUG) { + Log.d(TAG, "updateThumbnail() called with thumbnail = [" + Integer.toHexString( + Optional.ofNullable(player.getThumbnail()).map(Objects::hashCode).orElse(0)) + + "], title = [" + player.getVideoTitle() + "]"); + } + + setLargeIcon(notificationBuilder); + notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build()); + } + } + + private synchronized NotificationCompat.Builder createNotification() { + if (DEBUG) { + Log.d(TAG, "createNotification()"); + } + notificationManager = NotificationManagerCompat.from(player.getContext()); + final NotificationCompat.Builder builder = + new NotificationCompat.Builder(player.getContext(), + player.getContext().getString(R.string.notification_channel_id)); + final MediaStyle mediaStyle = new MediaStyle(); + + // setup media style (compact notification slots and media session) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + // notification actions are ignored on Android 13+, and are replaced by code in + // MediaSessionPlayerUi + final int[] compactSlots = initializeNotificationSlots(); + mediaStyle.setShowActionsInCompactView(compactSlots); + } + player.UIs() + .get(MediaSessionPlayerUi.class) + .flatMap(MediaSessionPlayerUi::getSessionToken) + .ifPresent(mediaStyle::setMediaSession); + + // setup notification builder + builder.setStyle(mediaStyle) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setCategory(NotificationCompat.CATEGORY_TRANSPORT) + .setShowWhen(false) + .setSmallIcon(R.drawable.ic_newpipe_triangle_white) + .setColor(ContextCompat.getColor(player.getContext(), + R.color.dark_background_color)) + .setColorized(player.getPrefs().getBoolean( + player.getContext().getString(R.string.notification_colorize_key), true)) + .setDeleteIntent(PendingIntentCompat.getBroadcast(player.getContext(), + NOTIFICATION_ID, new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT, false)); + + // set the initial value for the video thumbnail, updatable with updateNotificationThumbnail + setLargeIcon(builder); + + return builder; + } + + /** + * Updates the notification builder and the button icons depending on the playback state. + */ + private synchronized void updateNotification() { + if (DEBUG) { + Log.d(TAG, "updateNotification()"); + } + + // also update content intent, in case the user switched players + notificationBuilder.setContentIntent(PendingIntentCompat.getActivity(player.getContext(), + NOTIFICATION_ID, getIntentForNotification(), FLAG_UPDATE_CURRENT, false)); + notificationBuilder.setContentTitle(player.getVideoTitle()); + notificationBuilder.setContentText(player.getUploaderName()); + notificationBuilder.setTicker(player.getVideoTitle()); + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + // notification actions are ignored on Android 13+, and are replaced by code in + // MediaSessionPlayerUi + updateActions(notificationBuilder); + } + } + + + @SuppressLint("RestrictedApi") + public boolean shouldUpdateBufferingSlot() { + if (notificationBuilder == null) { + // if there is no notification active, there is no point in updating it + return false; + } else if (notificationBuilder.mActions.size() < 3) { + // this should never happen, but let's make sure notification actions are populated + return true; + } + + // only second and third slot could contain PLAY_PAUSE_BUFFERING, update them only if they + // are not already in the buffering state (the only one with a null action intent) + return (notificationSlots[1] == NotificationConstants.PLAY_PAUSE_BUFFERING + && notificationBuilder.mActions.get(1).actionIntent != null) + || (notificationSlots[2] == NotificationConstants.PLAY_PAUSE_BUFFERING + && notificationBuilder.mActions.get(2).actionIntent != null); + } + + + public void createNotificationAndStartForeground() { + if (notificationBuilder == null) { + notificationBuilder = createNotification(); + } + updateNotification(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build(), + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK); + } else { + player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build()); + } + } + + public void cancelNotificationAndStopForeground() { + ServiceCompat.stopForeground(player.getService(), ServiceCompat.STOP_FOREGROUND_REMOVE); + + if (notificationManager != null) { + notificationManager.cancel(NOTIFICATION_ID); + } + notificationManager = null; + notificationBuilder = null; + } + + + ///////////////////////////////////////////////////// + // ACTIONS + ///////////////////////////////////////////////////// + + /** + * The compact slots array from settings contains indices from 0 to 4, each referring to one of + * the five actions configurable by the user. However, if the user sets an action to "Nothing", + * then all of the actions coming after will have a "settings index" different than the index + * of the corresponding action when sent to the system. + * + * @return the indices of compact slots referred to the list of non-nothing actions that will be + * sent to the system + */ + private int[] initializeNotificationSlots() { + final Collection settingsCompactSlots = NotificationConstants + .getCompactSlotsFromPreferences(player.getContext(), player.getPrefs()); + final List adjustedCompactSlots = new ArrayList<>(); + + int nonNothingIndex = 0; + for (int i = 0; i < 5; ++i) { + notificationSlots[i] = player.getPrefs().getInt( + player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]), + NotificationConstants.SLOT_DEFAULTS[i]); + + if (notificationSlots[i] != NotificationConstants.NOTHING) { + if (settingsCompactSlots.contains(i)) { + adjustedCompactSlots.add(nonNothingIndex); + } + nonNothingIndex += 1; + } + } + + return adjustedCompactSlots.stream().mapToInt(Integer::intValue).toArray(); + } + + @SuppressLint("RestrictedApi") + private void updateActions(final NotificationCompat.Builder builder) { + builder.mActions.clear(); + for (int i = 0; i < 5; ++i) { + addAction(builder, notificationSlots[i]); + } + } + + private void addAction(final NotificationCompat.Builder builder, + @NotificationConstants.Action final int slot) { + @Nullable final NotificationActionData data = + NotificationActionData.fromNotificationActionEnum(player, slot); + if (data == null) { + return; + } + + final PendingIntent intent = PendingIntentCompat.getBroadcast(player.getContext(), + NOTIFICATION_ID, new Intent(data.action()), FLAG_UPDATE_CURRENT, false); + builder.addAction(new NotificationCompat.Action(data.icon(), data.name(), intent)); + } + + private Intent getIntentForNotification() { + if (player.audioPlayerSelected() || player.popupPlayerSelected()) { + // Means we play in popup or audio only. Let's show the play queue + return NavigationHelper.getPlayQueueActivityIntent(player.getContext()); + } else { + // We are playing in fragment. Don't open another activity just show fragment. That's it + final Intent intent = NavigationHelper.getPlayerIntent( + player.getContext(), MainActivity.class, null, true); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.setAction(Intent.ACTION_MAIN); + intent.addCategory(Intent.CATEGORY_LAUNCHER); + return intent; + } + } + + + ///////////////////////////////////////////////////// + // BITMAP + ///////////////////////////////////////////////////// + + private void setLargeIcon(final NotificationCompat.Builder builder) { + final boolean showThumbnail = player.getPrefs().getBoolean( + player.getContext().getString(R.string.show_thumbnail_key), true); + final Bitmap thumbnail = player.getThumbnail(); + if (thumbnail == null || !showThumbnail) { + // since the builder is reused, make sure the thumbnail is unset if there is not one + builder.setLargeIcon((Bitmap) null); + return; + } + + final boolean scaleImageToSquareAspectRatio = player.getPrefs().getBoolean( + player.getContext().getString(R.string.scale_to_square_image_in_notifications_key), + false); + if (scaleImageToSquareAspectRatio) { + builder.setLargeIcon(getBitmapWithSquareAspectRatio(thumbnail)); + } else { + builder.setLargeIcon(thumbnail); + } + } + + private Bitmap getBitmapWithSquareAspectRatio(@NonNull final Bitmap bitmap) { + // Find the smaller dimension and then take a center portion of the image that + // has that size. + final int w = bitmap.getWidth(); + final int h = bitmap.getHeight(); + final int dstSize = Math.min(w, h); + final int x = (w - dstSize) / 2; + final int y = (h - dstSize) / 2; + return Bitmap.createBitmap(bitmap, x, y, dstSize, dstSize); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java index 9b13bb3d7..88d7145bc 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/MediaSourceManager.java @@ -7,8 +7,6 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.collection.ArraySet; -import com.google.android.exoplayer2.source.MediaSource; - import org.reactivestreams.Subscriber; import org.reactivestreams.Subscription; import org.schabi.newpipe.extractor.exceptions.ExtractionException; @@ -23,10 +21,10 @@ import org.schabi.newpipe.player.playqueue.events.MoveEvent; import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent; import org.schabi.newpipe.player.playqueue.events.RemoveEvent; import org.schabi.newpipe.player.playqueue.events.ReorderEvent; -import org.schabi.newpipe.util.ServiceHelper; import java.util.Collection; import java.util.Collections; +import java.util.Optional; import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; @@ -43,6 +41,7 @@ import io.reactivex.rxjava3.subjects.PublishSubject; import static org.schabi.newpipe.player.mediasource.FailedMediaSource.MediaSourceResolutionException; import static org.schabi.newpipe.player.mediasource.FailedMediaSource.StreamInfoLoadException; import static org.schabi.newpipe.player.playqueue.PlayQueue.DEBUG; +import static org.schabi.newpipe.util.ServiceHelper.getCacheExpirationMillis; public class MediaSourceManager { @NonNull @@ -421,31 +420,39 @@ public class MediaSourceManager { } private Single getLoadedMediaSource(@NonNull final PlayQueueItem stream) { - return stream.getStream().map(streamInfo -> { - final MediaSource source = playbackListener.sourceOf(stream, streamInfo); - if (source == null || !MediaItemTag.from(source.getMediaItem()).isPresent()) { - final String message = "Unable to resolve source from stream info. " - + "URL: " + stream.getUrl() + ", " - + "audio count: " + streamInfo.getAudioStreams().size() + ", " - + "video count: " + streamInfo.getVideoOnlyStreams().size() + ", " - + streamInfo.getVideoStreams().size(); - return (ManagedMediaSource) - FailedMediaSource.of(stream, new MediaSourceResolutionException(message)); - } - - final MediaItemTag tag = MediaItemTag.from(source.getMediaItem()).get(); - final long expiration = System.currentTimeMillis() - + ServiceHelper.getCacheExpirationMillis(streamInfo.getServiceId()); - return new LoadedMediaSource(source, tag, stream, expiration); - }).onErrorReturn(throwable -> { - if (throwable instanceof ExtractionException) { - return FailedMediaSource.of(stream, new StreamInfoLoadException(throwable)); - } - // Non-source related error expected here (e.g. network), - // should allow retry shortly after the error. - return FailedMediaSource.of(stream, new Exception(throwable), - /*allowRetryIn=*/TimeUnit.MILLISECONDS.convert(3, TimeUnit.SECONDS)); - }); + return stream.getStream() + .map(streamInfo -> Optional + .ofNullable(playbackListener.sourceOf(stream, streamInfo)) + .flatMap(source -> + MediaItemTag.from(source.getMediaItem()) + .map(tag -> { + final int serviceId = streamInfo.getServiceId(); + final long expiration = System.currentTimeMillis() + + getCacheExpirationMillis(serviceId); + return new LoadedMediaSource(source, tag, stream, + expiration); + }) + ) + .orElseGet(() -> { + final String message = "Unable to resolve source from stream info. " + + "URL: " + stream.getUrl() + + ", audio count: " + streamInfo.getAudioStreams().size() + + ", video count: " + streamInfo.getVideoOnlyStreams().size() + + ", " + streamInfo.getVideoStreams().size(); + return FailedMediaSource.of(stream, + new MediaSourceResolutionException(message)); + }) + ) + .onErrorReturn(throwable -> { + if (throwable instanceof ExtractionException) { + return FailedMediaSource.of(stream, new StreamInfoLoadException(throwable)); + } + // Non-source related error expected here (e.g. network), + // should allow retry shortly after the error. + final long allowRetryIn = TimeUnit.MILLISECONDS.convert(3, + TimeUnit.SECONDS); + return FailedMediaSource.of(stream, new Exception(throwable), allowRetryIn); + }); } private void onMediaSourceReceived(@NonNull final PlayQueueItem item, diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/PlayerMediaSession.java b/app/src/main/java/org/schabi/newpipe/player/playback/PlayerMediaSession.java deleted file mode 100644 index ee0a6f118..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playback/PlayerMediaSession.java +++ /dev/null @@ -1,99 +0,0 @@ -package org.schabi.newpipe.player.playback; - -import android.net.Uri; -import android.os.Bundle; -import android.support.v4.media.MediaDescriptionCompat; -import android.support.v4.media.MediaMetadataCompat; - -import org.schabi.newpipe.player.Player; -import org.schabi.newpipe.player.mediasession.MediaSessionCallback; -import org.schabi.newpipe.player.playqueue.PlayQueueItem; - -public class PlayerMediaSession implements MediaSessionCallback { - private final Player player; - - public PlayerMediaSession(final Player player) { - this.player = player; - } - - @Override - public void playPrevious() { - player.playPrevious(); - } - - @Override - public void playNext() { - player.playNext(); - } - - @Override - public void playItemAtIndex(final int index) { - if (player.getPlayQueue() == null) { - return; - } - player.selectQueueItem(player.getPlayQueue().getItem(index)); - } - - @Override - public int getCurrentPlayingIndex() { - if (player.getPlayQueue() == null) { - return -1; - } - return player.getPlayQueue().getIndex(); - } - - @Override - public int getQueueSize() { - if (player.getPlayQueue() == null) { - return -1; - } - return player.getPlayQueue().size(); - } - - @Override - public MediaDescriptionCompat getQueueMetadata(final int index) { - if (player.getPlayQueue() == null) { - return null; - } - final PlayQueueItem item = player.getPlayQueue().getItem(index); - if (item == null) { - return null; - } - - final MediaDescriptionCompat.Builder descriptionBuilder - = new MediaDescriptionCompat.Builder() - .setMediaId(String.valueOf(index)) - .setTitle(item.getTitle()) - .setSubtitle(item.getUploader()); - - // set additional metadata for A2DP/AVRCP - final Bundle additionalMetadata = new Bundle(); - additionalMetadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, item.getTitle()); - additionalMetadata.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, item.getUploader()); - additionalMetadata - .putLong(MediaMetadataCompat.METADATA_KEY_DURATION, item.getDuration() * 1000); - additionalMetadata.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, index + 1); - additionalMetadata - .putLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS, player.getPlayQueue().size()); - descriptionBuilder.setExtras(additionalMetadata); - - final Uri thumbnailUri = Uri.parse(item.getThumbnailUrl()); - if (thumbnailUri != null) { - descriptionBuilder.setIconUri(thumbnailUri); - } - - return descriptionBuilder.build(); - } - - @Override - public void play() { - player.play(); - // hide the player controls even if the play command came from the media session - player.hideControls(0, 0); - } - - @Override - public void pause() { - player.pause(); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java b/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java index 5d67e6967..da6cb36d4 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java +++ b/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java @@ -4,7 +4,7 @@ import android.content.Context; import android.view.SurfaceHolder; import com.google.android.exoplayer2.Player; -import com.google.android.exoplayer2.video.DummySurface; +import com.google.android.exoplayer2.video.PlaceholderSurface; /** * Prevent error message: 'Unrecoverable player error occurred' @@ -26,7 +26,7 @@ public final class SurfaceHolderCallback implements SurfaceHolder.Callback { private final Context context; private final Player player; - private DummySurface dummySurface; + private PlaceholderSurface placeholderSurface; public SurfaceHolderCallback(final Context context, final Player player) { this.context = context; @@ -47,16 +47,16 @@ public final class SurfaceHolderCallback implements SurfaceHolder.Callback { @Override public void surfaceDestroyed(final SurfaceHolder holder) { - if (dummySurface == null) { - dummySurface = DummySurface.newInstanceV17(context, false); + if (placeholderSurface == null) { + placeholderSurface = PlaceholderSurface.newInstanceV17(context, false); } - player.setVideoSurface(dummySurface); + player.setVideoSurface(placeholderSurface); } public void release() { - if (dummySurface != null) { - dummySurface.release(); - dummySurface = null; + if (placeholderSurface != null) { + placeholderSurface.release(); + placeholderSurface = null; } } } diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java index df2747c3b..33ec390a5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/AbstractInfoPlayQueue.java @@ -4,6 +4,7 @@ import android.util.Log; import androidx.annotation.NonNull; +import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.Page; @@ -15,7 +16,7 @@ import java.util.stream.Collectors; import io.reactivex.rxjava3.core.SingleObserver; import io.reactivex.rxjava3.disposables.Disposable; -abstract class AbstractInfoPlayQueue> +abstract class AbstractInfoPlayQueue> extends PlayQueue { boolean isInitial; private boolean isComplete; @@ -27,7 +28,13 @@ abstract class AbstractInfoPlayQueue> private transient Disposable fetchReactor; protected AbstractInfoPlayQueue(final T info) { - this(info.getServiceId(), info.getUrl(), info.getNextPage(), info.getRelatedItems(), 0); + this(info.getServiceId(), info.getUrl(), info.getNextPage(), + info.getRelatedItems() + .stream() + .filter(StreamInfoItem.class::isInstance) + .map(StreamInfoItem.class::cast) + .collect(Collectors.toList()), + 0); } protected AbstractInfoPlayQueue(final int serviceId, @@ -72,7 +79,11 @@ abstract class AbstractInfoPlayQueue> } nextPage = result.getNextPage(); - append(extractListItems(result.getRelatedItems())); + append(extractListItems(result.getRelatedItems() + .stream() + .filter(StreamInfoItem.class::isInstance) + .map(StreamInfoItem.class::cast) + .collect(Collectors.toList()))); fetchReactor.dispose(); fetchReactor = null; @@ -82,12 +93,12 @@ abstract class AbstractInfoPlayQueue> public void onError(@NonNull final Throwable e) { Log.e(getTag(), "Error fetching more playlist, marking playlist as complete.", e); isComplete = true; - append(); // Notify change + notifyChange(); } }; } - SingleObserver> getNextPageObserver() { + SingleObserver> getNextPageObserver() { return new SingleObserver<>() { @Override public void onSubscribe(@NonNull final Disposable d) { @@ -101,13 +112,17 @@ abstract class AbstractInfoPlayQueue> @Override public void onSuccess( - @NonNull final ListExtractor.InfoItemsPage result) { + @NonNull final ListExtractor.InfoItemsPage result) { if (!result.hasNextPage()) { isComplete = true; } nextPage = result.getNextPage(); - append(extractListItems(result.getItems())); + append(extractListItems(result.getItems() + .stream() + .filter(StreamInfoItem.class::isInstance) + .map(StreamInfoItem.class::cast) + .collect(Collectors.toList()))); fetchReactor.dispose(); fetchReactor = null; @@ -117,7 +132,7 @@ abstract class AbstractInfoPlayQueue> public void onError(@NonNull final Throwable e) { Log.e(getTag(), "Error fetching more playlist, marking playlist as complete.", e); isComplete = true; - append(); // Notify change + notifyChange(); } }; } diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelPlayQueue.java deleted file mode 100644 index 1e1fef85e..000000000 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelPlayQueue.java +++ /dev/null @@ -1,47 +0,0 @@ -package org.schabi.newpipe.player.playqueue; - - -import org.schabi.newpipe.extractor.Page; -import org.schabi.newpipe.extractor.channel.ChannelInfo; -import org.schabi.newpipe.extractor.stream.StreamInfoItem; -import org.schabi.newpipe.util.ExtractorHelper; - -import java.util.List; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public final class ChannelPlayQueue extends AbstractInfoPlayQueue { - - public ChannelPlayQueue(final ChannelInfo info) { - super(info); - } - - public ChannelPlayQueue(final int serviceId, - final String url, - final Page nextPage, - final List streams, - final int index) { - super(serviceId, url, nextPage, streams, index); - } - - @Override - protected String getTag() { - return "ChannelPlayQueue@" + Integer.toHexString(hashCode()); - } - - @Override - public void fetch() { - if (this.isInitial) { - ExtractorHelper.getChannelInfo(this.serviceId, this.baseUrl, false) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getHeadListObserver()); - } else { - ExtractorHelper.getMoreChannelItems(this.serviceId, this.baseUrl, this.nextPage) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(getNextPageObserver()); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java new file mode 100644 index 000000000..a9eb2a19c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/ChannelTabPlayQueue.java @@ -0,0 +1,53 @@ +package org.schabi.newpipe.player.playqueue; + + +import org.schabi.newpipe.extractor.Page; +import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.util.ExtractorHelper; + +import java.util.Collections; +import java.util.List; + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.schedulers.Schedulers; + +public final class ChannelTabPlayQueue extends AbstractInfoPlayQueue { + + final ListLinkHandler linkHandler; + + public ChannelTabPlayQueue(final int serviceId, + final ListLinkHandler linkHandler, + final Page nextPage, + final List streams, + final int index) { + super(serviceId, linkHandler.getUrl(), nextPage, streams, index); + this.linkHandler = linkHandler; + } + + public ChannelTabPlayQueue(final int serviceId, + final ListLinkHandler linkHandler) { + this(serviceId, linkHandler, null, Collections.emptyList(), 0); + } + + @Override + protected String getTag() { + return "ChannelTabPlayQueue@" + Integer.toHexString(hashCode()); + } + + @Override + public void fetch() { + if (isInitial) { + ExtractorHelper.getChannelTab(this.serviceId, this.linkHandler, false) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getHeadListObserver()); + } else { + ExtractorHelper.getMoreChannelTabItems(this.serviceId, this.linkHandler, this.nextPage) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(getNextPageObserver()); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java index f46c9d72f..cfa2ab316 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java @@ -16,7 +16,6 @@ import org.schabi.newpipe.player.playqueue.events.SelectEvent; import java.io.Serializable; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.atomic.AtomicInteger; @@ -258,13 +257,10 @@ public abstract class PlayQueue implements Serializable { } /** - * Appends the given {@link PlayQueueItem}s to the current play queue. - * - * @see #append(List items) - * @param items {@link PlayQueueItem}s to append + * Notifies that a change has occurred. */ - public synchronized void append(@NonNull final PlayQueueItem... items) { - append(Arrays.asList(items)); + public synchronized void notifyChange() { + broadcast(new AppendEvent(0)); } /** @@ -522,12 +518,10 @@ public abstract class PlayQueue implements Serializable { * This method also gives a chance to track history of items in a queue in * VideoDetailFragment without duplicating items from two identical queues */ - @Override - public boolean equals(@Nullable final Object obj) { - if (!(obj instanceof PlayQueue)) { + public boolean equalStreams(@Nullable final PlayQueue other) { + if (other == null) { return false; } - final PlayQueue other = (PlayQueue) obj; if (size() != other.size()) { return false; } @@ -543,9 +537,12 @@ public abstract class PlayQueue implements Serializable { return true; } - @Override - public int hashCode() { - return streams.hashCode(); + public boolean equalStreamsAndIndex(@Nullable final PlayQueue other) { + if (equalStreams(other)) { + //noinspection ConstantConditions + return other.getIndex() == getIndex(); //NOSONAR: other is not null + } + return false; } public boolean isDisposed() { diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java index bf31ea9b1..759c51267 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java @@ -3,12 +3,14 @@ package org.schabi.newpipe.player.playqueue; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import org.schabi.newpipe.extractor.Image; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.util.ExtractorHelper; import java.io.Serializable; +import java.util.List; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.schedulers.Schedulers; @@ -24,7 +26,7 @@ public class PlayQueueItem implements Serializable { private final int serviceId; private final long duration; @NonNull - private final String thumbnailUrl; + private final List thumbnails; @NonNull private final String uploader; private final String uploaderUrl; @@ -38,7 +40,7 @@ public class PlayQueueItem implements Serializable { PlayQueueItem(@NonNull final StreamInfo info) { this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(), - info.getThumbnailUrl(), info.getUploaderName(), + info.getThumbnails(), info.getUploaderName(), info.getUploaderUrl(), info.getStreamType()); if (info.getStartPosition() > 0) { @@ -48,20 +50,20 @@ public class PlayQueueItem implements Serializable { PlayQueueItem(@NonNull final StreamInfoItem item) { this(item.getName(), item.getUrl(), item.getServiceId(), item.getDuration(), - item.getThumbnailUrl(), item.getUploaderName(), + item.getThumbnails(), item.getUploaderName(), item.getUploaderUrl(), item.getStreamType()); } @SuppressWarnings("ParameterNumber") private PlayQueueItem(@Nullable final String name, @Nullable final String url, final int serviceId, final long duration, - @Nullable final String thumbnailUrl, @Nullable final String uploader, + final List thumbnails, @Nullable final String uploader, final String uploaderUrl, @NonNull final StreamType streamType) { this.title = name != null ? name : EMPTY_STRING; this.url = url != null ? url : EMPTY_STRING; this.serviceId = serviceId; this.duration = duration; - this.thumbnailUrl = thumbnailUrl != null ? thumbnailUrl : EMPTY_STRING; + this.thumbnails = thumbnails; this.uploader = uploader != null ? uploader : EMPTY_STRING; this.uploaderUrl = uploaderUrl; this.streamType = streamType; @@ -88,8 +90,8 @@ public class PlayQueueItem implements Serializable { } @NonNull - public String getThumbnailUrl() { - return thumbnailUrl; + public List getThumbnails() { + return thumbnails; } @NonNull diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.java index f2e98d866..066f92c26 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemBuilder.java @@ -5,9 +5,9 @@ import android.text.TextUtils; import android.view.MotionEvent; import android.view.View; -import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.image.PicassoHelper; +import org.schabi.newpipe.util.ServiceHelper; public class PlayQueueItemBuilder { private static final String TAG = PlayQueueItemBuilder.class.toString(); @@ -25,7 +25,7 @@ public class PlayQueueItemBuilder { holder.itemVideoTitleView.setText(item.getTitle()); } holder.itemAdditionalDetailsView.setText(Localization.concatenateStrings(item.getUploader(), - NewPipe.getNameOfService(item.getServiceId()))); + ServiceHelper.getNameOfServiceById(item.getServiceId()))); if (item.getDuration() > 0) { holder.itemDurationView.setText(Localization.getDurationString(item.getDuration())); @@ -33,7 +33,7 @@ public class PlayQueueItemBuilder { holder.itemDurationView.setVisibility(View.GONE); } - PicassoHelper.loadThumbnail(item.getThumbnailUrl()).into(holder.itemThumbnailView); + PicassoHelper.loadThumbnail(item.getThumbnails()).into(holder.itemThumbnailView); holder.itemRoot.setOnClickListener(view -> { if (onItemClickListener != null) { diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.java index b283e105e..6e2792d4f 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItemTouchCallback.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.player.playqueue; +import androidx.annotation.NonNull; +import androidx.core.math.MathUtils; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; @@ -16,18 +18,21 @@ public abstract class PlayQueueItemTouchCallback extends ItemTouchHelper.SimpleC public abstract void onSwiped(int index); @Override - public int interpolateOutOfBoundsScroll(final RecyclerView recyclerView, final int viewSize, - final int viewSizeOutOfBounds, final int totalSize, + public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView, + final int viewSize, + final int viewSizeOutOfBounds, + final int totalSize, final long msSinceStartScroll) { final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll); - final int clampedAbsVelocity = Math.max(MINIMUM_INITIAL_DRAG_VELOCITY, - Math.min(Math.abs(standardSpeed), MAXIMUM_INITIAL_DRAG_VELOCITY)); + final int clampedAbsVelocity = MathUtils.clamp(Math.abs(standardSpeed), + MINIMUM_INITIAL_DRAG_VELOCITY, MAXIMUM_INITIAL_DRAG_VELOCITY); return clampedAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); } @Override - public boolean onMove(final RecyclerView recyclerView, final RecyclerView.ViewHolder source, + public boolean onMove(@NonNull final RecyclerView recyclerView, + final RecyclerView.ViewHolder source, final RecyclerView.ViewHolder target) { if (source.getItemViewType() != target.getItemViewType()) { return false; diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java index 527e80470..0eb0f235a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java @@ -1,31 +1,32 @@ package org.schabi.newpipe.player.playqueue; +import androidx.annotation.NonNull; + import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import java.util.ArrayList; -import java.util.Collections; import java.util.List; public final class SinglePlayQueue extends PlayQueue { public SinglePlayQueue(final StreamInfoItem item) { - super(0, Collections.singletonList(new PlayQueueItem(item))); + super(0, List.of(new PlayQueueItem(item))); } public SinglePlayQueue(final StreamInfo info) { - super(0, Collections.singletonList(new PlayQueueItem(info))); + super(0, List.of(new PlayQueueItem(info))); } public SinglePlayQueue(final StreamInfo info, final long startPosition) { - super(0, Collections.singletonList(new PlayQueueItem(info))); + super(0, List.of(new PlayQueueItem(info))); getItem().setRecoveryPosition(startPosition); } - public SinglePlayQueue(final List items, final int index) { + public SinglePlayQueue(@NonNull final List items, final int index) { super(index, playQueueItemsOf(items)); } - private static List playQueueItemsOf(final List items) { + private static List playQueueItemsOf(@NonNull final List items) { final List playQueueItems = new ArrayList<>(items.size()); for (final StreamInfoItem item : items) { playQueueItems.add(new PlayQueueItem(item)); @@ -40,5 +41,7 @@ public final class SinglePlayQueue extends PlayQueue { @Override public void fetch() { + // Item was already passed in constructor. + // No further items need to be fetched as this is a PlayQueue with only one item } } diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java index 9bded9331..2d4404b2a 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/AudioPlaybackResolver.java @@ -1,26 +1,36 @@ package org.schabi.newpipe.player.resolver; +import static org.schabi.newpipe.util.ListHelper.getFilteredAudioStreams; +import static org.schabi.newpipe.util.ListHelper.getPlayableStreams; + import android.content.Context; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import com.google.android.exoplayer2.source.MediaSource; -import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.player.helper.PlayerDataSource; -import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediaitem.StreamInfoTag; import org.schabi.newpipe.util.ListHelper; +import java.util.List; + public class AudioPlaybackResolver implements PlaybackResolver { + private static final String TAG = AudioPlaybackResolver.class.getSimpleName(); + @NonNull private final Context context; @NonNull private final PlayerDataSource dataSource; + @Nullable + private String audioTrack; public AudioPlaybackResolver(@NonNull final Context context, @NonNull final PlayerDataSource dataSource) { @@ -28,22 +38,66 @@ public class AudioPlaybackResolver implements PlaybackResolver { this.dataSource = dataSource; } + /** + * Get a media source providing audio. If a service has no separate {@link AudioStream}s we + * use a video stream as audio source to support audio background playback. + * + * @param info of the stream + * @return the audio source to use or null if none could be found + */ @Override @Nullable public MediaSource resolve(@NonNull final StreamInfo info) { - final MediaSource liveSource = maybeBuildLiveMediaSource(dataSource, info); + final MediaSource liveSource = PlaybackResolver.maybeBuildLiveMediaSource(dataSource, info); if (liveSource != null) { return liveSource; } - final int index = ListHelper.getDefaultAudioFormat(context, info.getAudioStreams()); - if (index < 0 || index >= info.getAudioStreams().size()) { - return null; + final List audioStreams = + getFilteredAudioStreams(context, info.getAudioStreams()); + final Stream stream; + final MediaItemTag tag; + + if (!audioStreams.isEmpty()) { + final int audioIndex = + ListHelper.getAudioFormatIndex(context, audioStreams, audioTrack); + stream = getStreamForIndex(audioIndex, audioStreams); + tag = StreamInfoTag.of(info, audioStreams, audioIndex); + } else { + final List videoStreams = + getPlayableStreams(info.getVideoStreams(), info.getServiceId()); + if (!videoStreams.isEmpty()) { + final int index = ListHelper.getDefaultResolutionIndex(context, videoStreams); + stream = getStreamForIndex(index, videoStreams); + tag = StreamInfoTag.of(info); + } else { + return null; + } } - final AudioStream audio = info.getAudioStreams().get(index); - final MediaItemTag tag = StreamInfoTag.of(info); - return buildMediaSource(dataSource, audio.getUrl(), PlayerHelper.cacheKeyOf(info, audio), - MediaFormat.getSuffixById(audio.getFormatId()), tag); + try { + return PlaybackResolver.buildMediaSource( + dataSource, stream, info, PlaybackResolver.cacheKeyOf(info, stream), tag); + } catch (final ResolverException e) { + Log.e(TAG, "Unable to create audio source", e); + return null; + } + } + + @Nullable + Stream getStreamForIndex(final int index, @NonNull final List streams) { + if (index >= 0 && index < streams.size()) { + return streams.get(index); + } + return null; + } + + @Nullable + public String getAudioTrack() { + return audioTrack; + } + + public void setAudioTrack(@Nullable final String audioLanguage) { + this.audioTrack = audioLanguage; } } diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java index 90b38ed51..e204b8372 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/PlaybackResolver.java @@ -1,63 +1,239 @@ package org.schabi.newpipe.player.resolver; +import static org.schabi.newpipe.extractor.stream.AudioStream.UNKNOWN_BITRATE; +import static org.schabi.newpipe.extractor.stream.VideoStream.RESOLUTION_UNKNOWN; +import static org.schabi.newpipe.player.helper.PlayerDataSource.LIVE_STREAM_EDGE_GAP_MILLIS; + import android.net.Uri; -import android.text.TextUtils; +import android.util.Log; + +import androidx.annotation.Nullable; import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.source.MediaSource; -import com.google.android.exoplayer2.util.Util; +import com.google.android.exoplayer2.source.ProgressiveMediaSource; +import com.google.android.exoplayer2.source.dash.DashMediaSource; +import com.google.android.exoplayer2.source.dash.manifest.DashManifest; +import com.google.android.exoplayer2.source.dash.manifest.DashManifestParser; +import com.google.android.exoplayer2.source.hls.HlsMediaSource; +import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; +import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifest; +import com.google.android.exoplayer2.source.smoothstreaming.manifest.SsManifestParser; +import org.schabi.newpipe.extractor.MediaFormat; +import org.schabi.newpipe.extractor.ServiceList; +import org.schabi.newpipe.extractor.services.youtube.ItagItem; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.CreationException; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeOtfDashManifestCreator; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubePostLiveStreamDvrDashManifestCreator; +import org.schabi.newpipe.extractor.services.youtube.dashmanifestcreators.YoutubeProgressiveDashManifestCreator; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.DeliveryMethod; +import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamType; +import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.player.datasource.NonUriHlsDataSourceFactory; import org.schabi.newpipe.player.helper.PlayerDataSource; import org.schabi.newpipe.player.mediaitem.MediaItemTag; import org.schabi.newpipe.player.mediaitem.StreamInfoTag; import org.schabi.newpipe.util.StreamTypeUtil; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import static org.schabi.newpipe.player.helper.PlayerDataSource.LIVE_STREAM_EDGE_GAP_MILLIS; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Objects; +/** + * This interface is just a shorthand for {@link Resolver} with {@link StreamInfo} as source and + * {@link MediaSource} as product. It contains many static methods that can be used by classes + * implementing this interface, and nothing else. + */ public interface PlaybackResolver extends Resolver { + String TAG = PlaybackResolver.class.getSimpleName(); + + //region Cache key generation + private static StringBuilder commonCacheKeyOf(final StreamInfo info, + final Stream stream, + final boolean resolutionOrBitrateUnknown) { + // stream info service id + final StringBuilder cacheKey = new StringBuilder(info.getServiceId()); + + // stream info id + cacheKey.append(" "); + cacheKey.append(info.getId()); + + // stream id (even if unknown) + cacheKey.append(" "); + cacheKey.append(stream.getId()); + + // mediaFormat (if not null) + final MediaFormat mediaFormat = stream.getFormat(); + if (mediaFormat != null) { + cacheKey.append(" "); + cacheKey.append(mediaFormat.getName()); + } + + // content (only if other information is missing) + // If the media format and the resolution/bitrate are both missing, then we don't have + // enough information to distinguish this stream from other streams. + // So, only in that case, we use the content (i.e. url or manifest) to differentiate + // between streams. + // Note that if the content were used even when other information is present, then two + // streams with the same stats but with different contents (e.g. because the url was + // refreshed) will be considered different (i.e. with a different cacheKey), making the + // cache useless. + if (resolutionOrBitrateUnknown && mediaFormat == null) { + cacheKey.append(" "); + cacheKey.append(Objects.hash(stream.getContent(), stream.getManifestUrl())); + } + + return cacheKey; + } + + /** + * Builds the cache key of a {@link VideoStream video stream}. + * + *

+ * A cache key is unique to the features of the provided video stream, and when possible + * independent of transient parameters (such as the URL of the stream). + * This ensures that there are no conflicts, but also that the cache is used as much as + * possible: the same cache should be used for two streams which have the same features but + * e.g. a different URL, since the URL might have been reloaded in the meantime, but the stream + * actually referenced by the URL is still the same. + *

+ * + * @param info the {@link StreamInfo stream info}, to distinguish between streams with + * the same features but coming from different stream infos + * @param videoStream the {@link VideoStream video stream} for which the cache key should be + * created + * @return a key to be used to store the cache of the provided {@link VideoStream video stream} + */ + static String cacheKeyOf(final StreamInfo info, final VideoStream videoStream) { + final boolean resolutionUnknown = videoStream.getResolution().equals(RESOLUTION_UNKNOWN); + final StringBuilder cacheKey = commonCacheKeyOf(info, videoStream, resolutionUnknown); + + // resolution (if known) + if (!resolutionUnknown) { + cacheKey.append(" "); + cacheKey.append(videoStream.getResolution()); + } + + // isVideoOnly + cacheKey.append(" "); + cacheKey.append(videoStream.isVideoOnly()); + + return cacheKey.toString(); + } + + /** + * Builds the cache key of an audio stream. + * + *

+ * A cache key is unique to the features of the provided {@link AudioStream audio stream}, and + * when possible independent of transient parameters (such as the URL of the stream). + * This ensures that there are no conflicts, but also that the cache is used as much as + * possible: the same cache should be used for two streams which have the same features but + * e.g. a different URL, since the URL might have been reloaded in the meantime, but the stream + * actually referenced by the URL is still the same. + *

+ * + * @param info the {@link StreamInfo stream info}, to distinguish between streams with + * the same features but coming from different stream infos + * @param audioStream the {@link AudioStream audio stream} for which the cache key should be + * created + * @return a key to be used to store the cache of the provided {@link AudioStream audio stream} + */ + static String cacheKeyOf(final StreamInfo info, final AudioStream audioStream) { + final boolean averageBitrateUnknown = audioStream.getAverageBitrate() == UNKNOWN_BITRATE; + final StringBuilder cacheKey = commonCacheKeyOf(info, audioStream, averageBitrateUnknown); + + // averageBitrate (if known) + if (!averageBitrateUnknown) { + cacheKey.append(" "); + cacheKey.append(audioStream.getAverageBitrate()); + } + + if (audioStream.getAudioTrackId() != null) { + cacheKey.append(" "); + cacheKey.append(audioStream.getAudioTrackId()); + } + + if (audioStream.getAudioLocale() != null) { + cacheKey.append(" "); + cacheKey.append(audioStream.getAudioLocale().getISO3Language()); + } + + return cacheKey.toString(); + } + + /** + * Use common base type {@link Stream} to handle {@link AudioStream} or {@link VideoStream} + * transparently. For more info see {@link #cacheKeyOf(StreamInfo, AudioStream)} or + * {@link #cacheKeyOf(StreamInfo, VideoStream)}. + * + * @param info the {@link StreamInfo stream info}, to distinguish between streams with + * the same features but coming from different stream infos + * @param stream the {@link Stream} ({@link AudioStream} or {@link VideoStream}) + * for which the cache key should be created + * @return a key to be used to store the cache of the provided {@link Stream} + */ + static String cacheKeyOf(final StreamInfo info, final Stream stream) { + if (stream instanceof AudioStream) { + return cacheKeyOf(info, (AudioStream) stream); + } else if (stream instanceof VideoStream) { + return cacheKeyOf(info, (VideoStream) stream); + } + throw new RuntimeException("no audio or video stream. That should never happen"); + } + //endregion + + + //region Live media sources @Nullable - default MediaSource maybeBuildLiveMediaSource(@NonNull final PlayerDataSource dataSource, - @NonNull final StreamInfo info) { - final StreamType streamType = info.getStreamType(); - if (!StreamTypeUtil.isLiveStream(streamType)) { + static MediaSource maybeBuildLiveMediaSource(final PlayerDataSource dataSource, + final StreamInfo info) { + if (!StreamTypeUtil.isLiveStream(info.getStreamType())) { return null; } - final StreamInfoTag tag = StreamInfoTag.of(info); - if (!info.getHlsUrl().isEmpty()) { - return buildLiveMediaSource(dataSource, info.getHlsUrl(), C.TYPE_HLS, tag); - } else if (!info.getDashMpdUrl().isEmpty()) { - return buildLiveMediaSource(dataSource, info.getDashMpdUrl(), C.TYPE_DASH, tag); + try { + final StreamInfoTag tag = StreamInfoTag.of(info); + if (!info.getHlsUrl().isEmpty()) { + return buildLiveMediaSource(dataSource, info.getHlsUrl(), C.CONTENT_TYPE_HLS, tag); + } else if (!info.getDashMpdUrl().isEmpty()) { + return buildLiveMediaSource( + dataSource, info.getDashMpdUrl(), C.CONTENT_TYPE_DASH, tag); + } + } catch (final Exception e) { + Log.w(TAG, "Error when generating live media source, falling back to standard sources", + e); } return null; } - @NonNull - default MediaSource buildLiveMediaSource(@NonNull final PlayerDataSource dataSource, - @NonNull final String sourceUrl, - @C.ContentType final int type, - @NonNull final MediaItemTag metadata) { + static MediaSource buildLiveMediaSource(final PlayerDataSource dataSource, + final String sourceUrl, + @C.ContentType final int type, + final MediaItemTag metadata) throws ResolverException { final MediaSource.Factory factory; switch (type) { - case C.TYPE_SS: + case C.CONTENT_TYPE_SS: factory = dataSource.getLiveSsMediaSourceFactory(); break; - case C.TYPE_DASH: + case C.CONTENT_TYPE_DASH: factory = dataSource.getLiveDashMediaSourceFactory(); break; - case C.TYPE_HLS: + case C.CONTENT_TYPE_HLS: factory = dataSource.getLiveHlsMediaSourceFactory(); break; + case C.CONTENT_TYPE_OTHER: + case C.CONTENT_TYPE_RTSP: default: - throw new IllegalStateException("Unsupported type: " + type); + throw new ResolverException("Unsupported type: " + type); } return factory.createMediaSource( @@ -67,46 +243,317 @@ public interface PlaybackResolver extends Resolver { .setLiveConfiguration( new MediaItem.LiveConfiguration.Builder() .setTargetOffsetMs(LIVE_STREAM_EDGE_GAP_MILLIS) - .build() - ) - .build() - ); + .build()) + .build()); } + //endregion - @NonNull - default MediaSource buildMediaSource(@NonNull final PlayerDataSource dataSource, - @NonNull final String sourceUrl, - @NonNull final String cacheKey, - @NonNull final String overrideExtension, - @NonNull final MediaItemTag metadata) { - final Uri uri = Uri.parse(sourceUrl); - @C.ContentType final int type = TextUtils.isEmpty(overrideExtension) - ? Util.inferContentType(uri) : Util.inferContentType("." + overrideExtension); - final MediaSource.Factory factory; - switch (type) { - case C.TYPE_SS: - factory = dataSource.getLiveSsMediaSourceFactory(); - break; - case C.TYPE_DASH: - factory = dataSource.getDashMediaSourceFactory(); - break; - case C.TYPE_HLS: - factory = dataSource.getHlsMediaSourceFactory(); - break; - case C.TYPE_OTHER: - factory = dataSource.getExtractorMediaSourceFactory(); - break; - default: - throw new IllegalStateException("Unsupported type: " + type); + //region Generic media sources + static MediaSource buildMediaSource(final PlayerDataSource dataSource, + final Stream stream, + final StreamInfo streamInfo, + final String cacheKey, + final MediaItemTag metadata) throws ResolverException { + if (streamInfo.getService() == ServiceList.YouTube) { + return createYoutubeMediaSource(stream, streamInfo, dataSource, cacheKey, metadata); } - return factory.createMediaSource( - new MediaItem.Builder() - .setTag(metadata) - .setUri(uri) - .setCustomCacheKey(cacheKey) - .build() - ); + final DeliveryMethod deliveryMethod = stream.getDeliveryMethod(); + switch (deliveryMethod) { + case PROGRESSIVE_HTTP: + return buildProgressiveMediaSource(dataSource, stream, cacheKey, metadata); + case DASH: + return buildDashMediaSource(dataSource, stream, cacheKey, metadata); + case HLS: + return buildHlsMediaSource(dataSource, stream, cacheKey, metadata); + case SS: + return buildSSMediaSource(dataSource, stream, cacheKey, metadata); + // Torrent streams are not supported by ExoPlayer + default: + throw new ResolverException("Unsupported delivery type: " + deliveryMethod); + } } + + private static ProgressiveMediaSource buildProgressiveMediaSource( + final PlayerDataSource dataSource, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) throws ResolverException { + if (!stream.isUrl()) { + throw new ResolverException("Non URI progressive contents are not supported"); + } + throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); + return dataSource.getProgressiveMediaSourceFactory().createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } + + private static DashMediaSource buildDashMediaSource(final PlayerDataSource dataSource, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) + throws ResolverException { + + if (stream.isUrl()) { + throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); + return dataSource.getDashMediaSourceFactory().createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } + + try { + return dataSource.getDashMediaSourceFactory().createMediaSource( + createDashManifest(stream.getContent(), stream), + new MediaItem.Builder() + .setTag(metadata) + .setUri(manifestUrlToUri(stream.getManifestUrl())) + .setCustomCacheKey(cacheKey) + .build()); + } catch (final IOException e) { + throw new ResolverException( + "Could not create a DASH media source/manifest from the manifest text", e); + } + } + + private static DashManifest createDashManifest(final String manifestContent, + final Stream stream) throws IOException { + return new DashManifestParser().parse(manifestUrlToUri(stream.getManifestUrl()), + new ByteArrayInputStream(manifestContent.getBytes(StandardCharsets.UTF_8))); + } + + private static HlsMediaSource buildHlsMediaSource(final PlayerDataSource dataSource, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) + throws ResolverException { + if (stream.isUrl()) { + throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); + return dataSource.getHlsMediaSourceFactory(null).createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } + + final NonUriHlsDataSourceFactory.Builder hlsDataSourceFactoryBuilder = + new NonUriHlsDataSourceFactory.Builder(); + hlsDataSourceFactoryBuilder.setPlaylistString(stream.getContent()); + + return dataSource.getHlsMediaSourceFactory(hlsDataSourceFactoryBuilder) + .createMediaSource(new MediaItem.Builder() + .setTag(metadata) + .setUri(manifestUrlToUri(stream.getManifestUrl())) + .setCustomCacheKey(cacheKey) + .build()); + } + + private static SsMediaSource buildSSMediaSource(final PlayerDataSource dataSource, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) + throws ResolverException { + if (stream.isUrl()) { + throwResolverExceptionIfUrlNullOrEmpty(stream.getContent()); + return dataSource.getSSMediaSourceFactory().createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } + + final Uri manifestUri = manifestUrlToUri(stream.getManifestUrl()); + + final SsManifest smoothStreamingManifest; + try { + final ByteArrayInputStream smoothStreamingManifestInput = new ByteArrayInputStream( + stream.getContent().getBytes(StandardCharsets.UTF_8)); + smoothStreamingManifest = new SsManifestParser().parse(manifestUri, + smoothStreamingManifestInput); + } catch (final IOException e) { + throw new ResolverException("Error when parsing manual SS manifest", e); + } + + return dataSource.getSSMediaSourceFactory().createMediaSource( + smoothStreamingManifest, + new MediaItem.Builder() + .setTag(metadata) + .setUri(manifestUri) + .setCustomCacheKey(cacheKey) + .build()); + } + //endregion + + + //region YouTube media sources + private static MediaSource createYoutubeMediaSource(final Stream stream, + final StreamInfo streamInfo, + final PlayerDataSource dataSource, + final String cacheKey, + final MediaItemTag metadata) + throws ResolverException { + if (!(stream instanceof AudioStream || stream instanceof VideoStream)) { + throw new ResolverException("Generation of YouTube DASH manifest for " + + stream.getClass().getSimpleName() + " is not supported"); + } + + final StreamType streamType = streamInfo.getStreamType(); + if (streamType == StreamType.VIDEO_STREAM) { + return createYoutubeMediaSourceOfVideoStreamType(dataSource, stream, streamInfo, + cacheKey, metadata); + } else if (streamType == StreamType.POST_LIVE_STREAM) { + // If the content is not an URL, uses the DASH delivery method and if the stream type + // of the stream is a post live stream, it means that the content is an ended + // livestream so we need to generate the manifest corresponding to the content + // (which is the last segment of the stream) + + try { + final ItagItem itagItem = Objects.requireNonNull(stream.getItagItem()); + final String manifestString = YoutubePostLiveStreamDvrDashManifestCreator + .fromPostLiveStreamDvrStreamingUrl(stream.getContent(), + itagItem, + itagItem.getTargetDurationSec(), + streamInfo.getDuration()); + return buildYoutubeManualDashMediaSource(dataSource, + createDashManifest(manifestString, stream), stream, cacheKey, + metadata); + } catch (final CreationException | IOException | NullPointerException e) { + throw new ResolverException( + "Error when generating the DASH manifest of YouTube ended live stream", e); + } + } else { + throw new ResolverException( + "DASH manifest generation of YouTube livestreams is not supported"); + } + } + + private static MediaSource createYoutubeMediaSourceOfVideoStreamType( + final PlayerDataSource dataSource, + final Stream stream, + final StreamInfo streamInfo, + final String cacheKey, + final MediaItemTag metadata) throws ResolverException { + final DeliveryMethod deliveryMethod = stream.getDeliveryMethod(); + switch (deliveryMethod) { + case PROGRESSIVE_HTTP: + if ((stream instanceof VideoStream && ((VideoStream) stream).isVideoOnly()) + || stream instanceof AudioStream) { + try { + final String manifestString = YoutubeProgressiveDashManifestCreator + .fromProgressiveStreamingUrl(stream.getContent(), + Objects.requireNonNull(stream.getItagItem()), + streamInfo.getDuration()); + return buildYoutubeManualDashMediaSource(dataSource, + createDashManifest(manifestString, stream), stream, cacheKey, + metadata); + } catch (final CreationException | IOException | NullPointerException e) { + Log.w(TAG, "Error when generating or parsing DASH manifest of " + + "YouTube progressive stream, falling back to a " + + "ProgressiveMediaSource.", e); + return buildYoutubeProgressiveMediaSource(dataSource, stream, cacheKey, + metadata); + } + } else { + // Legacy progressive streams, subtitles are handled by + // VideoPlaybackResolver + return buildYoutubeProgressiveMediaSource(dataSource, stream, cacheKey, + metadata); + } + case DASH: + // If the content is not a URL, uses the DASH delivery method and if the stream + // type of the stream is a video stream, it means the content is an OTF stream + // so we need to generate the manifest corresponding to the content (which is + // the base URL of the OTF stream). + + try { + final String manifestString = YoutubeOtfDashManifestCreator + .fromOtfStreamingUrl(stream.getContent(), + Objects.requireNonNull(stream.getItagItem()), + streamInfo.getDuration()); + return buildYoutubeManualDashMediaSource(dataSource, + createDashManifest(manifestString, stream), stream, cacheKey, + metadata); + } catch (final CreationException | IOException | NullPointerException e) { + Log.e(TAG, + "Error when generating the DASH manifest of YouTube OTF stream", e); + throw new ResolverException( + "Error when generating the DASH manifest of YouTube OTF stream", e); + } + case HLS: + return dataSource.getYoutubeHlsMediaSourceFactory().createMediaSource( + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + default: + throw new ResolverException("Unsupported delivery method for YouTube contents: " + + deliveryMethod); + } + } + + private static DashMediaSource buildYoutubeManualDashMediaSource( + final PlayerDataSource dataSource, + final DashManifest dashManifest, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) { + return dataSource.getYoutubeDashMediaSourceFactory().createMediaSource(dashManifest, + new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } + + private static ProgressiveMediaSource buildYoutubeProgressiveMediaSource( + final PlayerDataSource dataSource, + final Stream stream, + final String cacheKey, + final MediaItemTag metadata) { + return dataSource.getYoutubeProgressiveMediaSourceFactory() + .createMediaSource(new MediaItem.Builder() + .setTag(metadata) + .setUri(Uri.parse(stream.getContent())) + .setCustomCacheKey(cacheKey) + .build()); + } + //endregion + + + //region Utils + private static Uri manifestUrlToUri(final String manifestUrl) { + return Uri.parse(Objects.requireNonNullElse(manifestUrl, "")); + } + + private static void throwResolverExceptionIfUrlNullOrEmpty(@Nullable final String url) + throws ResolverException { + if (url == null) { + throw new ResolverException("Null stream URL"); + } else if (url.isEmpty()) { + throw new ResolverException("Empty stream URL"); + } + } + //endregion + + + //region Resolver exception + final class ResolverException extends Exception { + public ResolverException(final String message) { + super(message); + } + + public ResolverException(final String message, final Throwable cause) { + super(message, cause); + } + } + //endregion } diff --git a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java index 565f0b23e..670c13934 100644 --- a/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java +++ b/app/src/main/java/org/schabi/newpipe/player/resolver/VideoPlaybackResolver.java @@ -2,10 +2,12 @@ package org.schabi.newpipe.player.resolver; import android.content.Context; import android.net.Uri; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import com.google.android.exoplayer2.C; import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MergingMediaSource; @@ -26,8 +28,13 @@ import java.util.List; import java.util.Optional; import static com.google.android.exoplayer2.C.TIME_UNSET; +import static org.schabi.newpipe.util.ListHelper.getFilteredAudioStreams; +import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; +import static org.schabi.newpipe.util.ListHelper.getPlayableStreams; public class VideoPlaybackResolver implements PlaybackResolver { + private static final String TAG = VideoPlaybackResolver.class.getSimpleName(); + @NonNull private final Context context; @NonNull @@ -38,6 +45,8 @@ public class VideoPlaybackResolver implements PlaybackResolver { @Nullable private String playbackQuality; + @Nullable + private String audioTrack; public enum SourceType { LIVE_STREAM, @@ -56,7 +65,7 @@ public class VideoPlaybackResolver implements PlaybackResolver { @Override @Nullable public MediaSource resolve(@NonNull final StreamInfo info) { - final MediaSource liveSource = maybeBuildLiveMediaSource(dataSource, info); + final MediaSource liveSource = PlaybackResolver.maybeBuildLiveMediaSource(dataSource, info); if (liveSource != null) { streamSourceType = SourceType.LIVE_STREAM; return liveSource; @@ -65,40 +74,56 @@ public class VideoPlaybackResolver implements PlaybackResolver { final List mediaSources = new ArrayList<>(); // Create video stream source - final List videos = ListHelper.getSortedStreamVideosList(context, - info.getVideoStreams(), info.getVideoOnlyStreams(), false, true); - final int index; - if (videos.isEmpty()) { - index = -1; + final List videoStreamsList = ListHelper.getSortedStreamVideosList(context, + getPlayableStreams(info.getVideoStreams(), info.getServiceId()), + getPlayableStreams(info.getVideoOnlyStreams(), info.getServiceId()), false, true); + final List audioStreamsList = + getFilteredAudioStreams(context, info.getAudioStreams()); + + final int videoIndex; + if (videoStreamsList.isEmpty()) { + videoIndex = -1; } else if (playbackQuality == null) { - index = qualityResolver.getDefaultResolutionIndex(videos); + videoIndex = qualityResolver.getDefaultResolutionIndex(videoStreamsList); } else { - index = qualityResolver.getOverrideResolutionIndex(videos, getPlaybackQuality()); + videoIndex = qualityResolver.getOverrideResolutionIndex(videoStreamsList, + getPlaybackQuality()); } - final MediaItemTag tag = StreamInfoTag.of(info, videos, index); + + final int audioIndex = + ListHelper.getAudioFormatIndex(context, audioStreamsList, audioTrack); + final MediaItemTag tag = + StreamInfoTag.of(info, videoStreamsList, videoIndex, audioStreamsList, audioIndex); @Nullable final VideoStream video = tag.getMaybeQuality() .map(MediaItemTag.Quality::getSelectedVideoStream) .orElse(null); + @Nullable final AudioStream audio = tag.getMaybeAudioTrack() + .map(MediaItemTag.AudioTrack::getSelectedAudioStream) + .orElse(null); if (video != null) { - final MediaSource streamSource = buildMediaSource(dataSource, video.getUrl(), - PlayerHelper.cacheKeyOf(info, video), - MediaFormat.getSuffixById(video.getFormatId()), tag); - mediaSources.add(streamSource); + try { + final MediaSource streamSource = PlaybackResolver.buildMediaSource( + dataSource, video, info, PlaybackResolver.cacheKeyOf(info, video), tag); + mediaSources.add(streamSource); + } catch (final ResolverException e) { + Log.e(TAG, "Unable to create video source", e); + return null; + } } - // Create optional audio stream source - final List audioStreams = info.getAudioStreams(); - final AudioStream audio = audioStreams.isEmpty() ? null : audioStreams.get( - ListHelper.getDefaultAudioFormat(context, audioStreams)); // Use the audio stream if there is no video stream, or - // Merge with audio stream in case if video does not contain audio - if (audio != null && (video == null || video.isVideoOnly)) { - final MediaSource audioSource = buildMediaSource(dataSource, audio.getUrl(), - PlayerHelper.cacheKeyOf(info, audio), - MediaFormat.getSuffixById(audio.getFormatId()), tag); - mediaSources.add(audioSource); - streamSourceType = SourceType.VIDEO_WITH_SEPARATED_AUDIO; + // merge with audio stream in case if video does not contain audio + if (audio != null && (video == null || video.isVideoOnly() || audioTrack != null)) { + try { + final MediaSource audioSource = PlaybackResolver.buildMediaSource( + dataSource, audio, info, PlaybackResolver.cacheKeyOf(info, audio), tag); + mediaSources.add(audioSource); + streamSourceType = SourceType.VIDEO_WITH_SEPARATED_AUDIO; + } catch (final ResolverException e) { + Log.e(TAG, "Unable to create audio source", e); + return null; + } } else { streamSourceType = SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY; } @@ -107,32 +132,39 @@ public class VideoPlaybackResolver implements PlaybackResolver { if (mediaSources.isEmpty()) { return null; } + // Below are auxiliary media sources // Create subtitle sources - if (info.getSubtitles() != null) { - for (final SubtitlesStream subtitle : info.getSubtitles()) { - final String mimeType = PlayerHelper.subtitleMimeTypesOf(subtitle.getFormat()); - if (mimeType == null) { - continue; + final List subtitlesStreams = info.getSubtitles(); + if (subtitlesStreams != null) { + // Torrent and non URL subtitles are not supported by ExoPlayer + final List nonTorrentAndUrlStreams = getUrlAndNonTorrentStreams( + subtitlesStreams); + for (final SubtitlesStream subtitle : nonTorrentAndUrlStreams) { + final MediaFormat mediaFormat = subtitle.getFormat(); + if (mediaFormat != null) { + @C.RoleFlags final int textRoleFlag = subtitle.isAutoGenerated() + ? C.ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND + : C.ROLE_FLAG_CAPTION; + final MediaItem.SubtitleConfiguration textMediaItem = + new MediaItem.SubtitleConfiguration.Builder( + Uri.parse(subtitle.getContent())) + .setMimeType(mediaFormat.getMimeType()) + .setRoleFlags(textRoleFlag) + .setLanguage(PlayerHelper.captionLanguageOf(context, subtitle)) + .build(); + final MediaSource textSource = dataSource.getSingleSampleMediaSourceFactory() + .createMediaSource(textMediaItem, TIME_UNSET); + mediaSources.add(textSource); } - final MediaItem.SubtitleConfiguration textMediaItem = - new MediaItem.SubtitleConfiguration.Builder(Uri.parse(subtitle.getUrl())) - .setMimeType(mimeType) - .setLanguage(PlayerHelper.captionLanguageOf(context, subtitle)) - .build(); - final MediaSource textSource = dataSource - .getSampleMediaSourceFactory() - .createMediaSource(textMediaItem, TIME_UNSET); - mediaSources.add(textSource); } } if (mediaSources.size() == 1) { return mediaSources.get(0); } else { - return new MergingMediaSource(mediaSources.toArray( - new MediaSource[0])); + return new MergingMediaSource(true, mediaSources.toArray(new MediaSource[0])); } } @@ -155,6 +187,15 @@ public class VideoPlaybackResolver implements PlaybackResolver { this.playbackQuality = playbackQuality; } + @Nullable + public String getAudioTrack() { + return audioTrack; + } + + public void setAudioTrack(@Nullable final String audioLanguage) { + this.audioTrack = audioLanguage; + } + public interface QualityResolver { int getDefaultResolutionIndex(List sortedVideos); diff --git a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.java b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.java index 54d11da83..28856d606 100644 --- a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.java +++ b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.java @@ -8,14 +8,15 @@ import android.widget.ImageView; import androidx.annotation.IntDef; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.graphics.BitmapCompat; +import androidx.core.math.MathUtils; import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; import org.schabi.newpipe.util.DeviceUtils; import java.lang.annotation.Retention; -import java.util.Objects; -import java.util.Optional; import java.util.function.IntSupplier; import static java.lang.annotation.RetentionPolicy.SOURCE; @@ -65,44 +66,37 @@ public final class SeekbarPreviewThumbnailHelper { public static void tryResizeAndSetSeekbarPreviewThumbnail( @NonNull final Context context, - @NonNull final Optional optPreviewThumbnail, + @Nullable final Bitmap previewThumbnail, @NonNull final ImageView currentSeekbarPreviewThumbnail, @NonNull final IntSupplier baseViewWidthSupplier) { - - if (!optPreviewThumbnail.isPresent()) { + if (previewThumbnail == null) { currentSeekbarPreviewThumbnail.setVisibility(View.GONE); return; } currentSeekbarPreviewThumbnail.setVisibility(View.VISIBLE); - final Bitmap srcBitmap = optPreviewThumbnail.get(); // Resize original bitmap try { - Objects.requireNonNull(srcBitmap); - - final int srcWidth = srcBitmap.getWidth() > 0 ? srcBitmap.getWidth() : 1; - final int newWidth = Math.max( - Math.min( - // Use 1/4 of the width for the preview - Math.round(baseViewWidthSupplier.getAsInt() / 4f), - // Scaling more than that factor looks really pixelated -> max - Math.round(srcWidth * 2.5f) - ), - // Min width = 10dp - DeviceUtils.dpToPx(10, context) - ); + final int srcWidth = previewThumbnail.getWidth() > 0 ? previewThumbnail.getWidth() : 1; + final int newWidth = MathUtils.clamp( + // Use 1/4 of the width for the preview + Math.round(baseViewWidthSupplier.getAsInt() / 4f), + // But have a min width of 10dp + DeviceUtils.dpToPx(10, context), + // And scaling more than that factor looks really pixelated -> max + Math.round(srcWidth * 2.5f)); final float scaleFactor = (float) newWidth / srcWidth; - final int newHeight = (int) (srcBitmap.getHeight() * scaleFactor); + final int newHeight = (int) (previewThumbnail.getHeight() * scaleFactor); - currentSeekbarPreviewThumbnail.setImageBitmap( - Bitmap.createScaledBitmap(srcBitmap, newWidth, newHeight, true)); + currentSeekbarPreviewThumbnail.setImageBitmap(BitmapCompat + .createScaledBitmap(previewThumbnail, newWidth, newHeight, null, true)); } catch (final Exception ex) { Log.e(TAG, "Failed to resize and set seekbar preview thumbnail", ex); currentSeekbarPreviewThumbnail.setVisibility(View.GONE); } finally { - srcBitmap.recycle(); + previewThumbnail.recycle(); } } } diff --git a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java index 08c6366c8..26065de15 100644 --- a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java +++ b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.player.seekbarpreview; import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType; +import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.getSeekbarPreviewThumbnailType; import android.content.Context; import android.graphics.Bitmap; @@ -8,19 +9,17 @@ import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.collection.SparseArrayCompat; import com.google.common.base.Stopwatch; import org.schabi.newpipe.extractor.stream.Frameset; -import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.image.PicassoHelper; import java.util.Comparator; -import java.util.HashMap; import java.util.List; -import java.util.Map; import java.util.Optional; import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.function.Supplier; @@ -34,18 +33,15 @@ public class SeekbarPreviewThumbnailHolder { // Key = Position of the picture in milliseconds // Supplier = Supplies the bitmap for that position - private final Map> seekbarPreviewData = new ConcurrentHashMap<>(); + private final SparseArrayCompat> seekbarPreviewData = + new SparseArrayCompat<>(); // This ensures that if the reset is still undergoing // and another reset starts, only the last reset is processed private UUID currentUpdateRequestIdentifier = UUID.randomUUID(); - public synchronized void resetFrom( - @NonNull final Context context, - final List framesets) { - - final int seekbarPreviewType = - SeekbarPreviewThumbnailHelper.getSeekbarPreviewThumbnailType(context); + public void resetFrom(@NonNull final Context context, final List framesets) { + final int seekbarPreviewType = getSeekbarPreviewThumbnailType(context); final UUID updateRequestIdentifier = UUID.randomUUID(); this.currentUpdateRequestIdentifier = updateRequestIdentifier; @@ -63,13 +59,12 @@ public class SeekbarPreviewThumbnailHolder { executorService.shutdown(); } - private void resetFromAsync( - final int seekbarPreviewType, - final List framesets, - final UUID updateRequestIdentifier) { - + private void resetFromAsync(final int seekbarPreviewType, final List framesets, + final UUID updateRequestIdentifier) { Log.d(TAG, "Clearing seekbarPreviewData"); - seekbarPreviewData.clear(); + synchronized (seekbarPreviewData) { + seekbarPreviewData.clear(); + } if (seekbarPreviewType == SeekbarPreviewThumbnailType.NONE) { Log.d(TAG, "Not processing seekbarPreviewData due to settings"); @@ -94,10 +89,8 @@ public class SeekbarPreviewThumbnailHolder { generateDataFrom(frameset, updateRequestIdentifier); } - private Frameset getFrameSetForType( - final List framesets, - final int seekbarPreviewType) { - + private Frameset getFrameSetForType(final List framesets, + final int seekbarPreviewType) { if (seekbarPreviewType == SeekbarPreviewThumbnailType.HIGH_QUALITY) { Log.d(TAG, "Strategy for seekbarPreviewData: high quality"); return framesets.stream() @@ -111,17 +104,14 @@ public class SeekbarPreviewThumbnailHolder { } } - private void generateDataFrom( - final Frameset frameset, - final UUID updateRequestIdentifier) { - + private void generateDataFrom(final Frameset frameset, final UUID updateRequestIdentifier) { Log.d(TAG, "Starting generation of seekbarPreviewData"); final Stopwatch sw = Log.isLoggable(TAG, Log.DEBUG) ? Stopwatch.createStarted() : null; int currentPosMs = 0; int pos = 1; - final int frameCountPerUrl = frameset.getFramesPerPageX() * frameset.getFramesPerPageY(); + final int urlFrameCount = frameset.getFramesPerPageX() * frameset.getFramesPerPageY(); // Process each url in the frameset for (final String url : frameset.getUrls()) { @@ -130,11 +120,11 @@ public class SeekbarPreviewThumbnailHolder { // The data is not added directly to "seekbarPreviewData" due to // concurrency and checks for "updateRequestIdentifier" - final Map> generatedDataForUrl = new HashMap<>(); + final var generatedDataForUrl = new SparseArrayCompat>(urlFrameCount); // The bitmap consists of several images, which we process here // foreach frame in the returned bitmap - for (int i = 0; i < frameCountPerUrl; i++) { + for (int i = 0; i < urlFrameCount; i++) { // Frames outside the video length are skipped if (pos > frameset.getTotalCount()) { break; @@ -161,7 +151,9 @@ public class SeekbarPreviewThumbnailHolder { // Check if we are still the latest request // If not abort method execution if (isRequestIdentifierCurrent(updateRequestIdentifier)) { - seekbarPreviewData.putAll(generatedDataForUrl); + synchronized (seekbarPreviewData) { + seekbarPreviewData.putAll(generatedDataForUrl); + } } else { Log.d(TAG, "Aborted of generation of seekbarPreviewData"); break; @@ -169,7 +161,7 @@ public class SeekbarPreviewThumbnailHolder { } if (sw != null) { - Log.d(TAG, "Generation of seekbarPreviewData took " + sw.stop().toString()); + Log.d(TAG, "Generation of seekbarPreviewData took " + sw.stop()); } } @@ -189,17 +181,14 @@ public class SeekbarPreviewThumbnailHolder { final Bitmap bitmap = PicassoHelper.loadSeekbarThumbnailPreview(url).get(); if (sw != null) { - Log.d(TAG, - "Download of bitmap for seekbarPreview from '" + url - + "' took " + sw.stop().toString()); + Log.d(TAG, "Download of bitmap for seekbarPreview from '" + url + "' took " + + sw.stop()); } return bitmap; } catch (final Exception ex) { - Log.w(TAG, - "Failed to get bitmap for seekbarPreview from url='" + url - + "' in time", - ex); + Log.w(TAG, "Failed to get bitmap for seekbarPreview from url='" + url + + "' in time", ex); return null; } } @@ -208,32 +197,20 @@ public class SeekbarPreviewThumbnailHolder { return this.currentUpdateRequestIdentifier.equals(requestIdentifier); } - public Optional getBitmapAt(final int positionInMs) { - // Check if the BitmapData is empty - if (seekbarPreviewData.isEmpty()) { - return Optional.empty(); + // Get the frame supplier closest to the requested position + Supplier closestFrame = () -> null; + synchronized (seekbarPreviewData) { + int min = Integer.MAX_VALUE; + for (int i = 0; i < seekbarPreviewData.size(); i++) { + final int pos = Math.abs(seekbarPreviewData.keyAt(i) - positionInMs); + if (pos < min) { + closestFrame = seekbarPreviewData.valueAt(i); + min = pos; + } + } } - // Get the closest frame to the requested position - final int closestIndexPosition = - seekbarPreviewData.keySet().stream() - .min(Comparator.comparingInt(i -> Math.abs(i - positionInMs))) - .orElse(-1); - - // this should never happen, because - // it indicates that "seekbarPreviewData" is empty which was already checked - if (closestIndexPosition == -1) { - return Optional.empty(); - } - - try { - // Get the bitmap for the position (executes the supplier) - return Optional.ofNullable(seekbarPreviewData.get(closestIndexPosition).get()); - } catch (final Exception ex) { - // If there is an error, log it and return Optional.empty - Log.w(TAG, "Unable to get seekbar preview", ex); - return Optional.empty(); - } + return Optional.ofNullable(closestFrame.get()); } } diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java new file mode 100644 index 000000000..03f90a344 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java @@ -0,0 +1,987 @@ +package org.schabi.newpipe.player.ui; + +import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; +import static org.schabi.newpipe.MainActivity.DEBUG; +import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu; +import static org.schabi.newpipe.extractor.ServiceList.YouTube; +import static org.schabi.newpipe.ktx.ViewUtils.animate; +import static org.schabi.newpipe.player.Player.STATE_COMPLETED; +import static org.schabi.newpipe.player.Player.STATE_PAUSED; +import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_BACKGROUND; +import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_NONE; +import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_POPUP; +import static org.schabi.newpipe.player.helper.PlayerHelper.getMinimizeOnExitAction; +import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; +import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientationLocked; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.database.ContentObserver; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.os.Handler; +import android.os.Looper; +import android.provider.Settings; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.TypedValue; +import android.view.KeyEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.WindowManager; +import android.widget.FrameLayout; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AppCompatActivity; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.fragment.app.FragmentActivity; +import androidx.recyclerview.widget.ItemTouchHelper; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.exoplayer2.video.VideoSize; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.PlayerBinding; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamSegment; +import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; +import org.schabi.newpipe.fragments.detail.VideoDetailFragment; +import org.schabi.newpipe.info_list.StreamSegmentAdapter; +import org.schabi.newpipe.info_list.StreamSegmentItem; +import org.schabi.newpipe.ktx.AnimationType; +import org.schabi.newpipe.local.dialog.PlaylistDialog; +import org.schabi.newpipe.player.Player; +import org.schabi.newpipe.player.event.PlayerServiceEventListener; +import org.schabi.newpipe.player.gesture.BasePlayerGestureListener; +import org.schabi.newpipe.player.gesture.MainPlayerGestureListener; +import org.schabi.newpipe.player.helper.PlaybackParameterDialog; +import org.schabi.newpipe.player.helper.PlayerHelper; +import org.schabi.newpipe.player.mediaitem.MediaItemTag; +import org.schabi.newpipe.player.playqueue.PlayQueue; +import org.schabi.newpipe.player.playqueue.PlayQueueAdapter; +import org.schabi.newpipe.player.playqueue.PlayQueueItem; +import org.schabi.newpipe.player.playqueue.PlayQueueItemBuilder; +import org.schabi.newpipe.player.playqueue.PlayQueueItemHolder; +import org.schabi.newpipe.player.playqueue.PlayQueueItemTouchCallback; +import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.external_communication.KoreUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.Optional; + +public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutChangeListener { + private static final String TAG = MainPlayerUi.class.getSimpleName(); + + // see the Javadoc of calculateMaxEndScreenThumbnailHeight for information + private static final int DETAIL_ROOT_MINIMUM_HEIGHT = 85; // dp + private static final int DETAIL_TITLE_TEXT_SIZE_TV = 16; // sp + private static final int DETAIL_TITLE_TEXT_SIZE_TABLET = 15; // sp + + private boolean isFullscreen = false; + private boolean isVerticalVideo = false; + private boolean fragmentIsVisible = false; + + private ContentObserver settingsContentObserver; + + private PlayQueueAdapter playQueueAdapter; + private StreamSegmentAdapter segmentAdapter; + private boolean isQueueVisible = false; + private boolean areSegmentsVisible = false; + + // fullscreen player + private ItemTouchHelper itemTouchHelper; + + + /*////////////////////////////////////////////////////////////////////////// + // Constructor, setup, destroy + //////////////////////////////////////////////////////////////////////////*/ + //region Constructor, setup, destroy + + public MainPlayerUi(@NonNull final Player player, + @NonNull final PlayerBinding playerBinding) { + super(player, playerBinding); + } + + /** + * Open fullscreen on tablets where the option to have the main player start automatically in + * fullscreen mode is on. Rotating the device to landscape is already done in {@link + * VideoDetailFragment#openVideoPlayer(boolean)} when the thumbnail is clicked, and that's + * enough for phones, but not for tablets since the mini player can be also shown in landscape. + */ + private void directlyOpenFullscreenIfNeeded() { + if (PlayerHelper.isStartMainPlayerFullscreenEnabled(player.getService()) + && DeviceUtils.isTablet(player.getService()) + && PlayerHelper.globalScreenOrientationLocked(player.getService())) { + player.getFragmentListener().ifPresent( + PlayerServiceEventListener::onScreenRotationButtonClicked); + } + } + + @Override + public void setupAfterIntent() { + // needed for tablets, check the function for a better explanation + directlyOpenFullscreenIfNeeded(); + + super.setupAfterIntent(); + + initVideoPlayer(); + // Android TV: without it focus will frame the whole player + binding.playPauseButton.requestFocus(); + + // Note: This is for automatically playing (when "Resume playback" is off), see #6179 + if (player.getPlayWhenReady()) { + player.play(); + } else { + player.pause(); + } + } + + @Override + BasePlayerGestureListener buildGestureListener() { + return new MainPlayerGestureListener(this); + } + + @Override + protected void initListeners() { + super.initListeners(); + + binding.screenRotationButton.setOnClickListener(makeOnClickListener(() -> { + // Only if it's not a vertical video or vertical video but in landscape with locked + // orientation a screen orientation can be changed automatically + if (!isVerticalVideo || (isLandscape() && globalScreenOrientationLocked(context))) { + player.getFragmentListener() + .ifPresent(PlayerServiceEventListener::onScreenRotationButtonClicked); + } else { + toggleFullscreen(); + } + })); + binding.queueButton.setOnClickListener(v -> onQueueClicked()); + binding.segmentsButton.setOnClickListener(v -> onSegmentsClicked()); + + binding.addToPlaylistButton.setOnClickListener(v -> + getParentActivity().map(FragmentActivity::getSupportFragmentManager) + .ifPresent(fragmentManager -> + PlaylistDialog.showForPlayQueue(player, fragmentManager))); + + settingsContentObserver = new ContentObserver(new Handler(Looper.getMainLooper())) { + @Override + public void onChange(final boolean selfChange) { + setupScreenRotationButton(); + } + }; + context.getContentResolver().registerContentObserver( + Settings.System.getUriFor(Settings.System.ACCELEROMETER_ROTATION), false, + settingsContentObserver); + + binding.getRoot().addOnLayoutChangeListener(this); + + binding.moreOptionsButton.setOnLongClickListener(v -> { + player.getFragmentListener() + .ifPresent(PlayerServiceEventListener::onMoreOptionsLongClicked); + hideControls(0, 0); + hideSystemUIIfNeeded(); + return true; + }); + } + + @Override + protected void deinitListeners() { + super.deinitListeners(); + + binding.queueButton.setOnClickListener(null); + binding.segmentsButton.setOnClickListener(null); + binding.addToPlaylistButton.setOnClickListener(null); + + context.getContentResolver().unregisterContentObserver(settingsContentObserver); + + binding.getRoot().removeOnLayoutChangeListener(this); + } + + @Override + public void initPlayback() { + super.initPlayback(); + + if (playQueueAdapter != null) { + playQueueAdapter.dispose(); + } + playQueueAdapter = new PlayQueueAdapter(context, + Objects.requireNonNull(player.getPlayQueue())); + segmentAdapter = new StreamSegmentAdapter(getStreamSegmentListener()); + } + + @Override + public void removeViewFromParent() { + // view was added to fragment + final ViewParent parent = binding.getRoot().getParent(); + if (parent instanceof ViewGroup) { + ((ViewGroup) parent).removeView(binding.getRoot()); + } + } + + @Override + public void destroy() { + super.destroy(); + + // Exit from fullscreen when user closes the player via notification + if (isFullscreen) { + toggleFullscreen(); + } + + removeViewFromParent(); + } + + @Override + public void destroyPlayer() { + super.destroyPlayer(); + + if (playQueueAdapter != null) { + playQueueAdapter.unsetSelectedListener(); + playQueueAdapter.dispose(); + } + } + + @Override + public void smoothStopForImmediateReusing() { + super.smoothStopForImmediateReusing(); + // Android TV will handle back button in case controls will be visible + // (one more additional unneeded click while the player is hidden) + hideControls(0, 0); + closeItemsList(); + } + + private void initVideoPlayer() { + // restore last resize mode + setResizeMode(PlayerHelper.retrieveResizeModeFromPrefs(player)); + binding.getRoot().setLayoutParams(new FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)); + } + + @Override + protected void setupElementsVisibility() { + super.setupElementsVisibility(); + + closeItemsList(); + showHideKodiButton(); + binding.fullScreenButton.setVisibility(View.GONE); + setupScreenRotationButton(); + binding.resizeTextView.setVisibility(View.VISIBLE); + binding.getRoot().findViewById(R.id.metadataView).setVisibility(View.VISIBLE); + binding.moreOptionsButton.setVisibility(View.VISIBLE); + binding.topControls.setOrientation(LinearLayout.VERTICAL); + binding.primaryControls.getLayoutParams().width = MATCH_PARENT; + binding.secondaryControls.setVisibility(View.INVISIBLE); + binding.moreOptionsButton.setImageDrawable(AppCompatResources.getDrawable(context, + R.drawable.ic_expand_more)); + binding.share.setVisibility(View.VISIBLE); + binding.openInBrowser.setVisibility(View.VISIBLE); + binding.switchMute.setVisibility(View.VISIBLE); + binding.playerCloseButton.setVisibility(isFullscreen ? View.GONE : View.VISIBLE); + // Top controls have a large minHeight which is allows to drag the player + // down in fullscreen mode (just larger area to make easy to locate by finger) + binding.topControls.setClickable(true); + binding.topControls.setFocusable(true); + + binding.titleTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE); + binding.channelTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE); + } + + @Override + protected void setupElementsSize(final Resources resources) { + setupElementsSize( + resources.getDimensionPixelSize(R.dimen.player_main_buttons_min_width), + resources.getDimensionPixelSize(R.dimen.player_main_top_padding), + resources.getDimensionPixelSize(R.dimen.player_main_controls_padding), + resources.getDimensionPixelSize(R.dimen.player_main_buttons_padding) + ); + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Broadcast receiver + //////////////////////////////////////////////////////////////////////////*/ + //region Broadcast receiver + + @Override + public void onBroadcastReceived(final Intent intent) { + super.onBroadcastReceived(intent); + if (Intent.ACTION_CONFIGURATION_CHANGED.equals(intent.getAction())) { + // Close it because when changing orientation from portrait + // (in fullscreen mode) the size of queue layout can be larger than the screen size + closeItemsList(); + } else if (ACTION_PLAY_PAUSE.equals(intent.getAction())) { + // Ensure that we have audio-only stream playing when a user + // started to play from notification's play button from outside of the app + if (!fragmentIsVisible) { + onFragmentStopped(); + } + } else if (VideoDetailFragment.ACTION_VIDEO_FRAGMENT_STOPPED.equals(intent.getAction())) { + fragmentIsVisible = false; + onFragmentStopped(); + } else if (VideoDetailFragment.ACTION_VIDEO_FRAGMENT_RESUMED.equals(intent.getAction())) { + // Restore video source when user returns to the fragment + fragmentIsVisible = true; + player.useVideoSource(true); + + // When a user returns from background, the system UI will always be shown even if + // controls are invisible: hide it in that case + if (!isControlsVisible()) { + hideSystemUIIfNeeded(); + } + } + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Fragment binding + //////////////////////////////////////////////////////////////////////////*/ + //region Fragment binding + + @Override + public void onFragmentListenerSet() { + super.onFragmentListenerSet(); + fragmentIsVisible = true; + // Apply window insets because Android will not do it when orientation changes + // from landscape to portrait + if (!isFullscreen) { + binding.playbackControlRoot.setPadding(0, 0, 0, 0); + } + binding.itemsListPanel.setPadding(0, 0, 0, 0); + player.getFragmentListener().ifPresent(PlayerServiceEventListener::onViewCreated); + } + + /** + * This will be called when a user goes to another app/activity, turns off a screen. + * We don't want to interrupt playback and don't want to see notification so + * next lines of code will enable audio-only playback only if needed + */ + private void onFragmentStopped() { + if (player.isPlaying() || player.isLoading()) { + switch (getMinimizeOnExitAction(context)) { + case MINIMIZE_ON_EXIT_MODE_BACKGROUND: + player.useVideoSource(false); + break; + case MINIMIZE_ON_EXIT_MODE_POPUP: + getParentActivity().ifPresent(activity -> { + player.setRecovery(); + NavigationHelper.playOnPopupPlayer(activity, player.getPlayQueue(), true); + }); + break; + case MINIMIZE_ON_EXIT_MODE_NONE: default: + player.pause(); + break; + } + } + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Playback states + //////////////////////////////////////////////////////////////////////////*/ + //region Playback states + + @Override + public void onUpdateProgress(final int currentProgress, + final int duration, + final int bufferPercent) { + super.onUpdateProgress(currentProgress, duration, bufferPercent); + + if (areSegmentsVisible) { + segmentAdapter.selectSegmentAt(getNearestStreamSegmentPosition(currentProgress)); + } + if (isQueueVisible) { + updateQueueTime(currentProgress); + } + } + + @Override + public void onPlaying() { + super.onPlaying(); + checkLandscape(); + } + + @Override + public void onCompleted() { + super.onCompleted(); + if (isFullscreen) { + toggleFullscreen(); + } + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Controls showing / hiding + //////////////////////////////////////////////////////////////////////////*/ + //region Controls showing / hiding + + @Override + protected void showOrHideButtons() { + super.showOrHideButtons(); + @Nullable final PlayQueue playQueue = player.getPlayQueue(); + if (playQueue == null) { + return; + } + + final boolean showQueue = !playQueue.getStreams().isEmpty(); + final boolean showSegment = !player.getCurrentStreamInfo() + .map(StreamInfo::getStreamSegments) + .map(List::isEmpty) + .orElse(/*no stream info=*/true); + + binding.queueButton.setVisibility(showQueue ? View.VISIBLE : View.GONE); + binding.queueButton.setAlpha(showQueue ? 1.0f : 0.0f); + binding.segmentsButton.setVisibility(showSegment ? View.VISIBLE : View.GONE); + binding.segmentsButton.setAlpha(showSegment ? 1.0f : 0.0f); + } + + @Override + public void showSystemUIPartially() { + if (isFullscreen) { + getParentActivity().map(Activity::getWindow).ifPresent(window -> { + window.setStatusBarColor(Color.TRANSPARENT); + window.setNavigationBarColor(Color.TRANSPARENT); + final int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION; + window.getDecorView().setSystemUiVisibility(visibility); + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + }); + } + } + + @Override + public void hideSystemUIIfNeeded() { + player.getFragmentListener().ifPresent(PlayerServiceEventListener::hideSystemUiIfNeeded); + } + + /** + * Calculate the maximum allowed height for the {@link R.id.endScreen} + * to prevent it from enlarging the player. + *

+ * The calculating follows these rules: + *

    + *
  • + * Show at least stream title and content creator on TVs and tablets when in landscape + * (always the case for TVs) and not in fullscreen mode. This requires to have at least + * {@link #DETAIL_ROOT_MINIMUM_HEIGHT} free space for {@link R.id.detail_root} and + * additional space for the stream title text size ({@link R.id.detail_title_root_layout}). + * The text size is {@link #DETAIL_TITLE_TEXT_SIZE_TABLET} on tablets and + * {@link #DETAIL_TITLE_TEXT_SIZE_TV} on TVs, see {@link R.id.titleTextView}. + *
  • + *
  • + * Otherwise, the max thumbnail height is the screen height. + *
  • + *
+ * + * @param bitmap the bitmap that needs to be resized to fit the end screen + * @return the maximum height for the end screen thumbnail + */ + @Override + protected float calculateMaxEndScreenThumbnailHeight(@NonNull final Bitmap bitmap) { + final int screenHeight = context.getResources().getDisplayMetrics().heightPixels; + + if (DeviceUtils.isTv(context) && !isFullscreen()) { + final int videoInfoHeight = DeviceUtils.dpToPx(DETAIL_ROOT_MINIMUM_HEIGHT, context) + + DeviceUtils.spToPx(DETAIL_TITLE_TEXT_SIZE_TV, context); + return Math.min(bitmap.getHeight(), screenHeight - videoInfoHeight); + } else if (DeviceUtils.isTablet(context) && isLandscape() && !isFullscreen()) { + final int videoInfoHeight = DeviceUtils.dpToPx(DETAIL_ROOT_MINIMUM_HEIGHT, context) + + DeviceUtils.spToPx(DETAIL_TITLE_TEXT_SIZE_TABLET, context); + return Math.min(bitmap.getHeight(), screenHeight - videoInfoHeight); + } else { // fullscreen player: max height is the device height + return Math.min(bitmap.getHeight(), screenHeight); + } + } + + private void showHideKodiButton() { + // show kodi button if it supports the current service and it is enabled in settings + @Nullable final PlayQueue playQueue = player.getPlayQueue(); + binding.playWithKodi.setVisibility(playQueue != null && playQueue.getItem() != null + && KoreUtils.shouldShowPlayWithKodi(context, playQueue.getItem().getServiceId()) + ? View.VISIBLE : View.GONE); + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Captions (text tracks) + //////////////////////////////////////////////////////////////////////////*/ + //region Captions (text tracks) + + @Override + protected void setupSubtitleView(final float captionScale) { + final DisplayMetrics metrics = context.getResources().getDisplayMetrics(); + final int minimumLength = Math.min(metrics.heightPixels, metrics.widthPixels); + final float captionRatioInverse = 20f + 4f * (1.0f - captionScale); + binding.subtitleView.setFixedTextSize( + TypedValue.COMPLEX_UNIT_PX, minimumLength / captionRatioInverse); + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Gestures + //////////////////////////////////////////////////////////////////////////*/ + //region Gestures + + @SuppressWarnings("checkstyle:ParameterNumber") + @Override + public void onLayoutChange(final View view, final int l, final int t, final int r, final int b, + final int ol, final int ot, final int or, final int ob) { + if (l != ol || t != ot || r != or || b != ob) { + // Use a smaller value to be consistent across screen orientations, and to make usage + // easier. Multiply by 3/4 to ensure the user does not need to move the finger up to the + // screen border, in order to reach the maximum volume/brightness. + final int width = r - l; + final int height = b - t; + final int min = Math.min(width, height); + final int maxGestureLength = (int) (min * 0.75); + + if (DEBUG) { + Log.d(TAG, "maxGestureLength = " + maxGestureLength); + } + + binding.volumeProgressBar.setMax(maxGestureLength); + binding.brightnessProgressBar.setMax(maxGestureLength); + + setInitialGestureValues(); + binding.itemsListPanel.getLayoutParams().height = + height - binding.itemsListPanel.getTop(); + } + } + + private void setInitialGestureValues() { + if (player.getAudioReactor() != null) { + final float currentVolumeNormalized = (float) player.getAudioReactor().getVolume() + / player.getAudioReactor().getMaxVolume(); + binding.volumeProgressBar.setProgress( + (int) (binding.volumeProgressBar.getMax() * currentVolumeNormalized)); + } + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Play queue, segments and streams + //////////////////////////////////////////////////////////////////////////*/ + //region Play queue, segments and streams + + @Override + public void onMetadataChanged(@NonNull final StreamInfo info) { + super.onMetadataChanged(info); + showHideKodiButton(); + if (areSegmentsVisible) { + if (segmentAdapter.setItems(info)) { + final int adapterPosition = getNearestStreamSegmentPosition( + player.getExoPlayer().getCurrentPosition()); + segmentAdapter.selectSegmentAt(adapterPosition); + binding.itemsList.scrollToPosition(adapterPosition); + } else { + closeItemsList(); + } + } + } + + @Override + public void onPlayQueueEdited() { + super.onPlayQueueEdited(); + showOrHideButtons(); + } + + private void onQueueClicked() { + isQueueVisible = true; + + hideSystemUIIfNeeded(); + buildQueue(); + + binding.itemsListHeaderTitle.setVisibility(View.GONE); + binding.itemsListHeaderDuration.setVisibility(View.VISIBLE); + binding.shuffleButton.setVisibility(View.VISIBLE); + binding.repeatButton.setVisibility(View.VISIBLE); + binding.addToPlaylistButton.setVisibility(View.VISIBLE); + + hideControls(0, 0); + binding.itemsListPanel.requestFocus(); + animate(binding.itemsListPanel, true, DEFAULT_CONTROLS_DURATION, + AnimationType.SLIDE_AND_ALPHA); + + @Nullable final PlayQueue playQueue = player.getPlayQueue(); + if (playQueue != null) { + binding.itemsList.scrollToPosition(playQueue.getIndex()); + } + + updateQueueTime((int) player.getExoPlayer().getCurrentPosition()); + } + + private void buildQueue() { + binding.itemsList.setAdapter(playQueueAdapter); + binding.itemsList.setClickable(true); + binding.itemsList.setLongClickable(true); + + binding.itemsList.clearOnScrollListeners(); + binding.itemsList.addOnScrollListener(getQueueScrollListener()); + + itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); + itemTouchHelper.attachToRecyclerView(binding.itemsList); + + playQueueAdapter.setSelectedListener(getOnSelectedListener()); + + binding.itemsListClose.setOnClickListener(view -> closeItemsList()); + } + + private void onSegmentsClicked() { + areSegmentsVisible = true; + + hideSystemUIIfNeeded(); + buildSegments(); + + binding.itemsListHeaderTitle.setVisibility(View.VISIBLE); + binding.itemsListHeaderDuration.setVisibility(View.GONE); + binding.shuffleButton.setVisibility(View.GONE); + binding.repeatButton.setVisibility(View.GONE); + binding.addToPlaylistButton.setVisibility(View.GONE); + + hideControls(0, 0); + binding.itemsListPanel.requestFocus(); + animate(binding.itemsListPanel, true, DEFAULT_CONTROLS_DURATION, + AnimationType.SLIDE_AND_ALPHA); + + final int adapterPosition = getNearestStreamSegmentPosition( + player.getExoPlayer().getCurrentPosition()); + segmentAdapter.selectSegmentAt(adapterPosition); + binding.itemsList.scrollToPosition(adapterPosition); + } + + private void buildSegments() { + binding.itemsList.setAdapter(segmentAdapter); + binding.itemsList.setClickable(true); + binding.itemsList.setLongClickable(true); + + binding.itemsList.clearOnScrollListeners(); + if (itemTouchHelper != null) { + itemTouchHelper.attachToRecyclerView(null); + } + + player.getCurrentStreamInfo().ifPresent(segmentAdapter::setItems); + + binding.shuffleButton.setVisibility(View.GONE); + binding.repeatButton.setVisibility(View.GONE); + binding.addToPlaylistButton.setVisibility(View.GONE); + binding.itemsListClose.setOnClickListener(view -> closeItemsList()); + } + + public void closeItemsList() { + if (isQueueVisible || areSegmentsVisible) { + isQueueVisible = false; + areSegmentsVisible = false; + + if (itemTouchHelper != null) { + itemTouchHelper.attachToRecyclerView(null); + } + + animate(binding.itemsListPanel, false, DEFAULT_CONTROLS_DURATION, + AnimationType.SLIDE_AND_ALPHA, 0, () -> + // Even when queueLayout is GONE it receives touch events + // and ruins normal behavior of the app. This line fixes it + binding.itemsListPanel.setTranslationY( + -binding.itemsListPanel.getHeight() * 5.0f)); + + // clear focus, otherwise a white rectangle remains on top of the player + binding.itemsListClose.clearFocus(); + binding.playPauseButton.requestFocus(); + } + } + + private OnScrollBelowItemsListener getQueueScrollListener() { + return new OnScrollBelowItemsListener() { + @Override + public void onScrolledDown(final RecyclerView recyclerView) { + @Nullable final PlayQueue playQueue = player.getPlayQueue(); + if (playQueue != null && !playQueue.isComplete()) { + playQueue.fetch(); + } else if (binding != null) { + binding.itemsList.clearOnScrollListeners(); + } + } + }; + } + + private StreamSegmentAdapter.StreamSegmentListener getStreamSegmentListener() { + return new StreamSegmentAdapter.StreamSegmentListener() { + @Override + public void onItemClick(@NonNull final StreamSegmentItem item, final int seconds) { + segmentAdapter.selectSegment(item); + player.seekTo(seconds * 1000L); + player.triggerProgressUpdate(); + } + + @Override + public void onItemLongClick(@NonNull final StreamSegmentItem item, final int seconds) { + @Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata(); + if (currentMetadata == null + || currentMetadata.getServiceId() != YouTube.getServiceId()) { + return; + } + + final PlayQueueItem currentItem = player.getCurrentItem(); + if (currentItem != null) { + String videoUrl = player.getVideoUrl(); + videoUrl += ("&t=" + seconds); + ShareUtils.shareText(context, currentItem.getTitle(), + videoUrl, currentItem.getThumbnails()); + } + } + }; + } + + private int getNearestStreamSegmentPosition(final long playbackPosition) { + int nearestPosition = 0; + final List segments = player.getCurrentStreamInfo() + .map(StreamInfo::getStreamSegments) + .orElse(Collections.emptyList()); + + for (int i = 0; i < segments.size(); i++) { + if (segments.get(i).getStartTimeSeconds() * 1000L > playbackPosition) { + break; + } + nearestPosition++; + } + return Math.max(0, nearestPosition - 1); + } + + private ItemTouchHelper.SimpleCallback getItemTouchCallback() { + return new PlayQueueItemTouchCallback() { + @Override + public void onMove(final int sourceIndex, final int targetIndex) { + @Nullable final PlayQueue playQueue = player.getPlayQueue(); + if (playQueue != null) { + playQueue.move(sourceIndex, targetIndex); + } + } + + @Override + public void onSwiped(final int index) { + @Nullable final PlayQueue playQueue = player.getPlayQueue(); + if (playQueue != null && index != -1) { + playQueue.remove(index); + } + } + }; + } + + private PlayQueueItemBuilder.OnSelectedListener getOnSelectedListener() { + return new PlayQueueItemBuilder.OnSelectedListener() { + @Override + public void selected(final PlayQueueItem item, final View view) { + player.selectQueueItem(item); + } + + @Override + public void held(final PlayQueueItem item, final View view) { + @Nullable final PlayQueue playQueue = player.getPlayQueue(); + @Nullable final AppCompatActivity parentActivity = getParentActivity().orElse(null); + if (playQueue != null && parentActivity != null && playQueue.indexOf(item) != -1) { + openPopupMenu(player.getPlayQueue(), item, view, true, + parentActivity.getSupportFragmentManager(), context); + } + } + + @Override + public void onStartDrag(final PlayQueueItemHolder viewHolder) { + if (itemTouchHelper != null) { + itemTouchHelper.startDrag(viewHolder); + } + } + }; + } + + private void updateQueueTime(final int currentTime) { + @Nullable final PlayQueue playQueue = player.getPlayQueue(); + if (playQueue == null) { + return; + } + + final int currentStream = playQueue.getIndex(); + int before = 0; + int after = 0; + + final List streams = playQueue.getStreams(); + final int nStreams = streams.size(); + + for (int i = 0; i < nStreams; i++) { + if (i < currentStream) { + before += streams.get(i).getDuration(); + } else { + after += streams.get(i).getDuration(); + } + } + + before *= 1000; + after *= 1000; + + binding.itemsListHeaderDuration.setText( + String.format("%s/%s", + getTimeString(currentTime + before), + getTimeString(before + after) + )); + } + + @Override + protected boolean isAnyListViewOpen() { + return isQueueVisible || areSegmentsVisible; + } + + @Override + public boolean isFullscreen() { + return isFullscreen; + } + + public boolean isVerticalVideo() { + return isVerticalVideo; + } + + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Click listeners + //////////////////////////////////////////////////////////////////////////*/ + //region Click listeners + + @Override + protected void onPlaybackSpeedClicked() { + getParentActivity().ifPresent(activity -> + PlaybackParameterDialog.newInstance(player.getPlaybackSpeed(), + player.getPlaybackPitch(), player.getPlaybackSkipSilence(), + player::setPlaybackParameters) + .show(activity.getSupportFragmentManager(), null)); + } + + @Override + public boolean onKeyDown(final int keyCode) { + if (keyCode == KeyEvent.KEYCODE_SPACE && isFullscreen) { + player.playPause(); + if (player.isPlaying()) { + hideControls(0, 0); + } + return true; + } + return super.onKeyDown(keyCode); + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Video size, orientation, fullscreen + //////////////////////////////////////////////////////////////////////////*/ + //region Video size, orientation, fullscreen + + private void setupScreenRotationButton() { + binding.screenRotationButton.setVisibility(globalScreenOrientationLocked(context) + || isVerticalVideo || DeviceUtils.isTablet(context) + ? View.VISIBLE : View.GONE); + binding.screenRotationButton.setImageDrawable(AppCompatResources.getDrawable(context, + isFullscreen ? R.drawable.ic_fullscreen_exit + : R.drawable.ic_fullscreen)); + } + + @Override + public void onVideoSizeChanged(@NonNull final VideoSize videoSize) { + super.onVideoSizeChanged(videoSize); + isVerticalVideo = videoSize.width < videoSize.height; + + if (globalScreenOrientationLocked(context) + && isFullscreen + && isLandscape() == isVerticalVideo + && !DeviceUtils.isTv(context) + && !DeviceUtils.isTablet(context)) { + // set correct orientation + player.getFragmentListener().ifPresent( + PlayerServiceEventListener::onScreenRotationButtonClicked); + } + + setupScreenRotationButton(); + } + + public void toggleFullscreen() { + if (DEBUG) { + Log.d(TAG, "toggleFullscreen() called"); + } + final PlayerServiceEventListener fragmentListener = player.getFragmentListener() + .orElse(null); + if (fragmentListener == null || player.exoPlayerIsNull()) { + return; + } + + isFullscreen = !isFullscreen; + if (isFullscreen) { + // Android needs tens milliseconds to send new insets but a user is able to see + // how controls changes it's position from `0` to `nav bar height` padding. + // So just hide the controls to hide this visual inconsistency + hideControls(0, 0); + } else { + // Apply window insets because Android will not do it when orientation changes + // from landscape to portrait (open vertical video to reproduce) + binding.playbackControlRoot.setPadding(0, 0, 0, 0); + } + fragmentListener.onFullscreenStateChanged(isFullscreen); + + binding.titleTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE); + binding.channelTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE); + binding.playerCloseButton.setVisibility(isFullscreen ? View.GONE : View.VISIBLE); + setupScreenRotationButton(); + } + + public void checkLandscape() { + // check if landscape is correct + final boolean videoInLandscapeButNotInFullscreen = isLandscape() + && !isFullscreen + && !player.isAudioOnly(); + final boolean notPaused = player.getCurrentState() != STATE_COMPLETED + && player.getCurrentState() != STATE_PAUSED; + + if (videoInLandscapeButNotInFullscreen + && notPaused + && !DeviceUtils.isTablet(context)) { + toggleFullscreen(); + } + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Getters + //////////////////////////////////////////////////////////////////////////*/ + //region Getters + + private Optional getParentContext() { + return Optional.ofNullable(binding.getRoot().getParent()) + .filter(ViewGroup.class::isInstance) + .map(parent -> ((ViewGroup) parent).getContext()); + } + + public Optional getParentActivity() { + return getParentContext() + .filter(AppCompatActivity.class::isInstance) + .map(AppCompatActivity.class::cast); + } + + public boolean isLandscape() { + // DisplayMetrics from activity context knows about MultiWindow feature + // while DisplayMetrics from app context doesn't + return DeviceUtils.isLandscape(getParentContext().orElse(player.getService())); + } + //endregion +} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUi.java new file mode 100644 index 000000000..57e2ec2a2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUi.java @@ -0,0 +1,212 @@ +package org.schabi.newpipe.player.ui; + +import android.content.Context; +import android.content.Intent; +import android.graphics.Bitmap; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player.RepeatMode; +import com.google.android.exoplayer2.Tracks; +import com.google.android.exoplayer2.text.Cue; +import com.google.android.exoplayer2.video.VideoSize; + +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.player.Player; + +import java.util.List; + +/** + * A player UI is a component that can seamlessly connect and disconnect from the {@link Player} and + * provide a user interface of some sort. Try to extend this class instead of adding more code to + * {@link Player}! + */ +public abstract class PlayerUi { + + @NonNull protected final Context context; + @NonNull protected final Player player; + + /** + * @param player the player instance that will be usable throughout the lifetime of this UI; its + * context should already have been initialized + */ + protected PlayerUi(@NonNull final Player player) { + this.context = player.getContext(); + this.player = player; + } + + /** + * @return the player instance this UI was constructed with + */ + @NonNull + public Player getPlayer() { + return player; + } + + + /** + * Called after the player received an intent and processed it. + */ + public void setupAfterIntent() { + } + + /** + * Called right after the exoplayer instance is constructed, or right after this UI is + * constructed if the exoplayer is already available then. Note that the exoplayer instance + * could be built and destroyed multiple times during the lifetime of the player, so this method + * might be called multiple times. + */ + public void initPlayer() { + } + + /** + * Called when playback in the exoplayer is about to start, or right after this UI is + * constructed if the exoplayer and the play queue are already available then. The play queue + * will therefore always be not null. + */ + public void initPlayback() { + } + + /** + * Called when the exoplayer instance is about to be destroyed. Note that the exoplayer instance + * could be built and destroyed multiple times during the lifetime of the player, so this method + * might be called multiple times. Be sure to unset any video surface view or play queue + * listeners! This will also be called when this UI is being discarded, just before {@link + * #destroy()}. + */ + public void destroyPlayer() { + } + + /** + * Called when this UI is being discarded, either because the player is switching to a different + * UI or because the player is shutting down completely. + */ + public void destroy() { + } + + /** + * Called when the player is smooth-stopping, that is, transitioning smoothly to a new play + * queue after the user tapped on a new video stream while a stream was playing in the video + * detail fragment. + */ + public void smoothStopForImmediateReusing() { + } + + /** + * Called when the video detail fragment listener is connected with the player, or right after + * this UI is constructed if the listener is already connected then. + */ + public void onFragmentListenerSet() { + } + + /** + * Broadcasts that the player receives will also be notified to UIs here. If you want to + * register new broadcast actions to receive here, add them to {@link + * Player#setupBroadcastReceiver()}. + * @param intent the broadcast intent received by the player + */ + public void onBroadcastReceived(final Intent intent) { + } + + /** + * Called when stream progress (i.e. the current time in the seekbar) or stream duration change. + * Will surely be called every {@link Player#PROGRESS_LOOP_INTERVAL_MILLIS} while a stream is + * playing. + * @param currentProgress the current progress in milliseconds + * @param duration the duration of the stream being played + * @param bufferPercent the percentage of stream already buffered, see {@link + * com.google.android.exoplayer2.BasePlayer#getBufferedPercentage()} + */ + public void onUpdateProgress(final int currentProgress, + final int duration, + final int bufferPercent) { + } + + public void onPrepared() { + } + + public void onBlocked() { + } + + public void onPlaying() { + } + + public void onBuffering() { + } + + public void onPaused() { + } + + public void onPausedSeek() { + } + + public void onCompleted() { + } + + public void onRepeatModeChanged(@RepeatMode final int repeatMode) { + } + + public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) { + } + + public void onMuteUnmuteChanged(final boolean isMuted) { + } + + /** + * @see com.google.android.exoplayer2.Player.Listener#onTracksChanged(Tracks) + * @param currentTracks the available tracks information + */ + public void onTextTracksChanged(@NonNull final Tracks currentTracks) { + } + + /** + * @see com.google.android.exoplayer2.Player.Listener#onPlaybackParametersChanged + * @param playbackParameters the new playback parameters + */ + public void onPlaybackParametersChanged(@NonNull final PlaybackParameters playbackParameters) { + } + + /** + * @see com.google.android.exoplayer2.Player.Listener#onRenderedFirstFrame + */ + public void onRenderedFirstFrame() { + } + + /** + * @see com.google.android.exoplayer2.text.TextOutput#onCues + * @param cues the cues to pass to the subtitle view + */ + public void onCues(@NonNull final List cues) { + } + + /** + * Called when the stream being played changes. + * @param info the {@link StreamInfo} metadata object, along with data about the selected and + * available video streams (to be used to build the resolution menus, for example) + */ + public void onMetadataChanged(@NonNull final StreamInfo info) { + } + + /** + * Called when the thumbnail for the current metadata was loaded. + * @param bitmap the thumbnail to process, or null if there is no thumbnail or there was an + * error when loading the thumbnail + */ + public void onThumbnailLoaded(@Nullable final Bitmap bitmap) { + } + + /** + * Called when the play queue was edited: a stream was appended, moved or removed. + */ + public void onPlayQueueEdited() { + } + + /** + * @param videoSize the new video size, useful to set the surface aspect ratio + * @see com.google.android.exoplayer2.Player.Listener#onVideoSizeChanged + */ + public void onVideoSizeChanged(@NonNull final VideoSize videoSize) { + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.java b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.java new file mode 100644 index 000000000..24fec3b8a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.java @@ -0,0 +1,90 @@ +package org.schabi.newpipe.player.ui; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; + +public final class PlayerUiList { + final List playerUis = new ArrayList<>(); + + /** + * Creates a {@link PlayerUiList} starting with the provided player uis. The provided player uis + * will not be prepared like those passed to {@link #addAndPrepare(PlayerUi)}, because when + * the {@link PlayerUiList} constructor is called, the player is still not running and it + * wouldn't make sense to initialize uis then. Instead the player will initialize them by doing + * proper calls to {@link #call(Consumer)}. + * + * @param initialPlayerUis the player uis this list should start with; the order will be kept + */ + public PlayerUiList(final PlayerUi... initialPlayerUis) { + playerUis.addAll(List.of(initialPlayerUis)); + } + + /** + * Adds the provided player ui to the list and calls on it the initialization functions that + * apply based on the current player state. The preparation step needs to be done since when UIs + * are removed and re-added, the player will not call e.g. initPlayer again since the exoplayer + * is already initialized, but we need to notify the newly built UI that the player is ready + * nonetheless. + * @param playerUi the player ui to prepare and add to the list; its {@link + * PlayerUi#getPlayer()} will be used to query information about the player + * state + */ + public void addAndPrepare(final PlayerUi playerUi) { + if (playerUi.getPlayer().getFragmentListener().isPresent()) { + // make sure UIs know whether a service is connected or not + playerUi.onFragmentListenerSet(); + } + + if (!playerUi.getPlayer().exoPlayerIsNull()) { + playerUi.initPlayer(); + if (playerUi.getPlayer().getPlayQueue() != null) { + playerUi.initPlayback(); + } + } + + playerUis.add(playerUi); + } + + /** + * Destroys all matching player UIs and removes them from the list. + * @param playerUiType the class of the player UI to destroy; the {@link + * Class#isInstance(Object)} method will be used, so even subclasses will be + * destroyed and removed + * @param the class type parameter + */ + public void destroyAll(final Class playerUiType) { + playerUis.stream() + .filter(playerUiType::isInstance) + .forEach(playerUi -> { + playerUi.destroyPlayer(); + playerUi.destroy(); + }); + playerUis.removeIf(playerUiType::isInstance); + } + + /** + * @param playerUiType the class of the player UI to return; the {@link + * Class#isInstance(Object)} method will be used, so even subclasses could + * be returned + * @param the class type parameter + * @return the first player UI of the required type found in the list, or an empty {@link + * Optional} otherwise + */ + public Optional get(final Class playerUiType) { + return playerUis.stream() + .filter(playerUiType::isInstance) + .map(playerUiType::cast) + .findFirst(); + } + + /** + * Calls the provided consumer on all player UIs in the list, in order of addition. + * @param consumer the consumer to call with player UIs + */ + public void call(final Consumer consumer) { + //noinspection SimplifyStreamApiCallChains + playerUis.stream().forEachOrdered(consumer); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java new file mode 100644 index 000000000..90c24c0c6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java @@ -0,0 +1,592 @@ +package org.schabi.newpipe.player.ui; + +import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; +import static org.schabi.newpipe.MainActivity.DEBUG; +import static org.schabi.newpipe.player.helper.PlayerHelper.getMinimumVideoHeight; + +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.PixelFormat; +import android.os.Build; +import android.util.DisplayMetrics; +import android.util.Log; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowInsets; +import android.view.WindowManager; +import android.view.animation.AnticipateInterpolator; +import android.widget.LinearLayout; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; +import androidx.core.math.MathUtils; + +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; +import com.google.android.exoplayer2.ui.SubtitleView; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.PlayerBinding; +import org.schabi.newpipe.databinding.PlayerPopupCloseOverlayBinding; +import org.schabi.newpipe.player.Player; +import org.schabi.newpipe.player.gesture.BasePlayerGestureListener; +import org.schabi.newpipe.player.gesture.PopupPlayerGestureListener; +import org.schabi.newpipe.player.helper.PlayerHelper; + +public final class PopupPlayerUi extends VideoPlayerUi { + private static final String TAG = PopupPlayerUi.class.getSimpleName(); + + /** + * Maximum opacity allowed for Android 12 and higher to allow touches on other apps when using + * NewPipe's popup player. + * + *

+ * This value is hardcoded instead of being get dynamically with the method linked of the + * constant documentation below, because it is not static and popup player layout parameters + * are generated with static methods. + *

+ * + * @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE + */ + private static final float MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER = 0.8f; + + /*////////////////////////////////////////////////////////////////////////// + // Popup player + //////////////////////////////////////////////////////////////////////////*/ + + private PlayerPopupCloseOverlayBinding closeOverlayBinding; + + private boolean isPopupClosing = false; + + private int screenWidth; + private int screenHeight; + + /*////////////////////////////////////////////////////////////////////////// + // Popup player window manager + //////////////////////////////////////////////////////////////////////////*/ + + public static final int IDLE_WINDOW_FLAGS = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + | WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM; + public static final int ONGOING_PLAYBACK_WINDOW_FLAGS = IDLE_WINDOW_FLAGS + | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON; + + private WindowManager.LayoutParams popupLayoutParams; // null if player is not popup + private final WindowManager windowManager; + + + /*////////////////////////////////////////////////////////////////////////// + // Constructor, setup, destroy + //////////////////////////////////////////////////////////////////////////*/ + //region Constructor, setup, destroy + + public PopupPlayerUi(@NonNull final Player player, + @NonNull final PlayerBinding playerBinding) { + super(player, playerBinding); + windowManager = ContextCompat.getSystemService(context, WindowManager.class); + } + + @Override + public void setupAfterIntent() { + super.setupAfterIntent(); + initPopup(); + initPopupCloseOverlay(); + } + + @Override + BasePlayerGestureListener buildGestureListener() { + return new PopupPlayerGestureListener(this); + } + + @SuppressLint("RtlHardcoded") + private void initPopup() { + if (DEBUG) { + Log.d(TAG, "initPopup() called"); + } + + // Popup is already added to windowManager + if (popupHasParent()) { + return; + } + + updateScreenSize(); + + popupLayoutParams = retrievePopupLayoutParamsFromPrefs(); + binding.surfaceView.setHeights(popupLayoutParams.height, popupLayoutParams.height); + + checkPopupPositionBounds(); + + binding.loadingPanel.setMinimumWidth(popupLayoutParams.width); + binding.loadingPanel.setMinimumHeight(popupLayoutParams.height); + + windowManager.addView(binding.getRoot(), popupLayoutParams); + setupVideoSurfaceIfNeeded(); // now there is a parent, we can setup video surface + + // Popup doesn't have aspectRatio selector, using FIT automatically + setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FIT); + } + + @SuppressLint("RtlHardcoded") + private void initPopupCloseOverlay() { + if (DEBUG) { + Log.d(TAG, "initPopupCloseOverlay() called"); + } + + // closeOverlayView is already added to windowManager + if (closeOverlayBinding != null) { + return; + } + + closeOverlayBinding = PlayerPopupCloseOverlayBinding.inflate(LayoutInflater.from(context)); + + final WindowManager.LayoutParams closeOverlayLayoutParams = buildCloseOverlayLayoutParams(); + closeOverlayBinding.closeButton.setVisibility(View.GONE); + windowManager.addView(closeOverlayBinding.getRoot(), closeOverlayLayoutParams); + } + + @Override + protected void setupElementsVisibility() { + binding.fullScreenButton.setVisibility(View.VISIBLE); + binding.screenRotationButton.setVisibility(View.GONE); + binding.resizeTextView.setVisibility(View.GONE); + binding.getRoot().findViewById(R.id.metadataView).setVisibility(View.GONE); + binding.queueButton.setVisibility(View.GONE); + binding.segmentsButton.setVisibility(View.GONE); + binding.moreOptionsButton.setVisibility(View.GONE); + binding.topControls.setOrientation(LinearLayout.HORIZONTAL); + binding.primaryControls.getLayoutParams().width = WRAP_CONTENT; + binding.secondaryControls.setAlpha(1.0f); + binding.secondaryControls.setVisibility(View.VISIBLE); + binding.secondaryControls.setTranslationY(0); + binding.share.setVisibility(View.GONE); + binding.playWithKodi.setVisibility(View.GONE); + binding.openInBrowser.setVisibility(View.GONE); + binding.switchMute.setVisibility(View.GONE); + binding.playerCloseButton.setVisibility(View.GONE); + binding.topControls.bringToFront(); + binding.topControls.setClickable(false); + binding.topControls.setFocusable(false); + binding.bottomControls.bringToFront(); + super.setupElementsVisibility(); + } + + @Override + protected void setupElementsSize(final Resources resources) { + setupElementsSize( + 0, + 0, + resources.getDimensionPixelSize(R.dimen.player_popup_controls_padding), + resources.getDimensionPixelSize(R.dimen.player_popup_buttons_padding) + ); + } + + @Override + public void removeViewFromParent() { + // view was added by windowManager for popup player + windowManager.removeViewImmediate(binding.getRoot()); + } + + @Override + public void destroy() { + super.destroy(); + removePopupFromView(); + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Broadcast receiver + //////////////////////////////////////////////////////////////////////////*/ + //region Broadcast receiver + + @Override + public void onBroadcastReceived(final Intent intent) { + super.onBroadcastReceived(intent); + if (Intent.ACTION_CONFIGURATION_CHANGED.equals(intent.getAction())) { + updateScreenSize(); + changePopupSize(popupLayoutParams.width); + checkPopupPositionBounds(); + } else if (player.isPlaying() || player.isLoading()) { + if (Intent.ACTION_SCREEN_OFF.equals(intent.getAction())) { + // Use only audio source when screen turns off while popup player is playing + player.useVideoSource(false); + } else if (Intent.ACTION_SCREEN_ON.equals(intent.getAction())) { + // Restore video source when screen turns on and user was watching video in popup + player.useVideoSource(true); + } + } + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Popup position and size + //////////////////////////////////////////////////////////////////////////*/ + //region Popup position and size + + /** + * Check if {@link #popupLayoutParams}' position is within a arbitrary boundary + * that goes from (0, 0) to (screenWidth, screenHeight). + *

+ * If it's out of these boundaries, {@link #popupLayoutParams}' position is changed + * and {@code true} is returned to represent this change. + *

+ */ + public void checkPopupPositionBounds() { + if (DEBUG) { + Log.d(TAG, "checkPopupPositionBounds() called with: " + + "screenWidth = [" + screenWidth + "], " + + "screenHeight = [" + screenHeight + "]"); + } + if (popupLayoutParams == null) { + return; + } + + popupLayoutParams.x = MathUtils.clamp(popupLayoutParams.x, 0, screenWidth + - popupLayoutParams.width); + popupLayoutParams.y = MathUtils.clamp(popupLayoutParams.y, 0, screenHeight + - popupLayoutParams.height); + } + + public void updateScreenSize() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + final var windowMetrics = windowManager.getCurrentWindowMetrics(); + final var bounds = windowMetrics.getBounds(); + final var windowInsets = windowMetrics.getWindowInsets(); + final var insets = windowInsets.getInsetsIgnoringVisibility( + WindowInsets.Type.navigationBars() | WindowInsets.Type.displayCutout()); + screenWidth = bounds.width() - (insets.left + insets.right); + screenHeight = bounds.height() - (insets.top + insets.bottom); + } else { + final DisplayMetrics metrics = new DisplayMetrics(); + windowManager.getDefaultDisplay().getMetrics(metrics); + screenWidth = metrics.widthPixels; + screenHeight = metrics.heightPixels; + } + if (DEBUG) { + Log.d(TAG, "updateScreenSize() called: screenWidth = [" + + screenWidth + "], screenHeight = [" + screenHeight + "]"); + } + } + + /** + * Changes the size of the popup based on the width. + * @param width the new width, height is calculated with + * {@link PlayerHelper#getMinimumVideoHeight(float)} + */ + public void changePopupSize(final int width) { + if (DEBUG) { + Log.d(TAG, "changePopupSize() called with: width = [" + width + "]"); + } + + if (anyPopupViewIsNull()) { + return; + } + + final float minimumWidth = context.getResources().getDimension(R.dimen.popup_minimum_width); + final int actualWidth = MathUtils.clamp(width, (int) minimumWidth, screenWidth); + final int actualHeight = (int) getMinimumVideoHeight(width); + if (DEBUG) { + Log.d(TAG, "updatePopupSize() updated values:" + + " width = [" + actualWidth + "], height = [" + actualHeight + "]"); + } + + popupLayoutParams.width = actualWidth; + popupLayoutParams.height = actualHeight; + binding.surfaceView.setHeights(popupLayoutParams.height, popupLayoutParams.height); + windowManager.updateViewLayout(binding.getRoot(), popupLayoutParams); + } + + @Override + protected float calculateMaxEndScreenThumbnailHeight(@NonNull final Bitmap bitmap) { + // no need for the end screen thumbnail to be resized on popup player: it's only needed + // for the main player so that it is enlarged correctly inside the fragment + return bitmap.getHeight(); + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Popup closing + //////////////////////////////////////////////////////////////////////////*/ + //region Popup closing + + public void closePopup() { + if (DEBUG) { + Log.d(TAG, "closePopup() called, isPopupClosing = " + isPopupClosing); + } + if (isPopupClosing) { + return; + } + isPopupClosing = true; + + player.saveStreamProgressState(); + windowManager.removeView(binding.getRoot()); + + animatePopupOverlayAndFinishService(); + } + + public boolean isPopupClosing() { + return isPopupClosing; + } + + public void removePopupFromView() { + // wrap in try-catch since it could sometimes generate errors randomly + try { + if (popupHasParent()) { + windowManager.removeView(binding.getRoot()); + } + } catch (final IllegalArgumentException e) { + Log.w(TAG, "Failed to remove popup from window manager", e); + } + + try { + final boolean closeOverlayHasParent = closeOverlayBinding != null + && closeOverlayBinding.getRoot().getParent() != null; + if (closeOverlayHasParent) { + windowManager.removeView(closeOverlayBinding.getRoot()); + } + } catch (final IllegalArgumentException e) { + Log.w(TAG, "Failed to remove popup overlay from window manager", e); + } + } + + private void animatePopupOverlayAndFinishService() { + final int targetTranslationY = + (int) (closeOverlayBinding.closeButton.getRootView().getHeight() + - closeOverlayBinding.closeButton.getY()); + + closeOverlayBinding.closeButton.animate().setListener(null).cancel(); + closeOverlayBinding.closeButton.animate() + .setInterpolator(new AnticipateInterpolator()) + .translationY(targetTranslationY) + .setDuration(400) + .setListener(new AnimatorListenerAdapter() { + @Override + public void onAnimationCancel(final Animator animation) { + end(); + } + + @Override + public void onAnimationEnd(final Animator animation) { + end(); + } + + private void end() { + windowManager.removeView(closeOverlayBinding.getRoot()); + closeOverlayBinding = null; + player.getService().stopService(); + } + }).start(); + } + //endregion + + /*////////////////////////////////////////////////////////////////////////// + // Playback states + //////////////////////////////////////////////////////////////////////////*/ + //region Playback states + + private void changePopupWindowFlags(final int flags) { + if (DEBUG) { + Log.d(TAG, "changePopupWindowFlags() called with: flags = [" + flags + "]"); + } + + if (!anyPopupViewIsNull()) { + popupLayoutParams.flags = flags; + windowManager.updateViewLayout(binding.getRoot(), popupLayoutParams); + } + } + + @Override + public void onPlaying() { + super.onPlaying(); + changePopupWindowFlags(ONGOING_PLAYBACK_WINDOW_FLAGS); + } + + @Override + public void onPaused() { + super.onPaused(); + changePopupWindowFlags(IDLE_WINDOW_FLAGS); + } + + @Override + public void onCompleted() { + super.onCompleted(); + changePopupWindowFlags(IDLE_WINDOW_FLAGS); + } + + @Override + protected void setupSubtitleView(final float captionScale) { + final float captionRatio = (captionScale - 1.0f) / 5.0f + 1.0f; + binding.subtitleView.setFractionalTextSize( + SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * captionRatio); + } + + @Override + protected void onPlaybackSpeedClicked() { + playbackSpeedPopupMenu.show(); + isSomePopupMenuVisible = true; + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Gestures + //////////////////////////////////////////////////////////////////////////*/ + //region Gestures + + private int distanceFromCloseButton(@NonNull final MotionEvent popupMotionEvent) { + final int closeOverlayButtonX = closeOverlayBinding.closeButton.getLeft() + + closeOverlayBinding.closeButton.getWidth() / 2; + final int closeOverlayButtonY = closeOverlayBinding.closeButton.getTop() + + closeOverlayBinding.closeButton.getHeight() / 2; + + final float fingerX = popupLayoutParams.x + popupMotionEvent.getX(); + final float fingerY = popupLayoutParams.y + popupMotionEvent.getY(); + + return (int) Math.sqrt(Math.pow(closeOverlayButtonX - fingerX, 2) + + Math.pow(closeOverlayButtonY - fingerY, 2)); + } + + private float getClosingRadius() { + final int buttonRadius = closeOverlayBinding.closeButton.getWidth() / 2; + // 20% wider than the button itself + return buttonRadius * 1.2f; + } + + public boolean isInsideClosingRadius(@NonNull final MotionEvent popupMotionEvent) { + return distanceFromCloseButton(popupMotionEvent) <= getClosingRadius(); + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Popup & closing overlay layout params + saving popup position and size + //////////////////////////////////////////////////////////////////////////*/ + //region Popup & closing overlay layout params + saving popup position and size + + /** + * {@code screenWidth} and {@code screenHeight} must have been initialized. + * @return the popup starting layout params + */ + @SuppressLint("RtlHardcoded") + public WindowManager.LayoutParams retrievePopupLayoutParamsFromPrefs() { + final SharedPreferences prefs = getPlayer().getPrefs(); + final Context context = getPlayer().getContext(); + + final boolean popupRememberSizeAndPos = prefs.getBoolean( + context.getString(R.string.popup_remember_size_pos_key), true); + final float defaultSize = context.getResources().getDimension(R.dimen.popup_default_width); + final float popupWidth = popupRememberSizeAndPos + ? prefs.getFloat(context.getString(R.string.popup_saved_width_key), defaultSize) + : defaultSize; + final float popupHeight = getMinimumVideoHeight(popupWidth); + + final WindowManager.LayoutParams params = new WindowManager.LayoutParams( + (int) popupWidth, (int) popupHeight, + popupLayoutParamType(), + IDLE_WINDOW_FLAGS, + PixelFormat.TRANSLUCENT); + params.gravity = Gravity.LEFT | Gravity.TOP; + params.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE; + + final int centerX = (int) (screenWidth / 2f - popupWidth / 2f); + final int centerY = (int) (screenHeight / 2f - popupHeight / 2f); + params.x = popupRememberSizeAndPos + ? prefs.getInt(context.getString(R.string.popup_saved_x_key), centerX) : centerX; + params.y = popupRememberSizeAndPos + ? prefs.getInt(context.getString(R.string.popup_saved_y_key), centerY) : centerY; + + return params; + } + + public void savePopupPositionAndSizeToPrefs() { + if (getPopupLayoutParams() != null) { + final Context context = getPlayer().getContext(); + getPlayer().getPrefs().edit() + .putFloat(context.getString(R.string.popup_saved_width_key), + popupLayoutParams.width) + .putInt(context.getString(R.string.popup_saved_x_key), + popupLayoutParams.x) + .putInt(context.getString(R.string.popup_saved_y_key), + popupLayoutParams.y) + .apply(); + } + } + + @SuppressLint("RtlHardcoded") + public static WindowManager.LayoutParams buildCloseOverlayLayoutParams() { + final int flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE + | WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM; + + final WindowManager.LayoutParams closeOverlayLayoutParams = new WindowManager.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, + popupLayoutParamType(), + flags, + PixelFormat.TRANSLUCENT); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // Setting maximum opacity allowed for touch events to other apps for Android 12 and + // higher to prevent non interaction when using other apps with the popup player + closeOverlayLayoutParams.alpha = MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER; + } + + closeOverlayLayoutParams.gravity = Gravity.LEFT | Gravity.TOP; + closeOverlayLayoutParams.softInputMode = + WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE; + return closeOverlayLayoutParams; + } + + public static int popupLayoutParamType() { + return Build.VERSION.SDK_INT < Build.VERSION_CODES.O + ? WindowManager.LayoutParams.TYPE_PHONE + : WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY; + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Getters + //////////////////////////////////////////////////////////////////////////*/ + //region Getters + + private boolean popupHasParent() { + return binding != null + && binding.getRoot().getLayoutParams() instanceof WindowManager.LayoutParams + && binding.getRoot().getParent() != null; + } + + private boolean anyPopupViewIsNull() { + return popupLayoutParams == null || windowManager == null + || binding.getRoot().getParent() == null; + } + + public PlayerPopupCloseOverlayBinding getCloseOverlayBinding() { + return closeOverlayBinding; + } + + public WindowManager.LayoutParams getPopupLayoutParams() { + return popupLayoutParams; + } + + public WindowManager getWindowManager() { + return windowManager; + } + + public int getScreenHeight() { + return screenHeight; + } + + public int getScreenWidth() { + return screenWidth; + } + //endregion +} diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java new file mode 100644 index 000000000..b51aaa638 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java @@ -0,0 +1,1620 @@ +package org.schabi.newpipe.player.ui; + +import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL; +import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE; +import static org.schabi.newpipe.MainActivity.DEBUG; +import static org.schabi.newpipe.ktx.ViewUtils.animate; +import static org.schabi.newpipe.ktx.ViewUtils.animateRotation; +import static org.schabi.newpipe.player.Player.RENDERER_UNAVAILABLE; +import static org.schabi.newpipe.player.Player.STATE_BUFFERING; +import static org.schabi.newpipe.player.Player.STATE_COMPLETED; +import static org.schabi.newpipe.player.Player.STATE_PAUSED; +import static org.schabi.newpipe.player.Player.STATE_PAUSED_SEEK; +import static org.schabi.newpipe.player.Player.STATE_PLAYING; +import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; +import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString; +import static org.schabi.newpipe.player.helper.PlayerHelper.nextResizeModeAndSaveToPrefs; +import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences; + +import android.content.Intent; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffColorFilter; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.GestureDetector; +import android.view.Gravity; +import android.view.KeyEvent; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.SeekBar; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.view.ContextThemeWrapper; +import androidx.appcompat.widget.AppCompatImageButton; +import androidx.appcompat.widget.PopupMenu; +import androidx.core.graphics.BitmapCompat; +import androidx.core.graphics.Insets; +import androidx.core.math.MathUtils; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlayer; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player.RepeatMode; +import com.google.android.exoplayer2.Tracks; +import com.google.android.exoplayer2.text.Cue; +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; +import com.google.android.exoplayer2.ui.CaptionStyleCompat; +import com.google.android.exoplayer2.video.VideoSize; + +import org.schabi.newpipe.App; +import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.PlayerBinding; +import org.schabi.newpipe.extractor.MediaFormat; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.fragments.detail.VideoDetailFragment; +import org.schabi.newpipe.ktx.AnimationType; +import org.schabi.newpipe.player.Player; +import org.schabi.newpipe.player.gesture.BasePlayerGestureListener; +import org.schabi.newpipe.player.gesture.DisplayPortion; +import org.schabi.newpipe.player.helper.PlayerHelper; +import org.schabi.newpipe.player.mediaitem.MediaItemTag; +import org.schabi.newpipe.player.playback.SurfaceHolderCallback; +import org.schabi.newpipe.player.playqueue.PlayQueue; +import org.schabi.newpipe.player.playqueue.PlayQueueItem; +import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper; +import org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHolder; +import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.external_communication.KoreUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; +import org.schabi.newpipe.views.player.PlayerFastSeekOverlay; + +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; + +public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBarChangeListener, + PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener { + private static final String TAG = VideoPlayerUi.class.getSimpleName(); + + // time constants + public static final long DEFAULT_CONTROLS_DURATION = 300; // 300 millis + public static final long DEFAULT_CONTROLS_HIDE_TIME = 2000; // 2 Seconds + public static final long DPAD_CONTROLS_HIDE_TIME = 7000; // 7 Seconds + public static final int SEEK_OVERLAY_DURATION = 450; // 450 millis + + // other constants (TODO remove playback speeds and use normal menu for popup, too) + private static final float[] PLAYBACK_SPEEDS = {0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f}; + + private enum PlayButtonAction { + PLAY, PAUSE, REPLAY + } + + /*////////////////////////////////////////////////////////////////////////// + // Views + //////////////////////////////////////////////////////////////////////////*/ + + protected PlayerBinding binding; + private final Handler controlsVisibilityHandler = new Handler(Looper.getMainLooper()); + @Nullable + private SurfaceHolderCallback surfaceHolderCallback; + boolean surfaceIsSetup = false; + + + /*////////////////////////////////////////////////////////////////////////// + // Popup menus ("popup" means that they pop up, not that they belong to the popup player) + //////////////////////////////////////////////////////////////////////////*/ + + private static final int POPUP_MENU_ID_QUALITY = 69; + private static final int POPUP_MENU_ID_AUDIO_TRACK = 70; + private static final int POPUP_MENU_ID_PLAYBACK_SPEED = 79; + private static final int POPUP_MENU_ID_CAPTION = 89; + + protected boolean isSomePopupMenuVisible = false; + private PopupMenu qualityPopupMenu; + private PopupMenu audioTrackPopupMenu; + protected PopupMenu playbackSpeedPopupMenu; + private PopupMenu captionPopupMenu; + + + /*////////////////////////////////////////////////////////////////////////// + // Gestures + //////////////////////////////////////////////////////////////////////////*/ + + private GestureDetector gestureDetector; + private BasePlayerGestureListener playerGestureListener; + @Nullable + private View.OnLayoutChangeListener onLayoutChangeListener = null; + + @NonNull + private final SeekbarPreviewThumbnailHolder seekbarPreviewThumbnailHolder = + new SeekbarPreviewThumbnailHolder(); + + + /*////////////////////////////////////////////////////////////////////////// + // Constructor, setup, destroy + //////////////////////////////////////////////////////////////////////////*/ + //region Constructor, setup, destroy + + protected VideoPlayerUi(@NonNull final Player player, + @NonNull final PlayerBinding playerBinding) { + super(player); + binding = playerBinding; + setupFromView(); + } + + public void setupFromView() { + initViews(); + initListeners(); + setupPlayerSeekOverlay(); + } + + private void initViews() { + setupSubtitleView(); + + binding.resizeTextView + .setText(PlayerHelper.resizeTypeOf(context, binding.surfaceView.getResizeMode())); + + binding.playbackSeekBar.getThumb() + .setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN)); + binding.playbackSeekBar.getProgressDrawable() + .setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.MULTIPLY)); + + final ContextThemeWrapper themeWrapper = new ContextThemeWrapper(context, + R.style.DarkPopupMenu); + + qualityPopupMenu = new PopupMenu(themeWrapper, binding.qualityTextView); + audioTrackPopupMenu = new PopupMenu(themeWrapper, binding.audioTrackTextView); + playbackSpeedPopupMenu = new PopupMenu(context, binding.playbackSpeed); + captionPopupMenu = new PopupMenu(themeWrapper, binding.captionTextView); + + binding.progressBarLoadingPanel.getIndeterminateDrawable() + .setColorFilter(new PorterDuffColorFilter(Color.WHITE, PorterDuff.Mode.MULTIPLY)); + + binding.titleTextView.setSelected(true); + binding.channelTextView.setSelected(true); + + // Prevent hiding of bottom sheet via swipe inside queue + binding.itemsList.setNestedScrollingEnabled(false); + } + + abstract BasePlayerGestureListener buildGestureListener(); + + protected void initListeners() { + binding.qualityTextView.setOnClickListener(makeOnClickListener(this::onQualityClicked)); + binding.audioTrackTextView.setOnClickListener( + makeOnClickListener(this::onAudioTracksClicked)); + binding.playbackSpeed.setOnClickListener(makeOnClickListener(this::onPlaybackSpeedClicked)); + + binding.playbackSeekBar.setOnSeekBarChangeListener(this); + binding.captionTextView.setOnClickListener(makeOnClickListener(this::onCaptionClicked)); + binding.resizeTextView.setOnClickListener(makeOnClickListener(this::onResizeClicked)); + binding.playbackLiveSync.setOnClickListener(makeOnClickListener(player::seekToDefault)); + + playerGestureListener = buildGestureListener(); + gestureDetector = new GestureDetector(context, playerGestureListener); + binding.getRoot().setOnTouchListener(playerGestureListener); + + binding.repeatButton.setOnClickListener(v -> onRepeatClicked()); + binding.shuffleButton.setOnClickListener(v -> onShuffleClicked()); + + binding.playPauseButton.setOnClickListener(makeOnClickListener(player::playPause)); + binding.playPreviousButton.setOnClickListener(makeOnClickListener(player::playPrevious)); + binding.playNextButton.setOnClickListener(makeOnClickListener(player::playNext)); + + binding.moreOptionsButton.setOnClickListener( + makeOnClickListener(this::onMoreOptionsClicked)); + binding.share.setOnClickListener(makeOnClickListener(() -> { + final PlayQueueItem currentItem = player.getCurrentItem(); + if (currentItem != null) { + ShareUtils.shareText(context, currentItem.getTitle(), + player.getVideoUrlAtCurrentTime(), currentItem.getThumbnails()); + } + })); + binding.share.setOnLongClickListener(v -> { + ShareUtils.copyToClipboard(context, player.getVideoUrlAtCurrentTime()); + return true; + }); + binding.fullScreenButton.setOnClickListener(makeOnClickListener(() -> { + player.setRecovery(); + NavigationHelper.playOnMainPlayer(context, + Objects.requireNonNull(player.getPlayQueue()), true); + })); + binding.playWithKodi.setOnClickListener(makeOnClickListener(this::onPlayWithKodiClicked)); + binding.openInBrowser.setOnClickListener(makeOnClickListener(this::onOpenInBrowserClicked)); + binding.playerCloseButton.setOnClickListener(makeOnClickListener(() -> + // set package to this app's package to prevent the intent from being seen outside + context.sendBroadcast(new Intent(VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER) + .setPackage(App.PACKAGE_NAME)) + )); + binding.switchMute.setOnClickListener(makeOnClickListener(player::toggleMute)); + + ViewCompat.setOnApplyWindowInsetsListener(binding.itemsListPanel, (view, windowInsets) -> { + final Insets cutout = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout()); + if (!cutout.equals(Insets.NONE)) { + view.setPadding(cutout.left, cutout.top, cutout.right, cutout.bottom); + } + return windowInsets; + }); + + // PlaybackControlRoot already consumed window insets but we should pass them to + // player_overlays and fast_seek_overlay too. Without it they will be off-centered. + onLayoutChangeListener = + (v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> { + binding.playerOverlays.setPadding(v.getPaddingLeft(), v.getPaddingTop(), + v.getPaddingRight(), v.getPaddingBottom()); + + // If we added padding to the fast seek overlay, too, it would not go under the + // system ui. Instead we apply negative margins equal to the window insets of + // the opposite side, so that the view covers all of the player (overflowing on + // some sides) and its center coincides with the center of other controls. + final RelativeLayout.LayoutParams fastSeekParams = (RelativeLayout.LayoutParams) + binding.fastSeekOverlay.getLayoutParams(); + fastSeekParams.leftMargin = -v.getPaddingRight(); + fastSeekParams.topMargin = -v.getPaddingBottom(); + fastSeekParams.rightMargin = -v.getPaddingLeft(); + fastSeekParams.bottomMargin = -v.getPaddingTop(); + }; + binding.playbackControlRoot.addOnLayoutChangeListener(onLayoutChangeListener); + } + + protected void deinitListeners() { + binding.qualityTextView.setOnClickListener(null); + binding.audioTrackTextView.setOnClickListener(null); + binding.playbackSpeed.setOnClickListener(null); + binding.playbackSeekBar.setOnSeekBarChangeListener(null); + binding.captionTextView.setOnClickListener(null); + binding.resizeTextView.setOnClickListener(null); + binding.playbackLiveSync.setOnClickListener(null); + + binding.getRoot().setOnTouchListener(null); + playerGestureListener = null; + gestureDetector = null; + + binding.repeatButton.setOnClickListener(null); + binding.shuffleButton.setOnClickListener(null); + + binding.playPauseButton.setOnClickListener(null); + binding.playPreviousButton.setOnClickListener(null); + binding.playNextButton.setOnClickListener(null); + + binding.moreOptionsButton.setOnClickListener(null); + binding.moreOptionsButton.setOnLongClickListener(null); + binding.share.setOnClickListener(null); + binding.share.setOnLongClickListener(null); + binding.fullScreenButton.setOnClickListener(null); + binding.screenRotationButton.setOnClickListener(null); + binding.playWithKodi.setOnClickListener(null); + binding.openInBrowser.setOnClickListener(null); + binding.playerCloseButton.setOnClickListener(null); + binding.switchMute.setOnClickListener(null); + + ViewCompat.setOnApplyWindowInsetsListener(binding.itemsListPanel, null); + + binding.playbackControlRoot.removeOnLayoutChangeListener(onLayoutChangeListener); + } + + /** + * Initializes the Fast-For/Backward overlay. + */ + private void setupPlayerSeekOverlay() { + binding.fastSeekOverlay + .seekSecondsSupplier(() -> retrieveSeekDurationFromPreferences(player) / 1000) + .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); + } + + @NonNull + @Override + public FastSeekDirection getFastSeekDirection( + @NonNull final DisplayPortion portion + ) { + if (player.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 (player.getExoPlayer().getCurrentPosition() < 500L) { + return FastSeekDirection.NONE; + } + return FastSeekDirection.BACKWARD; + } else if (portion == DisplayPortion.RIGHT) { + // Check if it's possible to fast-forward + if (player.getCurrentState() == STATE_COMPLETED + || player.getExoPlayer().getCurrentPosition() + >= player.getExoPlayer().getDuration()) { + return FastSeekDirection.NONE; + } + return FastSeekDirection.FORWARD; + } + /* portion == DisplayPortion.MIDDLE */ + return FastSeekDirection.NONE; + } + + @Override + public void seek(final boolean forward) { + playerGestureListener.keepInDoubleTapMode(); + if (forward) { + player.fastForward(); + } else { + player.fastRewind(); + } + } + }); + playerGestureListener.doubleTapControls(binding.fastSeekOverlay); + } + + public void deinitPlayerSeekOverlay() { + binding.fastSeekOverlay + .seekSecondsSupplier(null) + .performListener(null); + } + + @Override + public void setupAfterIntent() { + super.setupAfterIntent(); + setupElementsVisibility(); + setupElementsSize(context.getResources()); + binding.getRoot().setVisibility(View.VISIBLE); + binding.playPauseButton.requestFocus(); + } + + @Override + public void initPlayer() { + super.initPlayer(); + setupVideoSurfaceIfNeeded(); + } + + @Override + public void initPlayback() { + super.initPlayback(); + + // #6825 - Ensure that the shuffle-button is in the correct state on the UI + setShuffleButton(player.getExoPlayer().getShuffleModeEnabled()); + } + + public abstract void removeViewFromParent(); + + @Override + public void destroyPlayer() { + super.destroyPlayer(); + clearVideoSurface(); + } + + @Override + public void destroy() { + super.destroy(); + binding.endScreen.setImageDrawable(null); + deinitPlayerSeekOverlay(); + deinitListeners(); + } + + protected void setupElementsVisibility() { + setMuteButton(player.isMuted()); + animateRotation(binding.moreOptionsButton, DEFAULT_CONTROLS_DURATION, 0); + } + + protected abstract void setupElementsSize(Resources resources); + + protected void setupElementsSize(final int buttonsMinWidth, + final int playerTopPad, + final int controlsPad, + final int buttonsPad) { + binding.topControls.setPaddingRelative(controlsPad, playerTopPad, controlsPad, 0); + binding.bottomControls.setPaddingRelative(controlsPad, 0, controlsPad, 0); + binding.qualityTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad); + binding.audioTrackTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad); + binding.playbackSpeed.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad); + binding.playbackSpeed.setMinimumWidth(buttonsMinWidth); + binding.captionTextView.setPadding(buttonsPad, buttonsPad, buttonsPad, buttonsPad); + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Broadcast receiver + //////////////////////////////////////////////////////////////////////////*/ + //region Broadcast receiver + + @Override + public void onBroadcastReceived(final Intent intent) { + super.onBroadcastReceived(intent); + if (Intent.ACTION_CONFIGURATION_CHANGED.equals(intent.getAction())) { + // When the orientation changes, the screen height might be smaller. If the end screen + // thumbnail is not re-scaled, it can be larger than the current screen height and thus + // enlarging the whole player. This causes the seekbar to be out of the visible area. + updateEndScreenThumbnail(player.getThumbnail()); + } + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Thumbnail + //////////////////////////////////////////////////////////////////////////*/ + //region Thumbnail + + /** + * Scale the player audio / end screen thumbnail down if necessary. + *

+ * This is necessary when the thumbnail's height is larger than the device's height + * and thus is enlarging the player's height + * causing the bottom playback controls to be out of the visible screen. + *

+ */ + @Override + public void onThumbnailLoaded(@Nullable final Bitmap bitmap) { + super.onThumbnailLoaded(bitmap); + updateEndScreenThumbnail(bitmap); + } + + private void updateEndScreenThumbnail(@Nullable final Bitmap thumbnail) { + if (thumbnail == null) { + // remove end screen thumbnail + binding.endScreen.setImageDrawable(null); + return; + } + + final float endScreenHeight = calculateMaxEndScreenThumbnailHeight(thumbnail); + final Bitmap endScreenBitmap = BitmapCompat.createScaledBitmap( + thumbnail, + (int) (thumbnail.getWidth() / (thumbnail.getHeight() / endScreenHeight)), + (int) endScreenHeight, + null, + true); + + if (DEBUG) { + Log.d(TAG, "Thumbnail - onThumbnailLoaded() called with: " + + "currentThumbnail = [" + thumbnail + "], " + + thumbnail.getWidth() + "x" + thumbnail.getHeight() + + ", scaled end screen height = " + endScreenHeight + + ", scaled end screen width = " + endScreenBitmap.getWidth()); + } + + binding.endScreen.setImageBitmap(endScreenBitmap); + } + + protected abstract float calculateMaxEndScreenThumbnailHeight(@NonNull Bitmap bitmap); + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Progress loop and updates + //////////////////////////////////////////////////////////////////////////*/ + //region Progress loop and updates + + @Override + public void onUpdateProgress(final int currentProgress, + final int duration, + final int bufferPercent) { + + if (duration != binding.playbackSeekBar.getMax()) { + setVideoDurationToControls(duration); + } + if (player.getCurrentState() != STATE_PAUSED) { + updatePlayBackElementsCurrentDuration(currentProgress); + } + if (player.isLoading() || bufferPercent > 90) { + binding.playbackSeekBar.setSecondaryProgress( + (int) (binding.playbackSeekBar.getMax() * ((float) bufferPercent / 100))); + } + if (DEBUG && bufferPercent % 20 == 0) { //Limit log + Log.d(TAG, "notifyProgressUpdateToListeners() called with: " + + "isVisible = " + isControlsVisible() + ", " + + "currentProgress = [" + currentProgress + "], " + + "duration = [" + duration + "], bufferPercent = [" + bufferPercent + "]"); + } + binding.playbackLiveSync.setClickable(!player.isLiveEdge()); + } + + /** + * Sets the current duration into the corresponding elements. + * + * @param currentProgress the current progress, in milliseconds + */ + private void updatePlayBackElementsCurrentDuration(final int currentProgress) { + // Don't set seekbar progress while user is seeking + if (player.getCurrentState() != STATE_PAUSED_SEEK) { + binding.playbackSeekBar.setProgress(currentProgress); + } + binding.playbackCurrentTime.setText(getTimeString(currentProgress)); + } + + /** + * Sets the video duration time into all control components (e.g. seekbar). + * + * @param duration the video duration, in milliseconds + */ + private void setVideoDurationToControls(final int duration) { + binding.playbackEndTime.setText(getTimeString(duration)); + + binding.playbackSeekBar.setMax(duration); + // This is important for Android TVs otherwise it would apply the default from + // setMax/Min methods which is (max - min) / 20 + binding.playbackSeekBar.setKeyProgressIncrement( + PlayerHelper.retrieveSeekDurationFromPreferences(player)); + } + + @Override // seekbar listener + public void onProgressChanged(final SeekBar seekBar, final int progress, + final boolean fromUser) { + // Currently we don't need method execution when fromUser is false + if (!fromUser) { + return; + } + if (DEBUG) { + Log.d(TAG, "onProgressChanged() called with: " + + "seekBar = [" + seekBar + "], progress = [" + progress + "]"); + } + + binding.currentDisplaySeek.setText(getTimeString(progress)); + + // Seekbar Preview Thumbnail + SeekbarPreviewThumbnailHelper + .tryResizeAndSetSeekbarPreviewThumbnail( + player.getContext(), + seekbarPreviewThumbnailHolder.getBitmapAt(progress).orElse(null), + binding.currentSeekbarPreviewThumbnail, + binding.subtitleView::getWidth); + + adjustSeekbarPreviewContainer(); + } + + + private void adjustSeekbarPreviewContainer() { + try { + // Should only be required when an error occurred before + // and the layout was positioned in the center + binding.bottomSeekbarPreviewLayout.setGravity(Gravity.NO_GRAVITY); + + // Calculate the current left position of seekbar progress in px + // More info: https://stackoverflow.com/q/20493577 + final int currentSeekbarLeft = + binding.playbackSeekBar.getLeft() + + binding.playbackSeekBar.getPaddingLeft() + + binding.playbackSeekBar.getThumb().getBounds().left; + + // Calculate the (unchecked) left position of the container + final int uncheckedContainerLeft = + currentSeekbarLeft - (binding.seekbarPreviewContainer.getWidth() / 2); + + // Fix the position so it's within the boundaries + final int checkedContainerLeft = MathUtils.clamp(uncheckedContainerLeft, + 0, binding.playbackWindowRoot.getWidth() + - binding.seekbarPreviewContainer.getWidth()); + + // See also: https://stackoverflow.com/a/23249734 + final LinearLayout.LayoutParams params = + new LinearLayout.LayoutParams( + binding.seekbarPreviewContainer.getLayoutParams()); + params.setMarginStart(checkedContainerLeft); + binding.seekbarPreviewContainer.setLayoutParams(params); + } catch (final Exception ex) { + Log.e(TAG, "Failed to adjust seekbarPreviewContainer", ex); + // Fallback - position in the middle + binding.bottomSeekbarPreviewLayout.setGravity(Gravity.CENTER); + } + } + + @Override // seekbar listener + public void onStartTrackingTouch(final SeekBar seekBar) { + if (DEBUG) { + Log.d(TAG, "onStartTrackingTouch() called with: seekBar = [" + seekBar + "]"); + } + if (player.getCurrentState() != STATE_PAUSED_SEEK) { + player.changeState(STATE_PAUSED_SEEK); + } + + showControls(0); + animate(binding.currentDisplaySeek, true, DEFAULT_CONTROLS_DURATION, + AnimationType.SCALE_AND_ALPHA); + animate(binding.currentSeekbarPreviewThumbnail, true, DEFAULT_CONTROLS_DURATION, + AnimationType.SCALE_AND_ALPHA); + } + + @Override // seekbar listener + public void onStopTrackingTouch(final SeekBar seekBar) { + if (DEBUG) { + Log.d(TAG, "onStopTrackingTouch() called with: seekBar = [" + seekBar + "]"); + } + + player.seekTo(seekBar.getProgress()); + if (player.getExoPlayer().getDuration() == seekBar.getProgress()) { + player.getExoPlayer().play(); + } + + binding.playbackCurrentTime.setText(getTimeString(seekBar.getProgress())); + animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA); + animate(binding.currentSeekbarPreviewThumbnail, false, 200, AnimationType.SCALE_AND_ALPHA); + + if (player.getCurrentState() == STATE_PAUSED_SEEK) { + player.changeState(STATE_BUFFERING); + } + if (!player.isProgressLoopRunning()) { + player.startProgressLoop(); + } + + showControlsThenHide(); + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Controls showing / hiding + //////////////////////////////////////////////////////////////////////////*/ + //region Controls showing / hiding + + public boolean isControlsVisible() { + return binding != null && binding.playbackControlRoot.getVisibility() == View.VISIBLE; + } + + public void showControlsThenHide() { + if (DEBUG) { + Log.d(TAG, "showControlsThenHide() called"); + } + + showOrHideButtons(); + showSystemUIPartially(); + + final long hideTime = binding.playbackControlRoot.isInTouchMode() + ? DEFAULT_CONTROLS_HIDE_TIME + : DPAD_CONTROLS_HIDE_TIME; + + showHideShadow(true, DEFAULT_CONTROLS_DURATION); + animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION, + AnimationType.ALPHA, 0, () -> hideControls(DEFAULT_CONTROLS_DURATION, hideTime)); + } + + public void showControls(final long duration) { + if (DEBUG) { + Log.d(TAG, "showControls() called"); + } + showOrHideButtons(); + showSystemUIPartially(); + controlsVisibilityHandler.removeCallbacksAndMessages(null); + showHideShadow(true, duration); + animate(binding.playbackControlRoot, true, duration); + } + + public void hideControls(final long duration, final long delay) { + if (DEBUG) { + Log.d(TAG, "hideControls() called with: duration = [" + duration + + "], delay = [" + delay + "]"); + } + + showOrHideButtons(); + + controlsVisibilityHandler.removeCallbacksAndMessages(null); + controlsVisibilityHandler.postDelayed(() -> { + showHideShadow(false, duration); + animate(binding.playbackControlRoot, false, duration, AnimationType.ALPHA, + 0, this::hideSystemUIIfNeeded); + }, delay); + } + + public 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); + } + + protected void showOrHideButtons() { + @Nullable final PlayQueue playQueue = player.getPlayQueue(); + if (playQueue == null) { + return; + } + + final boolean showPrev = playQueue.getIndex() != 0; + final boolean showNext = playQueue.getIndex() + 1 != playQueue.getStreams().size(); + + binding.playPreviousButton.setVisibility(showPrev ? View.VISIBLE : View.INVISIBLE); + binding.playPreviousButton.setAlpha(showPrev ? 1.0f : 0.0f); + binding.playNextButton.setVisibility(showNext ? View.VISIBLE : View.INVISIBLE); + binding.playNextButton.setAlpha(showNext ? 1.0f : 0.0f); + } + + protected void showSystemUIPartially() { + // system UI is really changed only by MainPlayerUi, so overridden there + } + + protected void hideSystemUIIfNeeded() { + // system UI is really changed only by MainPlayerUi, so overridden there + } + + protected boolean isAnyListViewOpen() { + // only MainPlayerUi has list views for the queue and for segments, so overridden there + return false; + } + + public boolean isFullscreen() { + // only MainPlayerUi can be in fullscreen, so overridden there + return false; + } + + /** + * Update the play/pause button ({@link R.id.playPauseButton}) to reflect the action + * that will be performed when the button is clicked.. + * @param action the action that is performed when the play/pause button is clicked + */ + private void updatePlayPauseButton(final PlayButtonAction action) { + final AppCompatImageButton button = binding.playPauseButton; + switch (action) { + case PLAY: + button.setContentDescription(context.getString(R.string.play)); + button.setImageResource(R.drawable.ic_play_arrow); + break; + case PAUSE: + button.setContentDescription(context.getString(R.string.pause)); + button.setImageResource(R.drawable.ic_pause); + break; + case REPLAY: + button.setContentDescription(context.getString(R.string.replay)); + button.setImageResource(R.drawable.ic_replay); + break; + } + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Playback states + //////////////////////////////////////////////////////////////////////////*/ + //region Playback states + + @Override + public void onPrepared() { + super.onPrepared(); + setVideoDurationToControls((int) player.getExoPlayer().getDuration()); + binding.playbackSpeed.setText(formatSpeed(player.getPlaybackSpeed())); + } + + @Override + public void onBlocked() { + super.onBlocked(); + + // if we are e.g. switching players, hide controls + hideControls(DEFAULT_CONTROLS_DURATION, 0); + + binding.playbackSeekBar.setEnabled(false); + binding.playbackSeekBar.getThumb() + .setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN)); + + binding.loadingPanel.setBackgroundColor(Color.BLACK); + animate(binding.loadingPanel, true, 0); + animate(binding.surfaceForeground, true, 100); + + updatePlayPauseButton(PlayButtonAction.PLAY); + animatePlayButtons(false, 100); + binding.getRoot().setKeepScreenOn(false); + } + + @Override + public void onPlaying() { + super.onPlaying(); + + updateStreamRelatedViews(); + + binding.playbackSeekBar.setEnabled(true); + binding.playbackSeekBar.getThumb() + .setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.SRC_IN)); + + binding.loadingPanel.setVisibility(View.GONE); + + animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA); + + animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0, + () -> { + updatePlayPauseButton(PlayButtonAction.PAUSE); + animatePlayButtons(true, 200); + if (!isAnyListViewOpen()) { + binding.playPauseButton.requestFocus(); + } + }); + + binding.getRoot().setKeepScreenOn(true); + } + + @Override + public void onBuffering() { + super.onBuffering(); + binding.loadingPanel.setBackgroundColor(Color.TRANSPARENT); + binding.loadingPanel.setVisibility(View.VISIBLE); + binding.getRoot().setKeepScreenOn(true); + } + + @Override + public void onPaused() { + super.onPaused(); + + // 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, + () -> { + updatePlayPauseButton(PlayButtonAction.PLAY); + animatePlayButtons(true, 200); + if (!isAnyListViewOpen()) { + binding.playPauseButton.requestFocus(); + } + }); + } + + binding.getRoot().setKeepScreenOn(false); + } + + @Override + public void onPausedSeek() { + super.onPausedSeek(); + animatePlayButtons(false, 100); + binding.getRoot().setKeepScreenOn(true); + } + + @Override + public void onCompleted() { + super.onCompleted(); + + animate(binding.playPauseButton, false, 0, AnimationType.SCALE_AND_ALPHA, 0, + () -> { + updatePlayPauseButton(PlayButtonAction.REPLAY); + animatePlayButtons(true, DEFAULT_CONTROLS_DURATION); + }); + + binding.getRoot().setKeepScreenOn(false); + + // When a (short) video ends the elements have to display the correct values - see #6180 + updatePlayBackElementsCurrentDuration(binding.playbackSeekBar.getMax()); + + showControls(500); + animate(binding.currentDisplaySeek, false, 200, AnimationType.SCALE_AND_ALPHA); + binding.loadingPanel.setVisibility(View.GONE); + animate(binding.surfaceForeground, true, 100); + } + + private void animatePlayButtons(final boolean show, final long duration) { + animate(binding.playPauseButton, show, duration, AnimationType.SCALE_AND_ALPHA); + + @Nullable final PlayQueue playQueue = player.getPlayQueue(); + if (playQueue == null) { + return; + } + + if (!show || playQueue.getIndex() > 0) { + animate( + binding.playPreviousButton, + show, + duration, + AnimationType.SCALE_AND_ALPHA); + } + if (!show || playQueue.getIndex() + 1 < playQueue.getStreams().size()) { + animate( + binding.playNextButton, + show, + duration, + AnimationType.SCALE_AND_ALPHA); + } + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Repeat, shuffle, mute + //////////////////////////////////////////////////////////////////////////*/ + //region Repeat, shuffle, mute + + public void onRepeatClicked() { + if (DEBUG) { + Log.d(TAG, "onRepeatClicked() called"); + } + player.cycleNextRepeatMode(); + } + + public void onShuffleClicked() { + if (DEBUG) { + Log.d(TAG, "onShuffleClicked() called"); + } + player.toggleShuffleModeEnabled(); + } + + @Override + public void onRepeatModeChanged(@RepeatMode final int repeatMode) { + super.onRepeatModeChanged(repeatMode); + + if (repeatMode == REPEAT_MODE_ALL) { + binding.repeatButton.setImageResource(R.drawable.exo_controls_repeat_all); + } else if (repeatMode == REPEAT_MODE_ONE) { + binding.repeatButton.setImageResource(R.drawable.exo_controls_repeat_one); + } else /* repeatMode == REPEAT_MODE_OFF */ { + binding.repeatButton.setImageResource(R.drawable.exo_controls_repeat_off); + } + } + + @Override + public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) { + super.onShuffleModeEnabledChanged(shuffleModeEnabled); + setShuffleButton(shuffleModeEnabled); + } + + @Override + public void onMuteUnmuteChanged(final boolean isMuted) { + super.onMuteUnmuteChanged(isMuted); + setMuteButton(isMuted); + } + + private void setMuteButton(final boolean isMuted) { + binding.switchMute.setImageDrawable(AppCompatResources.getDrawable(context, isMuted + ? R.drawable.ic_volume_off : R.drawable.ic_volume_up)); + } + + private void setShuffleButton(final boolean shuffled) { + binding.shuffleButton.setImageAlpha(shuffled ? 255 : 77); + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Other player listeners + //////////////////////////////////////////////////////////////////////////*/ + //region Other player listeners + + @Override + public void onPlaybackParametersChanged(@NonNull final PlaybackParameters playbackParameters) { + super.onPlaybackParametersChanged(playbackParameters); + binding.playbackSpeed.setText(formatSpeed(playbackParameters.speed)); + } + + @Override + public void onRenderedFirstFrame() { + super.onRenderedFirstFrame(); + //TODO check if this causes black screen when switching to fullscreen + animate(binding.surfaceForeground, false, DEFAULT_CONTROLS_DURATION); + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Metadata & stream related views + //////////////////////////////////////////////////////////////////////////*/ + //region Metadata & stream related views + + @Override + public void onMetadataChanged(@NonNull final StreamInfo info) { + super.onMetadataChanged(info); + + updateStreamRelatedViews(); + + binding.titleTextView.setText(info.getName()); + binding.channelTextView.setText(info.getUploaderName()); + + this.seekbarPreviewThumbnailHolder.resetFrom(player.getContext(), info.getPreviewFrames()); + } + + private void updateStreamRelatedViews() { + player.getCurrentStreamInfo().ifPresent(info -> { + binding.qualityTextView.setVisibility(View.GONE); + binding.audioTrackTextView.setVisibility(View.GONE); + binding.playbackSpeed.setVisibility(View.GONE); + + binding.playbackEndTime.setVisibility(View.GONE); + binding.playbackLiveSync.setVisibility(View.GONE); + + switch (info.getStreamType()) { + case AUDIO_STREAM: + case POST_LIVE_AUDIO_STREAM: + binding.surfaceView.setVisibility(View.GONE); + binding.endScreen.setVisibility(View.VISIBLE); + binding.playbackEndTime.setVisibility(View.VISIBLE); + break; + + case AUDIO_LIVE_STREAM: + binding.surfaceView.setVisibility(View.GONE); + binding.endScreen.setVisibility(View.VISIBLE); + binding.playbackLiveSync.setVisibility(View.VISIBLE); + break; + + case LIVE_STREAM: + binding.surfaceView.setVisibility(View.VISIBLE); + binding.endScreen.setVisibility(View.GONE); + binding.playbackLiveSync.setVisibility(View.VISIBLE); + break; + + case VIDEO_STREAM: + case POST_LIVE_STREAM: + if (player.getCurrentMetadata() != null + && player.getCurrentMetadata().getMaybeQuality().isEmpty() + || (info.getVideoStreams().isEmpty() + && info.getVideoOnlyStreams().isEmpty())) { + break; + } + + buildQualityMenu(); + buildAudioTrackMenu(); + + binding.qualityTextView.setVisibility(View.VISIBLE); + binding.surfaceView.setVisibility(View.VISIBLE); + // fallthrough + default: + binding.endScreen.setVisibility(View.GONE); + binding.playbackEndTime.setVisibility(View.VISIBLE); + break; + } + + buildPlaybackSpeedMenu(); + binding.playbackSpeed.setVisibility(View.VISIBLE); + }); + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Popup menus ("popup" means that they pop up, not that they belong to the popup player) + //////////////////////////////////////////////////////////////////////////*/ + //region Popup menus ("popup" means that they pop up, not that they belong to the popup player) + + private void buildQualityMenu() { + if (qualityPopupMenu == null) { + return; + } + qualityPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_QUALITY); + + final List availableStreams = Optional.ofNullable(player.getCurrentMetadata()) + .flatMap(MediaItemTag::getMaybeQuality) + .map(MediaItemTag.Quality::getSortedVideoStreams) + .orElse(null); + if (availableStreams == null) { + return; + } + + for (int i = 0; i < availableStreams.size(); i++) { + final VideoStream videoStream = availableStreams.get(i); + qualityPopupMenu.getMenu().add(POPUP_MENU_ID_QUALITY, i, Menu.NONE, MediaFormat + .getNameById(videoStream.getFormatId()) + " " + videoStream.getResolution()); + } + qualityPopupMenu.setOnMenuItemClickListener(this); + qualityPopupMenu.setOnDismissListener(this); + + player.getSelectedVideoStream() + .ifPresent(s -> binding.qualityTextView.setText(s.getResolution())); + } + + private void buildAudioTrackMenu() { + if (audioTrackPopupMenu == null) { + return; + } + audioTrackPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_AUDIO_TRACK); + + final List availableStreams = Optional.ofNullable(player.getCurrentMetadata()) + .flatMap(MediaItemTag::getMaybeAudioTrack) + .map(MediaItemTag.AudioTrack::getAudioStreams) + .orElse(null); + if (availableStreams == null || availableStreams.size() < 2) { + return; + } + + for (int i = 0; i < availableStreams.size(); i++) { + final AudioStream audioStream = availableStreams.get(i); + audioTrackPopupMenu.getMenu().add(POPUP_MENU_ID_AUDIO_TRACK, i, Menu.NONE, + Localization.audioTrackName(context, audioStream)); + } + + player.getSelectedAudioStream() + .ifPresent(s -> binding.audioTrackTextView.setText( + Localization.audioTrackName(context, s))); + binding.audioTrackTextView.setVisibility(View.VISIBLE); + audioTrackPopupMenu.setOnMenuItemClickListener(this); + audioTrackPopupMenu.setOnDismissListener(this); + } + + private void buildPlaybackSpeedMenu() { + if (playbackSpeedPopupMenu == null) { + return; + } + playbackSpeedPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_PLAYBACK_SPEED); + + for (int i = 0; i < PLAYBACK_SPEEDS.length; i++) { + playbackSpeedPopupMenu.getMenu().add(POPUP_MENU_ID_PLAYBACK_SPEED, i, Menu.NONE, + formatSpeed(PLAYBACK_SPEEDS[i])); + } + binding.playbackSpeed.setText(formatSpeed(player.getPlaybackSpeed())); + playbackSpeedPopupMenu.setOnMenuItemClickListener(this); + playbackSpeedPopupMenu.setOnDismissListener(this); + } + + private void buildCaptionMenu(@NonNull final List availableLanguages) { + if (captionPopupMenu == null) { + return; + } + captionPopupMenu.getMenu().removeGroup(POPUP_MENU_ID_CAPTION); + + captionPopupMenu.setOnDismissListener(this); + + // Add option for turning off caption + final MenuItem captionOffItem = captionPopupMenu.getMenu().add(POPUP_MENU_ID_CAPTION, + 0, Menu.NONE, R.string.caption_none); + captionOffItem.setOnMenuItemClickListener(menuItem -> { + final int textRendererIndex = player.getCaptionRendererIndex(); + if (textRendererIndex != RENDERER_UNAVAILABLE) { + player.getTrackSelector().setParameters(player.getTrackSelector() + .buildUponParameters().setRendererDisabled(textRendererIndex, true)); + } + player.getPrefs().edit() + .remove(context.getString(R.string.caption_user_set_key)).apply(); + return true; + }); + + // Add all available captions + for (int i = 0; i < availableLanguages.size(); i++) { + final String captionLanguage = availableLanguages.get(i); + final MenuItem captionItem = captionPopupMenu.getMenu().add(POPUP_MENU_ID_CAPTION, + i + 1, Menu.NONE, captionLanguage); + captionItem.setOnMenuItemClickListener(menuItem -> { + final int textRendererIndex = player.getCaptionRendererIndex(); + if (textRendererIndex != RENDERER_UNAVAILABLE) { + // DefaultTrackSelector will select for text tracks in the following order. + // When multiple tracks share the same rank, a random track will be chosen. + // 1. ANY track exactly matching preferred language name + // 2. ANY track exactly matching preferred language stem + // 3. ROLE_FLAG_CAPTION track matching preferred language stem + // 4. ROLE_FLAG_DESCRIBES_MUSIC_AND_SOUND track matching preferred language stem + // This means if a caption track of preferred language is not available, + // then an auto-generated track of that language will be chosen automatically. + player.getTrackSelector().setParameters(player.getTrackSelector() + .buildUponParameters() + .setPreferredTextLanguages(captionLanguage, + PlayerHelper.captionLanguageStemOf(captionLanguage)) + .setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION) + .setRendererDisabled(textRendererIndex, false)); + player.getPrefs().edit().putString(context.getString( + R.string.caption_user_set_key), captionLanguage).apply(); + } + return true; + }); + } + captionPopupMenu.setOnDismissListener(this); + + // apply caption language from previous user preference + final int textRendererIndex = player.getCaptionRendererIndex(); + if (textRendererIndex == RENDERER_UNAVAILABLE) { + return; + } + + // If user prefers to show no caption, then disable the renderer. + // Otherwise, DefaultTrackSelector may automatically find an available caption + // and display that. + final String userPreferredLanguage = + player.getPrefs().getString(context.getString(R.string.caption_user_set_key), null); + if (userPreferredLanguage == null) { + player.getTrackSelector().setParameters(player.getTrackSelector().buildUponParameters() + .setRendererDisabled(textRendererIndex, true)); + return; + } + + // Only set preferred language if it does not match the user preference, + // otherwise there might be an infinite cycle at onTextTracksChanged. + final List selectedPreferredLanguages = + player.getTrackSelector().getParameters().preferredTextLanguages; + if (!selectedPreferredLanguages.contains(userPreferredLanguage)) { + player.getTrackSelector().setParameters(player.getTrackSelector().buildUponParameters() + .setPreferredTextLanguages(userPreferredLanguage, + PlayerHelper.captionLanguageStemOf(userPreferredLanguage)) + .setPreferredTextRoleFlags(C.ROLE_FLAG_CAPTION) + .setRendererDisabled(textRendererIndex, false)); + } + } + + protected abstract void onPlaybackSpeedClicked(); + + private void onQualityClicked() { + qualityPopupMenu.show(); + isSomePopupMenuVisible = true; + + player.getSelectedVideoStream() + .map(s -> MediaFormat.getNameById(s.getFormatId()) + " " + s.getResolution()) + .ifPresent(binding.qualityTextView::setText); + } + + private void onAudioTracksClicked() { + audioTrackPopupMenu.show(); + isSomePopupMenuVisible = true; + } + + /** + * Called when an item of the quality selector or the playback speed selector is selected. + */ + @Override + public boolean onMenuItemClick(@NonNull final MenuItem menuItem) { + if (DEBUG) { + Log.d(TAG, "onMenuItemClick() called with: " + + "menuItem = [" + menuItem + "], " + + "menuItem.getItemId = [" + menuItem.getItemId() + "]"); + } + + if (menuItem.getGroupId() == POPUP_MENU_ID_QUALITY) { + onQualityItemClick(menuItem); + return true; + } else if (menuItem.getGroupId() == POPUP_MENU_ID_AUDIO_TRACK) { + onAudioTrackItemClick(menuItem); + return true; + } else if (menuItem.getGroupId() == POPUP_MENU_ID_PLAYBACK_SPEED) { + final int speedIndex = menuItem.getItemId(); + final float speed = PLAYBACK_SPEEDS[speedIndex]; + + player.setPlaybackSpeed(speed); + binding.playbackSpeed.setText(formatSpeed(speed)); + } + + return false; + } + + private void onQualityItemClick(@NonNull final MenuItem menuItem) { + final int menuItemIndex = menuItem.getItemId(); + @Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata(); + if (currentMetadata == null || currentMetadata.getMaybeQuality().isEmpty()) { + return; + } + + final MediaItemTag.Quality quality = currentMetadata.getMaybeQuality().get(); + final List availableStreams = quality.getSortedVideoStreams(); + final int selectedStreamIndex = quality.getSelectedVideoStreamIndex(); + if (selectedStreamIndex == menuItemIndex || availableStreams.size() <= menuItemIndex) { + return; + } + + final String newResolution = availableStreams.get(menuItemIndex).getResolution(); + player.setPlaybackQuality(newResolution); + + binding.qualityTextView.setText(menuItem.getTitle()); + } + + private void onAudioTrackItemClick(@NonNull final MenuItem menuItem) { + final int menuItemIndex = menuItem.getItemId(); + @Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata(); + if (currentMetadata == null || currentMetadata.getMaybeAudioTrack().isEmpty()) { + return; + } + + final MediaItemTag.AudioTrack audioTrack = + currentMetadata.getMaybeAudioTrack().get(); + final List availableStreams = audioTrack.getAudioStreams(); + final int selectedStreamIndex = audioTrack.getSelectedAudioStreamIndex(); + if (selectedStreamIndex == menuItemIndex || availableStreams.size() <= menuItemIndex) { + return; + } + + final String newAudioTrack = availableStreams.get(menuItemIndex).getAudioTrackId(); + player.setAudioTrack(newAudioTrack); + + binding.audioTrackTextView.setText(menuItem.getTitle()); + } + + /** + * Called when some popup menu is dismissed. + */ + @Override + public void onDismiss(@Nullable final PopupMenu menu) { + if (DEBUG) { + Log.d(TAG, "onDismiss() called with: menu = [" + menu + "]"); + } + isSomePopupMenuVisible = false; //TODO check if this works + player.getSelectedVideoStream() + .ifPresent(s -> binding.qualityTextView.setText(s.getResolution())); + + if (player.isPlaying()) { + hideControls(DEFAULT_CONTROLS_DURATION, 0); + hideSystemUIIfNeeded(); + } + } + + private void onCaptionClicked() { + if (DEBUG) { + Log.d(TAG, "onCaptionClicked() called"); + } + captionPopupMenu.show(); + isSomePopupMenuVisible = true; + } + + public boolean isSomePopupMenuVisible() { + return isSomePopupMenuVisible; + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Captions (text tracks) + //////////////////////////////////////////////////////////////////////////*/ + //region Captions (text tracks) + + @Override + public void onTextTracksChanged(@NonNull final Tracks currentTracks) { + super.onTextTracksChanged(currentTracks); + + final boolean trackTypeTextSupported = !currentTracks.containsType(C.TRACK_TYPE_TEXT) + || currentTracks.isTypeSupported(C.TRACK_TYPE_TEXT, false); + if (getPlayer().getTrackSelector().getCurrentMappedTrackInfo() == null + || !trackTypeTextSupported) { + binding.captionTextView.setVisibility(View.GONE); + return; + } + + // Extract all loaded languages + final List textTracks = currentTracks + .getGroups() + .stream() + .filter(trackGroupInfo -> C.TRACK_TYPE_TEXT == trackGroupInfo.getType()) + .collect(Collectors.toList()); + final List availableLanguages = textTracks.stream() + .map(Tracks.Group::getMediaTrackGroup) + .filter(textTrack -> textTrack.length > 0) + .map(textTrack -> textTrack.getFormat(0).language) + .collect(Collectors.toList()); + + // Find selected text track + final Optional selectedTracks = textTracks.stream() + .filter(Tracks.Group::isSelected) + .filter(info -> info.getMediaTrackGroup().length >= 1) + .map(info -> info.getMediaTrackGroup().getFormat(0)) + .findFirst(); + + // Build UI + buildCaptionMenu(availableLanguages); + if (player.getTrackSelector().getParameters().getRendererDisabled( + player.getCaptionRendererIndex()) || selectedTracks.isEmpty()) { + binding.captionTextView.setText(R.string.caption_none); + } else { + binding.captionTextView.setText(selectedTracks.get().language); + } + binding.captionTextView.setVisibility( + availableLanguages.isEmpty() ? View.GONE : View.VISIBLE); + } + + @Override + public void onCues(@NonNull final List cues) { + super.onCues(cues); + binding.subtitleView.setCues(cues); + } + + private void setupSubtitleView() { + setupSubtitleView(PlayerHelper.getCaptionScale(context)); + final CaptionStyleCompat captionStyle = PlayerHelper.getCaptionStyle(context); + binding.subtitleView.setApplyEmbeddedStyles(captionStyle == CaptionStyleCompat.DEFAULT); + binding.subtitleView.setStyle(captionStyle); + } + + protected abstract void setupSubtitleView(float captionScale); + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Click listeners + //////////////////////////////////////////////////////////////////////////*/ + //region Click listeners + + /** + * Create on-click listener which manages the player controls after the view on-click action. + * + * @param runnable The action to be executed. + * @return The view click listener. + */ + protected View.OnClickListener makeOnClickListener(@NonNull final Runnable runnable) { + return v -> { + if (DEBUG) { + Log.d(TAG, "onClick() called with: v = [" + v + "]"); + } + + runnable.run(); + + // Manages the player controls after handling the view click. + if (player.getCurrentState() == STATE_COMPLETED) { + return; + } + controlsVisibilityHandler.removeCallbacksAndMessages(null); + showHideShadow(true, DEFAULT_CONTROLS_DURATION); + animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION, + AnimationType.ALPHA, 0, () -> { + if (player.getCurrentState() == STATE_PLAYING && !isSomePopupMenuVisible) { + if (v == binding.playPauseButton + // Hide controls in fullscreen immediately + || (v == binding.screenRotationButton && isFullscreen())) { + hideControls(0, 0); + } else { + hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME); + } + } + }); + }; + } + + public boolean onKeyDown(final int keyCode) { + switch (keyCode) { + case KeyEvent.KEYCODE_BACK: + if (DeviceUtils.isTv(context) && isControlsVisible()) { + hideControls(0, 0); + return true; + } + break; + case KeyEvent.KEYCODE_DPAD_UP: + case KeyEvent.KEYCODE_DPAD_LEFT: + case KeyEvent.KEYCODE_DPAD_DOWN: + case KeyEvent.KEYCODE_DPAD_RIGHT: + case KeyEvent.KEYCODE_DPAD_CENTER: + if ((binding.getRoot().hasFocus() && !binding.playbackControlRoot.hasFocus()) + || isAnyListViewOpen()) { + // do not interfere with focus in playlist and play queue etc. + break; + } + + if (player.getCurrentState() == org.schabi.newpipe.player.Player.STATE_BLOCKED) { + return true; + } + + if (isControlsVisible()) { + hideControls(DEFAULT_CONTROLS_DURATION, DPAD_CONTROLS_HIDE_TIME); + } else { + binding.playPauseButton.requestFocus(); + showControlsThenHide(); + showSystemUIPartially(); + return true; + } + break; + default: + break; // ignore other keys + } + + return false; + } + + private void onMoreOptionsClicked() { + if (DEBUG) { + Log.d(TAG, "onMoreOptionsClicked() called"); + } + + final boolean isMoreControlsVisible = + binding.secondaryControls.getVisibility() == View.VISIBLE; + + animateRotation(binding.moreOptionsButton, DEFAULT_CONTROLS_DURATION, + isMoreControlsVisible ? 0 : 180); + animate(binding.secondaryControls, !isMoreControlsVisible, DEFAULT_CONTROLS_DURATION, + AnimationType.SLIDE_AND_ALPHA, 0, () -> { + // Fix for a ripple effect on background drawable. + // When view returns from GONE state it takes more milliseconds than returning + // from INVISIBLE state. And the delay makes ripple background end to fast + if (isMoreControlsVisible) { + binding.secondaryControls.setVisibility(View.INVISIBLE); + } + }); + showControls(DEFAULT_CONTROLS_DURATION); + } + + private void onPlayWithKodiClicked() { + if (player.getCurrentMetadata() != null) { + player.pause(); + KoreUtils.playWithKore(context, Uri.parse(player.getVideoUrl())); + } + } + + private void onOpenInBrowserClicked() { + player.getCurrentStreamInfo().ifPresent(streamInfo -> + ShareUtils.openUrlInBrowser(player.getContext(), streamInfo.getOriginalUrl())); + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Video size + //////////////////////////////////////////////////////////////////////////*/ + //region Video size + + protected void setResizeMode(@AspectRatioFrameLayout.ResizeMode final int resizeMode) { + binding.surfaceView.setResizeMode(resizeMode); + binding.resizeTextView.setText(PlayerHelper.resizeTypeOf(context, resizeMode)); + } + + void onResizeClicked() { + setResizeMode(nextResizeModeAndSaveToPrefs(player, binding.surfaceView.getResizeMode())); + } + + @Override + public void onVideoSizeChanged(@NonNull final VideoSize videoSize) { + super.onVideoSizeChanged(videoSize); + binding.surfaceView.setAspectRatio(((float) videoSize.width) / videoSize.height); + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // SurfaceHolderCallback helpers + //////////////////////////////////////////////////////////////////////////*/ + //region SurfaceHolderCallback helpers + + /** + * Connects the video surface to the exo player. This can be called anytime without the risk for + * issues to occur, since the player will run just fine when no surface is connected. Therefore + * the video surface will be setup only when all of these conditions are true: it is not already + * setup (this just prevents wasting resources to setup the surface again), there is an exo + * player, the root view is attached to a parent and the surface view is valid/unreleased (the + * latter two conditions prevent "The surface has been released" errors). So this function can + * be called many times and even while the UI is in unready states. + */ + public void setupVideoSurfaceIfNeeded() { + if (!surfaceIsSetup && player.getExoPlayer() != null + && binding.getRoot().getParent() != null) { + // make sure there is nothing left over from previous calls + clearVideoSurface(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // >=API23 + surfaceHolderCallback = new SurfaceHolderCallback(context, player.getExoPlayer()); + binding.surfaceView.getHolder().addCallback(surfaceHolderCallback); + + // ensure player is using an unreleased surface, which the surfaceView might not be + // when starting playback on background or during player switching + if (binding.surfaceView.getHolder().getSurface().isValid()) { + // initially set the surface manually otherwise + // onRenderedFirstFrame() will not be called + player.getExoPlayer().setVideoSurfaceHolder(binding.surfaceView.getHolder()); + } + } else { + player.getExoPlayer().setVideoSurfaceView(binding.surfaceView); + } + + surfaceIsSetup = true; + } + } + + private void clearVideoSurface() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M // >=API23 + && surfaceHolderCallback != null) { + binding.surfaceView.getHolder().removeCallback(surfaceHolderCallback); + surfaceHolderCallback.release(); + surfaceHolderCallback = null; + } + Optional.ofNullable(player.getExoPlayer()).ifPresent(ExoPlayer::clearVideoSurface); + surfaceIsSetup = false; + } + //endregion + + + /*////////////////////////////////////////////////////////////////////////// + // Getters + //////////////////////////////////////////////////////////////////////////*/ + //region Getters + + public PlayerBinding getBinding() { + return binding; + } + + public GestureDetector getGestureDetector() { + return gestureDetector; + } + //endregion +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java index 70ac5cdcc..ef0e8670c 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java @@ -44,7 +44,13 @@ public class AppearanceSettingsFragment extends BasePreferenceFragment { return false; }); } else { - removePreference(nightThemeKey); + // disable the night theme selection + final Preference preference = findPreference(nightThemeKey); + if (preference != null) { + preference.setEnabled(false); + preference.setSummary(getString(R.string.night_theme_available, + getString(R.string.auto_device_theme_title))); + } } } @@ -61,20 +67,13 @@ public class AppearanceSettingsFragment extends BasePreferenceFragment { return super.onPreferenceTreeClick(preference); } - private void removePreference(final String preferenceKey) { - final Preference preference = findPreference(preferenceKey); - if (preference != null) { - getPreferenceScreen().removePreference(preference); - } - } - private void applyThemeChange(final String beginningThemeKey, final String themeKey, final Object newValue) { defaultPreferences.edit().putBoolean(Constants.KEY_THEME_CHANGE, true).apply(); defaultPreferences.edit().putString(themeKey, newValue.toString()).apply(); - ThemeHelper.setDayNightMode(getContext(), newValue.toString()); + ThemeHelper.setDayNightMode(requireContext(), newValue.toString()); if (!newValue.equals(beginningThemeKey) && getActivity() != null) { // if it's not the current theme diff --git a/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java new file mode 100644 index 000000000..bc24fbe81 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java @@ -0,0 +1,271 @@ +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.app.AlertDialog; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Bundle; +import android.widget.Toast; + +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; +import androidx.preference.Preference; +import androidx.preference.PreferenceManager; + +import org.schabi.newpipe.NewPipeDatabase; +import org.schabi.newpipe.R; +import org.schabi.newpipe.error.ErrorUtil; +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.ZipHelper; + +import java.io.File; +import java.io.IOException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.Objects; + +public class BackupRestoreSettingsFragment extends BasePreferenceFragment { + + private static final String ZIP_MIME_TYPE = "application/zip"; + + private final SimpleDateFormat exportDateFormat = + new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US); + private ContentSettingsManager manager; + private String importExportDataPathKey; + private final ActivityResultLauncher requestImportPathLauncher = + registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), + this::requestImportPathResult); + private final ActivityResultLauncher requestExportPathLauncher = + registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), + this::requestExportPathResult); + + + @Override + public void onCreatePreferences(@Nullable final Bundle savedInstanceState, + @Nullable final String rootKey) { + final File homeDir = ContextCompat.getDataDir(requireContext()); + Objects.requireNonNull(homeDir); + manager = new ContentSettingsManager(new NewPipeFileLocator(homeDir)); + manager.deleteSettingsFile(); + + importExportDataPathKey = getString(R.string.import_export_data_path); + + + addPreferencesFromResourceRegistry(); + + final Preference importDataPreference = requirePreference(R.string.import_data); + importDataPreference.setOnPreferenceClickListener((Preference p) -> { + NoFileManagerSafeGuard.launchSafe( + requestImportPathLauncher, + StoredFileHelper.getPicker(requireContext(), + ZIP_MIME_TYPE, getImportExportDataUri()), + TAG, + getContext() + ); + + return true; + }); + + final Preference exportDataPreference = requirePreference(R.string.export_data); + exportDataPreference.setOnPreferenceClickListener((final Preference p) -> { + NoFileManagerSafeGuard.launchSafe( + requestExportPathLauncher, + StoredFileHelper.getNewPicker(requireContext(), + "NewPipeData-" + exportDateFormat.format(new Date()) + ".zip", + ZIP_MIME_TYPE, getImportExportDataUri()), + TAG, + getContext() + ); + + return true; + }); + + final Preference resetSettings = findPreference(getString(R.string.reset_settings)); + // Resets all settings by deleting shared preference and restarting the app + // A dialogue will pop up to confirm if user intends to reset all settings + assert resetSettings != null; + resetSettings.setOnPreferenceClickListener(preference -> { + // Show Alert Dialogue + final AlertDialog.Builder builder = new AlertDialog.Builder(getContext()); + builder.setMessage(R.string.reset_all_settings); + builder.setCancelable(true); + builder.setPositiveButton(R.string.ok, (dialogInterface, i) -> { + // Deletes all shared preferences xml files. + final SharedPreferences sharedPreferences = + PreferenceManager.getDefaultSharedPreferences(requireContext()); + sharedPreferences.edit().clear().apply(); + // Restarts the app + if (getActivity() == null) { + return; + } + NavigationHelper.restartApp(getActivity()); + }); + builder.setNegativeButton(R.string.cancel, (dialogInterface, i) -> { + }); + final AlertDialog alertDialog = builder.create(); + alertDialog.show(); + return true; + }); + } + + private void requestExportPathResult(final ActivityResult result) { + assureCorrectAppLanguage(requireContext()); + if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { + // will be saved only on success + final Uri lastExportDataUri = result.getData().getData(); + + final StoredFileHelper file = new StoredFileHelper( + requireContext(), result.getData().getData(), ZIP_MIME_TYPE); + + exportDatabase(file, lastExportDataUri); + } + } + + private void requestImportPathResult(final ActivityResult result) { + assureCorrectAppLanguage(requireContext()); + if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { + // will be saved only on success + final Uri lastImportDataUri = result.getData().getData(); + + final StoredFileHelper file = new StoredFileHelper( + requireContext(), result.getData().getData(), ZIP_MIME_TYPE); + + new androidx.appcompat.app.AlertDialog.Builder(requireActivity()) + .setMessage(R.string.override_current_data) + .setPositiveButton(R.string.ok, (d, id) -> + importDatabase(file, lastImportDataUri)) + .setNegativeButton(R.string.cancel, (d, id) -> + d.cancel()) + .show(); + } + } + + private void exportDatabase(final StoredFileHelper file, final Uri exportDataUri) { + try { + //checkpoint before export + NewPipeDatabase.checkpoint(); + + final SharedPreferences preferences = PreferenceManager + .getDefaultSharedPreferences(requireContext()); + manager.exportDatabase(preferences, file); + + saveLastImportExportDataUri(exportDataUri); // save export path only on success + Toast.makeText(requireContext(), R.string.export_complete_toast, Toast.LENGTH_SHORT) + .show(); + } catch (final Exception e) { + ErrorUtil.showUiErrorSnackbar(this, "Exporting database", e); + } + } + + private void importDatabase(final StoredFileHelper file, final Uri importDataUri) { + // check if file is supported + if (!ZipHelper.isValidZipFile(file)) { + Toast.makeText(requireContext(), R.string.no_valid_zip_file, Toast.LENGTH_SHORT) + .show(); + return; + } + + try { + if (!manager.ensureDbDirectoryExists()) { + throw new IOException("Could not create databases dir"); + } + + if (!manager.extractDb(file)) { + Toast.makeText(requireContext(), R.string.could_not_import_all_files, + Toast.LENGTH_LONG) + .show(); + } + + // if settings file exist, ask if it should be imported. + if (manager.extractSettings(file)) { + new androidx.appcompat.app.AlertDialog.Builder(requireContext()) + .setTitle(R.string.import_settings) + .setNegativeButton(R.string.cancel, (dialog, which) -> { + dialog.dismiss(); + finishImport(importDataUri); + }) + .setPositiveButton(R.string.ok, (dialog, which) -> { + dialog.dismiss(); + final Context context = requireContext(); + final SharedPreferences prefs = PreferenceManager + .getDefaultSharedPreferences(context); + manager.loadSharedPreferences(prefs); + cleanImport(context, prefs); + finishImport(importDataUri); + }) + .show(); + } else { + finishImport(importDataUri); + } + } catch (final Exception e) { + ErrorUtil.showUiErrorSnackbar(this, "Importing database", e); + } + } + + /** + * Remove settings that are not supposed to be imported on different devices + * and reset them to default values. + * @param context the context used for the import + * @param prefs the preferences used while running the import + */ + private void cleanImport(@NonNull final Context context, + @NonNull final SharedPreferences prefs) { + // Check if media tunnelling needs to be disabled automatically, + // if it was disabled automatically in the imported preferences. + final String tunnelingKey = context.getString(R.string.disable_media_tunneling_key); + final String automaticTunnelingKey = + context.getString(R.string.disabled_media_tunneling_automatically_key); + // R.string.disable_media_tunneling_key should always be true + // if R.string.disabled_media_tunneling_automatically_key equals 1, + // but we double check here just to be sure and to avoid regressions + // caused by possible later modification of the media tunneling functionality. + // R.string.disabled_media_tunneling_automatically_key == 0: + // automatic value overridden by user in settings + // R.string.disabled_media_tunneling_automatically_key == -1: not set + final boolean wasMediaTunnelingDisabledAutomatically = + prefs.getInt(automaticTunnelingKey, -1) == 1 + && prefs.getBoolean(tunnelingKey, false); + if (wasMediaTunnelingDisabledAutomatically) { + prefs.edit() + .putInt(automaticTunnelingKey, -1) + .putBoolean(tunnelingKey, false) + .apply(); + NewPipeSettings.setMediaTunneling(context); + } + } + + /** + * Save import path and restart system. + * + * @param importDataUri The import path to save + */ + private void finishImport(final Uri importDataUri) { + // save import path only on success + saveLastImportExportDataUri(importDataUri); + // restart app to properly load db + NavigationHelper.restartApp(requireActivity()); + } + + private Uri getImportExportDataUri() { + final String path = defaultPreferences.getString(importExportDataPathKey, null); + return isBlank(path) ? null : Uri.parse(path); + } + + private void saveLastImportExportDataUri(final Uri importExportDataUri) { + final SharedPreferences.Editor editor = defaultPreferences.edit() + .putString(importExportDataPathKey, importExportDataUri.toString()); + editor.apply(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java index 47458ad3f..ec2bed67a 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java @@ -1,112 +1,47 @@ 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; -import android.content.SharedPreferences; -import android.net.Uri; import android.os.Bundle; import android.util.Log; import android.widget.Toast; -import androidx.activity.result.ActivityResult; -import androidx.activity.result.ActivityResultLauncher; -import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; -import androidx.appcompat.app.AlertDialog; -import androidx.core.content.ContextCompat; import androidx.preference.Preference; -import androidx.preference.PreferenceManager; import org.schabi.newpipe.DownloaderImpl; -import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.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; -import org.schabi.newpipe.util.ZipHelper; +import org.schabi.newpipe.util.image.ImageStrategy; +import org.schabi.newpipe.util.image.PicassoHelper; +import org.schabi.newpipe.util.image.PreferredImageQuality; -import java.io.File; import java.io.IOException; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.Locale; -import java.util.Objects; public class ContentSettingsFragment extends BasePreferenceFragment { - private static final String ZIP_MIME_TYPE = "application/zip"; - - private final SimpleDateFormat exportDateFormat - = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US); - - private ContentSettingsManager manager; - - private String importExportDataPathKey; private String youtubeRestrictedModeEnabledKey; private Localization initialSelectedLocalization; private ContentCountry initialSelectedContentCountry; private String initialLanguage; - private final ActivityResultLauncher requestImportPathLauncher = - registerForActivityResult(new StartActivityForResult(), this::requestImportPathResult); - private final ActivityResultLauncher requestExportPathLauncher = - registerForActivityResult(new StartActivityForResult(), this::requestExportPathResult); @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - final File homeDir = ContextCompat.getDataDir(requireContext()); - Objects.requireNonNull(homeDir); - manager = new ContentSettingsManager(new NewPipeFileLocator(homeDir)); - manager.deleteSettingsFile(); - - importExportDataPathKey = getString(R.string.import_export_data_path); youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled); addPreferencesFromResourceRegistry(); - final Preference importDataPreference = requirePreference(R.string.import_data); - importDataPreference.setOnPreferenceClickListener((Preference p) -> { - NoFileManagerSafeGuard.launchSafe( - requestImportPathLauncher, - StoredFileHelper.getPicker(requireContext(), - ZIP_MIME_TYPE, getImportExportDataUri()), - TAG, - getContext() - ); - - return true; - }); - - final Preference exportDataPreference = requirePreference(R.string.export_data); - exportDataPreference.setOnPreferenceClickListener((final Preference p) -> { - NoFileManagerSafeGuard.launchSafe( - requestExportPathLauncher, - StoredFileHelper.getNewPicker(requireContext(), - "NewPipeData-" + exportDateFormat.format(new Date()) + ".zip", - ZIP_MIME_TYPE, getImportExportDataUri()), - TAG, - getContext() - ); - - return true; - }); - initialSelectedLocalization = org.schabi.newpipe.util.Localization .getPreferredLocalization(requireContext()); initialSelectedContentCountry = org.schabi.newpipe.util.Localization .getPreferredContentCountry(requireContext()); initialLanguage = defaultPreferences.getString(getString(R.string.app_language_key), "en"); - findPreference(getString(R.string.download_thumbnail_key)).setOnPreferenceChangeListener( + final Preference imageQualityPreference = requirePreference(R.string.image_quality_key); + imageQualityPreference.setOnPreferenceChangeListener( (preference, newValue) -> { - PicassoHelper.setShouldLoadImages((Boolean) newValue); + ImageStrategy.setPreferredImageQuality(PreferredImageQuality + .fromPreferenceKey(requireContext(), (String) newValue)); try { PicassoHelper.clearCache(preference.getContext()); Toast.makeText(preference.getContext(), @@ -153,118 +88,4 @@ public class ContentSettingsFragment extends BasePreferenceFragment { NewPipe.setupLocalization(selectedLocalization, selectedContentCountry); } } - - private void requestExportPathResult(final ActivityResult result) { - assureCorrectAppLanguage(getContext()); - if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { - // will be saved only on success - final Uri lastExportDataUri = result.getData().getData(); - - final StoredFileHelper file - = new StoredFileHelper(getContext(), result.getData().getData(), ZIP_MIME_TYPE); - - exportDatabase(file, lastExportDataUri); - } - } - - private void requestImportPathResult(final ActivityResult result) { - assureCorrectAppLanguage(getContext()); - if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { - // will be saved only on success - final Uri lastImportDataUri = result.getData().getData(); - - final StoredFileHelper file - = new StoredFileHelper(getContext(), result.getData().getData(), ZIP_MIME_TYPE); - - new AlertDialog.Builder(requireActivity()) - .setMessage(R.string.override_current_data) - .setPositiveButton(R.string.ok, (d, id) -> - importDatabase(file, lastImportDataUri)) - .setNegativeButton(R.string.cancel, (d, id) -> - d.cancel()) - .create() - .show(); - } - } - - private void exportDatabase(final StoredFileHelper file, final Uri exportDataUri) { - try { - //checkpoint before export - NewPipeDatabase.checkpoint(); - - final SharedPreferences preferences = PreferenceManager - .getDefaultSharedPreferences(requireContext()); - manager.exportDatabase(preferences, file); - - saveLastImportExportDataUri(exportDataUri); // save export path only on success - Toast.makeText(getContext(), R.string.export_complete_toast, Toast.LENGTH_SHORT).show(); - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Exporting database", e); - } - } - - private void importDatabase(final StoredFileHelper file, final Uri importDataUri) { - // check if file is supported - if (!ZipHelper.isValidZipFile(file)) { - Toast.makeText(getContext(), R.string.no_valid_zip_file, Toast.LENGTH_SHORT) - .show(); - return; - } - - try { - if (!manager.ensureDbDirectoryExists()) { - throw new IOException("Could not create databases dir"); - } - - if (!manager.extractDb(file)) { - Toast.makeText(getContext(), R.string.could_not_import_all_files, Toast.LENGTH_LONG) - .show(); - } - - // if settings file exist, ask if it should be imported. - if (manager.extractSettings(file)) { - final AlertDialog.Builder alert = new AlertDialog.Builder(requireContext()); - alert.setTitle(R.string.import_settings); - - alert.setNegativeButton(R.string.cancel, (dialog, which) -> { - dialog.dismiss(); - finishImport(importDataUri); - }); - alert.setPositiveButton(R.string.ok, (dialog, which) -> { - dialog.dismiss(); - manager.loadSharedPreferences(PreferenceManager - .getDefaultSharedPreferences(requireContext())); - finishImport(importDataUri); - }); - alert.show(); - } else { - finishImport(importDataUri); - } - } catch (final Exception e) { - ErrorUtil.showUiErrorSnackbar(this, "Importing database", e); - } - } - - /** - * Save import path and restart system. - * - * @param importDataUri The import path to save - */ - private void finishImport(final Uri importDataUri) { - // save import path only on success - saveLastImportExportDataUri(importDataUri); - // restart app to properly load db - NavigationHelper.restartApp(requireActivity()); - } - - private Uri getImportExportDataUri() { - final String path = defaultPreferences.getString(importExportDataPathKey, null); - return isBlank(path) ? null : Uri.parse(path); - } - - private void saveLastImportExportDataUri(final Uri importExportDataUri) { - final SharedPreferences.Editor editor = defaultPreferences.edit() - .putString(importExportDataPathKey, importExportDataUri.toString()); - editor.apply(); - } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsManager.kt b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsManager.kt index 3ac275695..df56de516 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsManager.kt +++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsManager.kt @@ -2,12 +2,10 @@ package org.schabi.newpipe.settings import android.content.SharedPreferences import android.util.Log +import org.schabi.newpipe.MainActivity.DEBUG import org.schabi.newpipe.streams.io.SharpOutputStream import org.schabi.newpipe.streams.io.StoredFileHelper import org.schabi.newpipe.util.ZipHelper -import java.io.BufferedOutputStream -import java.io.FileInputStream -import java.io.FileOutputStream import java.io.IOException import java.io.ObjectInputStream import java.io.ObjectOutputStream @@ -25,17 +23,19 @@ class ContentSettingsManager(private val fileLocator: NewPipeFileLocator) { @Throws(Exception::class) fun exportDatabase(preferences: SharedPreferences, file: StoredFileHelper) { file.create() - ZipOutputStream(BufferedOutputStream(SharpOutputStream(file.stream))) + ZipOutputStream(SharpOutputStream(file.stream).buffered()) .use { outZip -> ZipHelper.addFileToZip(outZip, fileLocator.db.path, "newpipe.db") try { - ObjectOutputStream(FileOutputStream(fileLocator.settings)).use { output -> + ObjectOutputStream(fileLocator.settings.outputStream()).use { output -> output.writeObject(preferences.all) output.flush() } } catch (e: IOException) { - Log.e(TAG, "Unable to exportDatabase", e) + if (DEBUG) { + Log.e(TAG, "Unable to exportDatabase", e) + } } ZipHelper.addFileToZip(outZip, fileLocator.settings.path, "newpipe.settings") @@ -70,11 +70,14 @@ class ContentSettingsManager(private val fileLocator: NewPipeFileLocator) { return ZipHelper.extractFileFromZip(file, fileLocator.settings.path, "newpipe.settings") } + /** + * Remove all shared preferences from the app and load the preferences supplied to the manager. + */ fun loadSharedPreferences(preferences: SharedPreferences) { try { val preferenceEditor = preferences.edit() - ObjectInputStream(FileInputStream(fileLocator.settings)).use { input -> + ObjectInputStream(fileLocator.settings.inputStream()).use { input -> preferenceEditor.clear() @Suppress("UNCHECKED_CAST") val entries = input.readObject() as Map @@ -105,9 +108,13 @@ class ContentSettingsManager(private val fileLocator: NewPipeFileLocator) { preferenceEditor.commit() } } catch (e: IOException) { - Log.e(TAG, "Unable to loadSharedPreferences", e) + if (DEBUG) { + Log.e(TAG, "Unable to loadSharedPreferences", e) + } } catch (e: ClassNotFoundException) { - Log.e(TAG, "Unable to loadSharedPreferences", e) + if (DEBUG) { + Log.e(TAG, "Unable to loadSharedPreferences", e) + } } } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java index dd9f5fb1f..d78ade49d 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java @@ -9,8 +9,8 @@ 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 org.schabi.newpipe.local.feed.notifications.NotificationWorker; +import org.schabi.newpipe.util.image.PicassoHelper; import java.util.Optional; @@ -21,20 +21,20 @@ public class DebugSettingsFragment extends BasePreferenceFragment { 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 checkNewStreamsPreference - = findPreference(getString(R.string.check_new_streams_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)); + 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 checkNewStreamsPreference = + findPreference(getString(R.string.check_new_streams_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; diff --git a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java index ec98b865e..472db6afe 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java @@ -1,5 +1,8 @@ package org.schabi.newpipe.settings; +import static org.schabi.newpipe.extractor.utils.Utils.decodeUrlUtf8; +import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; + import android.app.Activity; import android.content.ContentResolver; import android.content.Context; @@ -29,10 +32,6 @@ import java.io.File; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.net.URI; -import java.net.URLDecoder; -import java.nio.charset.StandardCharsets; - -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; public class DownloadSettingsFragment extends BasePreferenceFragment { public static final boolean IGNORE_RELEASE_ON_OLD_PATH = true; @@ -66,16 +65,10 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { prefStorageAsk = findPreference(downloadStorageAsk); final SwitchPreferenceCompat prefUseSaf = findPreference(storageUseSafPreference); - prefUseSaf.setDefaultValue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP); prefUseSaf.setChecked(NewPipeSettings.useStorageAccessFramework(ctx)); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q - || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { prefUseSaf.setEnabled(false); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - prefUseSaf.setSummary(R.string.downloads_storage_use_saf_summary_api_29); - } else { - prefUseSaf.setSummary(R.string.downloads_storage_use_saf_summary_api_19); - } + prefUseSaf.setSummary(R.string.downloads_storage_use_saf_summary_api_29); prefStorageAsk.setSummary(R.string.downloads_storage_ask_summary_no_saf_notice); } @@ -131,7 +124,7 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { } try { - rawUri = URLDecoder.decode(rawUri, StandardCharsets.UTF_8.name()); + rawUri = decodeUrlUtf8(rawUri); } catch (final UnsupportedEncodingException e) { // nothing to do } @@ -177,11 +170,11 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { } private void showMessageDialog(@StringRes final int title, @StringRes final int message) { - final AlertDialog.Builder msg = new AlertDialog.Builder(ctx); - msg.setTitle(title); - msg.setMessage(message); - msg.setPositiveButton(getString(R.string.ok), null); - msg.show(); + new AlertDialog.Builder(ctx) + .setTitle(title) + .setMessage(message) + .setPositiveButton(getString(R.string.ok), null) + .show(); } @Override @@ -253,8 +246,7 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { forgetSAFTree(context, defaultPreferences.getString(key, "")); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP - && !FilePickerActivityHelper.isOwnFileUri(context, uri)) { + if (!FilePickerActivityHelper.isOwnFileUri(context, uri)) { // steps to acquire the selected path: // 1. acquire permissions on the new save path // 2. save the new path, if step(2) was successful @@ -262,8 +254,8 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { context.grantUriPermission(context.getPackageName(), uri, StoredDirectoryHelper.PERMISSION_FLAGS); - final StoredDirectoryHelper mainStorage - = new StoredDirectoryHelper(context, uri, null); + final StoredDirectoryHelper mainStorage = + new StoredDirectoryHelper(context, uri, null); Log.i(TAG, "Acquiring tree success from " + uri.toString()); if (!mainStorage.canWrite()) { diff --git a/app/src/main/java/org/schabi/newpipe/settings/ExoPlayerSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ExoPlayerSettingsFragment.java new file mode 100644 index 000000000..14dd0c409 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/ExoPlayerSettingsFragment.java @@ -0,0 +1,45 @@ +package org.schabi.newpipe.settings; + +import android.content.SharedPreferences; +import android.os.Bundle; + +import androidx.annotation.Nullable; +import androidx.preference.Preference; +import androidx.preference.PreferenceManager; +import androidx.preference.SwitchPreferenceCompat; + +import org.schabi.newpipe.R; + +public class ExoPlayerSettingsFragment extends BasePreferenceFragment { + + @Override + public void onCreatePreferences(@Nullable final Bundle savedInstanceState, + @Nullable final String rootKey) { + addPreferencesFromResourceRegistry(); + + final String disabledMediaTunnelingAutomaticallyKey = + getString(R.string.disabled_media_tunneling_automatically_key); + final SwitchPreferenceCompat disableMediaTunnelingPref = + (SwitchPreferenceCompat) requirePreference(R.string.disable_media_tunneling_key); + final SharedPreferences prefs = PreferenceManager + .getDefaultSharedPreferences(requireContext()); + final boolean mediaTunnelingAutomaticallyDisabled = + prefs.getInt(disabledMediaTunnelingAutomaticallyKey, -1) == 1; + final String summaryText = getString(R.string.disable_media_tunneling_summary); + disableMediaTunnelingPref.setSummary(mediaTunnelingAutomaticallyDisabled + ? summaryText + " " + getString(R.string.disable_media_tunneling_automatic_info) + : summaryText); + + disableMediaTunnelingPref.setOnPreferenceChangeListener((Preference p, Object enabled) -> { + if (Boolean.FALSE.equals(enabled)) { + PreferenceManager.getDefaultSharedPreferences(requireContext()) + .edit() + .putInt(disabledMediaTunnelingAutomaticallyKey, 0) + .apply(); + // the info text might have been shown before + p.setSummary(R.string.disable_media_tunneling_summary); + } + return true; + }); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java index 86e651e2b..9bc9058c8 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java @@ -132,7 +132,6 @@ public class HistorySettingsFragment extends BasePreferenceFragment { disposables.add(getWholeStreamHistoryDisposable(context, recordManager)); disposables.add(getRemoveOrphanedRecordsDisposable(context, recordManager)); })) - .create() .show(); } @@ -144,7 +143,6 @@ public class HistorySettingsFragment extends BasePreferenceFragment { .setNegativeButton(R.string.cancel, ((dialog, which) -> dialog.dismiss())) .setPositiveButton(R.string.delete, ((dialog, which) -> disposables.add(getDeletePlaybackStatesDisposable(context, recordManager)))) - .create() .show(); } @@ -156,7 +154,6 @@ public class HistorySettingsFragment extends BasePreferenceFragment { .setNegativeButton(R.string.cancel, ((dialog, which) -> dialog.dismiss())) .setPositiveButton(R.string.delete, ((dialog, which) -> disposables.add(getDeleteSearchHistoryDisposable(context, recordManager)))) - .create() .show(); } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java index 3776d78f6..32e33d55b 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java @@ -23,7 +23,7 @@ public class MainSettingsFragment extends BasePreferenceFragment { setHasOptionsMenu(true); // Otherwise onCreateOptionsMenu is not called // Check if the app is updatable - if (!ReleaseVersionUtil.isReleaseApk()) { + if (!ReleaseVersionUtil.INSTANCE.isReleaseApk()) { getPreferenceScreen().removePreference( findPreference(getString(R.string.update_pref_screen_key))); diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java index 1e1d08856..421440ea7 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java +++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.settings; +import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; + import android.content.Context; import android.content.SharedPreferences; import android.os.Build; @@ -9,14 +11,13 @@ import androidx.annotation.NonNull; import androidx.annotation.StringRes; import androidx.preference.PreferenceManager; +import org.schabi.newpipe.App; import org.schabi.newpipe.R; import org.schabi.newpipe.util.DeviceUtils; import java.io.File; import java.util.Set; -import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; - /* * Created by k3b on 07.01.2016. * @@ -44,24 +45,8 @@ public final class NewPipeSettings { private NewPipeSettings() { } public static void initSettings(final Context context) { - // check if there are entries in the prefs to determine whether this is the first app run - Boolean isFirstRun = null; - final Set prefsKeys = PreferenceManager.getDefaultSharedPreferences(context) - .getAll().keySet(); - for (final String key: prefsKeys) { - // ACRA stores some info in the prefs during app initialization - // which happens before this method is called. Therefore ignore ACRA-related keys. - if (!key.toLowerCase().startsWith("acra")) { - isFirstRun = false; - break; - } - } - if (isFirstRun == null) { - isFirstRun = true; - } - // first run migrations, then setDefaultValues, since the latter requires the correct types - SettingMigrations.initMigrations(context, isFirstRun); + SettingMigrations.runMigrationsIfNeeded(context); // readAgain is true so that if new settings are added their default value is set PreferenceManager.setDefaultValues(context, R.xml.main_settings, true); @@ -73,9 +58,12 @@ public final class NewPipeSettings { PreferenceManager.setDefaultValues(context, R.xml.player_notification_settings, true); PreferenceManager.setDefaultValues(context, R.xml.update_settings, true); PreferenceManager.setDefaultValues(context, R.xml.debug_settings, true); + PreferenceManager.setDefaultValues(context, R.xml.backup_restore_settings, true); saveDefaultVideoDownloadDirectory(context); saveDefaultAudioDownloadDirectory(context); + + disableMediaTunnelingIfNecessary(context); } static void saveDefaultVideoDownloadDirectory(final Context context) { @@ -116,7 +104,7 @@ public final class NewPipeSettings { public static boolean useStorageAccessFramework(final Context context) { // There's a FireOS bug which prevents SAF open/close dialogs from being confirmed with a // remote (see #6455). - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP || DeviceUtils.isFireTv()) { + if (DeviceUtils.isFireTv()) { return false; } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return true; @@ -152,4 +140,48 @@ public final class NewPipeSettings { return showSearchSuggestions(context, sharedPreferences, R.string.show_remote_search_suggestions_key); } + + private static void disableMediaTunnelingIfNecessary(@NonNull final Context context) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + final String disabledTunnelingKey = context.getString(R.string.disable_media_tunneling_key); + final String disabledTunnelingAutomaticallyKey = + context.getString(R.string.disabled_media_tunneling_automatically_key); + final String blacklistVersionKey = + context.getString(R.string.media_tunneling_device_blacklist_version); + + final int lastMediaTunnelingUpdate = prefs.getInt(blacklistVersionKey, 0); + final boolean wasDeviceBlacklistUpdated = + DeviceUtils.MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION != lastMediaTunnelingUpdate; + final boolean wasMediaTunnelingEnabledByUser = + prefs.getInt(disabledTunnelingAutomaticallyKey, -1) == 0 + && !prefs.getBoolean(disabledTunnelingKey, false); + + if (App.getApp().isFirstRun() + || (wasDeviceBlacklistUpdated && !wasMediaTunnelingEnabledByUser)) { + setMediaTunneling(context); + } + } + + /** + * Check if device does not support media tunneling + * and disable that exoplayer feature if necessary. + * @see DeviceUtils#shouldSupportMediaTunneling() + * @param context + */ + public static void setMediaTunneling(@NonNull final Context context) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + if (!DeviceUtils.shouldSupportMediaTunneling()) { + prefs.edit() + .putBoolean(context.getString(R.string.disable_media_tunneling_key), true) + .putInt(context.getString( + R.string.disabled_media_tunneling_automatically_key), 1) + .putInt(context.getString(R.string.media_tunneling_device_blacklist_version), + DeviceUtils.MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION) + .apply(); + } else { + prefs.edit() + .putInt(context.getString(R.string.media_tunneling_device_blacklist_version), + DeviceUtils.MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION).apply(); + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/NotificationSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/NotificationSettingsFragment.kt index 6bea8b69e..11eb4fa33 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/NotificationSettingsFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/settings/NotificationSettingsFragment.kt @@ -1,19 +1,9 @@ package org.schabi.newpipe.settings -import android.os.Build import android.os.Bundle -import androidx.preference.Preference -import org.schabi.newpipe.R class NotificationSettingsFragment : BasePreferenceFragment() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResourceRegistry() - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - val colorizePref: Preference? = findPreference(getString(R.string.notification_colorize_key)) - colorizePref?.let { - preferenceScreen.removePreference(it) - } - } } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/NotificationsSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/NotificationsSettingsFragment.kt index e823c2fcf..fcc9abf73 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/NotificationsSettingsFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/settings/NotificationsSettingsFragment.kt @@ -26,6 +26,10 @@ class NotificationsSettingsFragment : BasePreferenceFragment(), OnSharedPreferen override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.notifications_settings) + + // main check is done in onResume, but also do it here to prevent flickering + preferenceScreen.isEnabled = + NotificationHelper.areNotificationsEnabledOnDevice(requireContext()) } override fun onStart() { @@ -64,7 +68,7 @@ class NotificationsSettingsFragment : BasePreferenceFragment(), OnSharedPreferen // If they are disabled, show a snackbar informing the user about that // while allowing them to open the device's app settings. val enabled = NotificationHelper.areNotificationsEnabledOnDevice(requireContext()) - preferenceScreen.isEnabled = enabled + preferenceScreen.isEnabled = enabled // it is disabled by default, see the xml if (!enabled) { if (notificationWarningSnackbar == null) { notificationWarningSnackbar = Snackbar.make( @@ -85,9 +89,6 @@ class NotificationsSettingsFragment : BasePreferenceFragment(), OnSharedPreferen show() } } - } else { - notificationWarningSnackbar?.dismiss() - notificationWarningSnackbar = null } // (Re-)Create loader @@ -102,6 +103,9 @@ class NotificationsSettingsFragment : BasePreferenceFragment(), OnSharedPreferen loader?.dispose() loader = null + notificationWarningSnackbar?.dismiss() + notificationWarningSnackbar = null + super.onPause() } diff --git a/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java b/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java index c7eb0be40..1158b3d83 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java @@ -12,28 +12,27 @@ import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; -import android.widget.ImageView; -import android.widget.ProgressBar; import android.widget.RadioButton; -import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.widget.AppCompatImageView; import androidx.fragment.app.Fragment; import androidx.preference.PreferenceManager; +import androidx.recyclerview.widget.DiffUtil; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.ListAdapter; import androidx.recyclerview.widget.RecyclerView; -import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.grack.nanojson.JsonStringWriter; import com.grack.nanojson.JsonWriter; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.DialogEditTextBinding; +import org.schabi.newpipe.databinding.FragmentInstanceListBinding; +import org.schabi.newpipe.databinding.ItemInstanceBinding; import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.PeertubeHelper; @@ -41,7 +40,6 @@ import org.schabi.newpipe.util.ThemeHelper; import java.util.ArrayList; import java.util.Collections; -import java.util.List; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Single; @@ -50,12 +48,11 @@ import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; public class PeertubeInstanceListFragment extends Fragment { - private final List instanceList = new ArrayList<>(); private PeertubeInstance selectedInstance; private String savedInstanceListKey; private InstanceListAdapter instanceListAdapter; - private ProgressBar progressBar; + private FragmentInstanceListBinding binding; private SharedPreferences sharedPreferences; private CompositeDisposable disposables = new CompositeDisposable(); @@ -71,7 +68,6 @@ public class PeertubeInstanceListFragment extends Fragment { sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()); savedInstanceListKey = getString(R.string.peertube_instance_list_key); selectedInstance = PeertubeHelper.getCurrentInstance(); - updateInstanceList(); setHasOptionsMenu(true); } @@ -79,7 +75,8 @@ public class PeertubeInstanceListFragment extends Fragment { @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_instance_list, container, false); + binding = FragmentInstanceListBinding.inflate(inflater, container, false); + return binding.getRoot(); } @Override @@ -87,26 +84,17 @@ public class PeertubeInstanceListFragment extends Fragment { @Nullable final Bundle savedInstanceState) { super.onViewCreated(rootView, savedInstanceState); - initViews(rootView); - } - - private void initViews(@NonNull final View rootView) { - final TextView instanceHelpTV = rootView.findViewById(R.id.instanceHelpTV); - instanceHelpTV.setText(getString(R.string.peertube_instance_url_help, + binding.instanceHelpTV.setText(getString(R.string.peertube_instance_url_help, getString(R.string.peertube_instance_list_url))); - - initButton(rootView); - - final RecyclerView listInstances = rootView.findViewById(R.id.instances); - listInstances.setLayoutManager(new LinearLayoutManager(requireContext())); + binding.addInstanceButton.setOnClickListener(v -> showAddItemDialog(requireContext())); + binding.instances.setLayoutManager(new LinearLayoutManager(requireContext())); final ItemTouchHelper itemTouchHelper = new ItemTouchHelper(getItemTouchCallback()); - itemTouchHelper.attachToRecyclerView(listInstances); + itemTouchHelper.attachToRecyclerView(binding.instances); instanceListAdapter = new InstanceListAdapter(requireContext(), itemTouchHelper); - listInstances.setAdapter(instanceListAdapter); - - progressBar = rootView.findViewById(R.id.loading_progress_bar); + binding.instances.setAdapter(instanceListAdapter); + instanceListAdapter.submitList(PeertubeHelper.getInstanceList(requireContext())); } @Override @@ -131,6 +119,12 @@ public class PeertubeInstanceListFragment extends Fragment { disposables = null; } + @Override + public void onDestroyView() { + binding = null; + super.onDestroyView(); + } + /*////////////////////////////////////////////////////////////////////////// // Menu //////////////////////////////////////////////////////////////////////////*/ @@ -156,11 +150,6 @@ public class PeertubeInstanceListFragment extends Fragment { // Utils //////////////////////////////////////////////////////////////////////////*/ - private void updateInstanceList() { - instanceList.clear(); - instanceList.addAll(PeertubeHelper.getInstanceList(requireContext())); - } - private void selectInstance(final PeertubeInstance instance) { selectedInstance = PeertubeHelper.selectInstance(instance, requireContext()); sharedPreferences.edit().putBoolean(Constants.KEY_MAIN_PAGE_CHANGE, true).apply(); @@ -168,7 +157,7 @@ public class PeertubeInstanceListFragment extends Fragment { private void saveChanges() { final JsonStringWriter jsonWriter = JsonWriter.string().object().array("instances"); - for (final PeertubeInstance instance : instanceList) { + for (final PeertubeInstance instance : instanceListAdapter.getCurrentList()) { jsonWriter.object(); jsonWriter.value("name", instance.getName()); jsonWriter.value("url", instance.getUrl()); @@ -179,35 +168,28 @@ public class PeertubeInstanceListFragment extends Fragment { } private void restoreDefaults() { - new AlertDialog.Builder(requireContext()) + final Context context = requireContext(); + new AlertDialog.Builder(context) .setTitle(R.string.restore_defaults) .setMessage(R.string.restore_defaults_confirmation) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok, (dialog, which) -> { sharedPreferences.edit().remove(savedInstanceListKey).apply(); selectInstance(PeertubeInstance.DEFAULT_INSTANCE); - updateInstanceList(); - instanceListAdapter.notifyDataSetChanged(); + instanceListAdapter.submitList(PeertubeHelper.getInstanceList(context)); }) .show(); } - private void initButton(final View rootView) { - final FloatingActionButton fab = rootView.findViewById(R.id.addInstanceButton); - fab.setOnClickListener(v -> - showAddItemDialog(requireContext())); - } - private void showAddItemDialog(final Context c) { - final DialogEditTextBinding dialogBinding - = DialogEditTextBinding.inflate(getLayoutInflater()); + final var dialogBinding = DialogEditTextBinding.inflate(getLayoutInflater()); dialogBinding.dialogEditText.setInputType( InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); dialogBinding.dialogEditText.setHint(R.string.peertube_instance_add_help); new AlertDialog.Builder(c) .setTitle(R.string.peertube_instance_add_title) - .setIcon(R.drawable.place_holder_peertube) + .setIcon(R.drawable.ic_placeholder_peertube) .setView(dialogBinding.getRoot()) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.ok, (dialog1, which) -> { @@ -222,17 +204,17 @@ public class PeertubeInstanceListFragment extends Fragment { if (cleanUrl == null) { return; } - progressBar.setVisibility(View.VISIBLE); + binding.loadingProgressBar.setVisibility(View.VISIBLE); final Disposable disposable = Single.fromCallable(() -> { final PeertubeInstance instance = new PeertubeInstance(cleanUrl); instance.fetchInstanceMetaData(); return instance; }).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()) .subscribe((instance) -> { - progressBar.setVisibility(View.GONE); + binding.loadingProgressBar.setVisibility(View.GONE); add(instance); }, e -> { - progressBar.setVisibility(View.GONE); + binding.loadingProgressBar.setVisibility(View.GONE); Toast.makeText(getActivity(), R.string.peertube_instance_add_fail, Toast.LENGTH_SHORT).show(); }); @@ -255,7 +237,7 @@ public class PeertubeInstanceListFragment extends Fragment { return null; } // only allow if not already exists - for (final PeertubeInstance instance : instanceList) { + for (final PeertubeInstance instance : instanceListAdapter.getCurrentList()) { if (instance.getUrl().equals(cleanUrl)) { Toast.makeText(getActivity(), R.string.peertube_instance_add_exists, Toast.LENGTH_SHORT).show(); @@ -266,8 +248,9 @@ public class PeertubeInstanceListFragment extends Fragment { } private void add(final PeertubeInstance instance) { - instanceList.add(instance); - instanceListAdapter.notifyDataSetChanged(); + final var list = new ArrayList<>(instanceListAdapter.getCurrentList()); + list.add(instance); + instanceListAdapter.submitList(list); } private ItemTouchHelper.SimpleCallback getItemTouchCallback() { @@ -281,8 +264,7 @@ public class PeertubeInstanceListFragment extends Fragment { final long msSinceStartScroll) { final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll); - final int minimumAbsVelocity = Math.max(12, - Math.abs(standardSpeed)); + final int minimumAbsVelocity = Math.max(12, Math.abs(standardSpeed)); return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds); } @@ -316,17 +298,19 @@ public class PeertubeInstanceListFragment extends Fragment { final int swipeDir) { final int position = viewHolder.getBindingAdapterPosition(); // do not allow swiping the selected instance - if (instanceList.get(position).getUrl().equals(selectedInstance.getUrl())) { + if (instanceListAdapter.getCurrentList().get(position).getUrl() + .equals(selectedInstance.getUrl())) { instanceListAdapter.notifyItemChanged(position); return; } - instanceList.remove(position); - instanceListAdapter.notifyItemRemoved(position); + final var list = new ArrayList<>(instanceListAdapter.getCurrentList()); + list.remove(position); - if (instanceList.isEmpty()) { - instanceList.add(selectedInstance); - instanceListAdapter.notifyItemInserted(0); + if (list.isEmpty()) { + list.add(selectedInstance); } + + instanceListAdapter.submitList(list); } }; } @@ -336,96 +320,94 @@ public class PeertubeInstanceListFragment extends Fragment { //////////////////////////////////////////////////////////////////////////*/ private class InstanceListAdapter - extends RecyclerView.Adapter { + extends ListAdapter { private final LayoutInflater inflater; private final ItemTouchHelper itemTouchHelper; private RadioButton lastChecked; InstanceListAdapter(final Context context, final ItemTouchHelper itemTouchHelper) { + super(new PeertubeInstanceCallback()); this.itemTouchHelper = itemTouchHelper; this.inflater = LayoutInflater.from(context); } public void swapItems(final int fromPosition, final int toPosition) { - Collections.swap(instanceList, fromPosition, toPosition); - notifyItemMoved(fromPosition, toPosition); + final var list = new ArrayList<>(getCurrentList()); + Collections.swap(list, fromPosition, toPosition); + submitList(list); } @NonNull @Override public InstanceListAdapter.TabViewHolder onCreateViewHolder(@NonNull final ViewGroup parent, final int viewType) { - final View view = inflater.inflate(R.layout.item_instance, parent, false); - return new InstanceListAdapter.TabViewHolder(view); + return new InstanceListAdapter.TabViewHolder(ItemInstanceBinding.inflate(inflater, + parent, false)); } @Override public void onBindViewHolder(@NonNull final InstanceListAdapter.TabViewHolder holder, final int position) { - holder.bind(position, holder); - } - - @Override - public int getItemCount() { - return instanceList.size(); + holder.bind(position); } class TabViewHolder extends RecyclerView.ViewHolder { - private final AppCompatImageView instanceIconView; - private final TextView instanceNameView; - private final TextView instanceUrlView; - private final RadioButton instanceRB; - private final ImageView handle; + private final ItemInstanceBinding itemBinding; - TabViewHolder(final View itemView) { - super(itemView); - - instanceIconView = itemView.findViewById(R.id.instanceIcon); - instanceNameView = itemView.findViewById(R.id.instanceName); - instanceUrlView = itemView.findViewById(R.id.instanceUrl); - instanceRB = itemView.findViewById(R.id.selectInstanceRB); - handle = itemView.findViewById(R.id.handle); + TabViewHolder(final ItemInstanceBinding binding) { + super(binding.getRoot()); + this.itemBinding = binding; } @SuppressLint("ClickableViewAccessibility") - void bind(final int position, final TabViewHolder holder) { - handle.setOnTouchListener(getOnTouchListener(holder)); - - final PeertubeInstance instance = instanceList.get(position); - instanceNameView.setText(instance.getName()); - instanceUrlView.setText(instance.getUrl()); - instanceRB.setOnCheckedChangeListener(null); - if (selectedInstance.getUrl().equals(instance.getUrl())) { - if (lastChecked != null && lastChecked != instanceRB) { - lastChecked.setChecked(false); - } - instanceRB.setChecked(true); - lastChecked = instanceRB; - } - instanceRB.setOnCheckedChangeListener((buttonView, isChecked) -> { - if (isChecked) { - selectInstance(instance); - if (lastChecked != null && lastChecked != instanceRB) { - lastChecked.setChecked(false); - } - lastChecked = instanceRB; - } - }); - instanceIconView.setImageResource(R.drawable.place_holder_peertube); - } - - @SuppressLint("ClickableViewAccessibility") - private View.OnTouchListener getOnTouchListener(final RecyclerView.ViewHolder item) { - return (view, motionEvent) -> { + void bind(final int position) { + itemBinding.handle.setOnTouchListener((view, motionEvent) -> { if (motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) { if (itemTouchHelper != null && getItemCount() > 1) { - itemTouchHelper.startDrag(item); + itemTouchHelper.startDrag(this); return true; } } return false; - }; + }); + + final PeertubeInstance instance = getItem(position); + itemBinding.instanceName.setText(instance.getName()); + itemBinding.instanceUrl.setText(instance.getUrl()); + itemBinding.selectInstanceRB.setOnCheckedChangeListener(null); + if (selectedInstance.getUrl().equals(instance.getUrl())) { + if (lastChecked != null && lastChecked != itemBinding.selectInstanceRB) { + lastChecked.setChecked(false); + } + itemBinding.selectInstanceRB.setChecked(true); + lastChecked = itemBinding.selectInstanceRB; + } + itemBinding.selectInstanceRB.setOnCheckedChangeListener((buttonView, isChecked) -> { + if (isChecked) { + selectInstance(instance); + if (lastChecked != null && lastChecked != itemBinding.selectInstanceRB) { + lastChecked.setChecked(false); + } + lastChecked = itemBinding.selectInstanceRB; + } + }); + itemBinding.instanceIcon.setImageResource(R.drawable.ic_placeholder_peertube); } } } + + private static class PeertubeInstanceCallback extends DiffUtil.ItemCallback { + @Override + public boolean areItemsTheSame(@NonNull final PeertubeInstance oldItem, + @NonNull final PeertubeInstance newItem) { + return oldItem.getUrl().equals(newItem.getUrl()); + } + + @Override + public boolean areContentsTheSame(@NonNull final PeertubeInstance oldItem, + @NonNull final PeertubeInstance newItem) { + return oldItem.getName().equals(newItem.getName()) + && oldItem.getUrl().equals(newItem.getUrl()); + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/PlayerNotificationSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/PlayerNotificationSettingsFragment.kt index 3549bff42..7d95433a4 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/PlayerNotificationSettingsFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/settings/PlayerNotificationSettingsFragment.kt @@ -1,19 +1,9 @@ package org.schabi.newpipe.settings -import android.os.Build import android.os.Bundle -import androidx.preference.Preference -import org.schabi.newpipe.R class PlayerNotificationSettingsFragment : BasePreferenceFragment() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResourceRegistry() - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - val colorizePref: Preference? = findPreference(getString(R.string.notification_colorize_key)) - colorizePref?.let { - preferenceScreen.removePreference(it) - } - } } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java index 0f25be630..37335421d 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java @@ -19,7 +19,7 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.local.subscription.SubscriptionManager; -import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.ThemeHelper; import java.util.List; diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java index 905a44fd1..3e97d42e6 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java @@ -25,7 +25,7 @@ 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; -import org.schabi.newpipe.util.PicassoHelper; +import org.schabi.newpipe.util.image.PicassoHelper; import java.util.List; import java.util.Vector; diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java b/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java index 8924ecbe1..d731f2f5e 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java @@ -2,11 +2,12 @@ package org.schabi.newpipe.settings; import android.content.Context; import android.content.SharedPreferences; -import android.os.Build; import android.util.Log; +import androidx.annotation.NonNull; import androidx.preference.PreferenceManager; +import org.schabi.newpipe.App; import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorUtil; @@ -31,9 +32,9 @@ public final class SettingMigrations { private static final String TAG = SettingMigrations.class.toString(); private static SharedPreferences sp; - public static final Migration MIGRATION_0_1 = new Migration(0, 1) { + private static final Migration MIGRATION_0_1 = new Migration(0, 1) { @Override - public void migrate(final Context context) { + public void migrate(@NonNull final Context context) { // We changed the content of the dialog which opens when sharing a link to NewPipe // by removing the "open detail page" option. // Therefore, show the dialog once again to ensure users need to choose again and are @@ -45,9 +46,9 @@ public final class SettingMigrations { } }; - public static final Migration MIGRATION_1_2 = new Migration(1, 2) { + private static final Migration MIGRATION_1_2 = new Migration(1, 2) { @Override - protected void migrate(final Context context) { + protected void migrate(@NonNull final Context context) { // The new application workflow introduced in #2907 allows minimizing videos // while playing to do other stuff within the app. // For an even better workflow, we minimize a stream when switching the app to play in @@ -64,25 +65,25 @@ public final class SettingMigrations { } }; - public static final Migration MIGRATION_2_3 = new Migration(2, 3) { + private static final Migration MIGRATION_2_3 = new Migration(2, 3) { @Override - protected void migrate(final Context context) { + protected void migrate(@NonNull final Context context) { // Storage Access Framework implementation was improved in #5415, allowing the modern // and standard way to access folders and files to be used consistently everywhere. // We reset the setting to its default value, i.e. "use SAF", since now there are no // more issues with SAF and users should use that one instead of the old - // NoNonsenseFilePicker. SAF does not work on KitKat and below, though, so the setting - // is set to false in that case. Also, there's a bug on FireOS in which SAF open/close + // NoNonsenseFilePicker. Also, there's a bug on FireOS in which SAF open/close // dialogs cannot be confirmed with a remote (see #6455). - sp.edit().putBoolean(context.getString(R.string.storage_use_saf), - Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP - && !DeviceUtils.isFireTv()).apply(); + sp.edit().putBoolean( + context.getString(R.string.storage_use_saf), + !DeviceUtils.isFireTv() + ).apply(); } }; - public static final Migration MIGRATION_3_4 = new Migration(3, 4) { + private static final Migration MIGRATION_3_4 = new Migration(3, 4) { @Override - protected void migrate(final Context context) { + protected void migrate(@NonNull final Context context) { // Pull request #3546 added support for choosing the type of search suggestions to // show, replacing the on-off switch used before, so migrate the previous user choice @@ -109,6 +110,39 @@ public final class SettingMigrations { } }; + private static final Migration MIGRATION_4_5 = new Migration(4, 5) { + @Override + protected void migrate(@NonNull final Context context) { + final boolean brightness = sp.getBoolean("brightness_gesture_control", true); + final boolean volume = sp.getBoolean("volume_gesture_control", true); + + final SharedPreferences.Editor editor = sp.edit(); + + editor.putString(context.getString(R.string.right_gesture_control_key), + context.getString(volume + ? R.string.volume_control_key : R.string.none_control_key)); + editor.putString(context.getString(R.string.left_gesture_control_key), + context.getString(brightness + ? R.string.brightness_control_key : R.string.none_control_key)); + + editor.apply(); + } + }; + + public static final Migration MIGRATION_5_6 = new Migration(5, 6) { + @Override + protected void migrate(@NonNull final Context context) { + final boolean loadImages = sp.getBoolean("download_thumbnail_key", true); + + sp.edit() + .putString(context.getString(R.string.image_quality_key), + context.getString(loadImages + ? R.string.image_quality_default + : R.string.image_quality_none_key)) + .apply(); + } + }; + /** * List of all implemented migrations. *

@@ -120,22 +154,24 @@ public final class SettingMigrations { MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, + MIGRATION_4_5, + MIGRATION_5_6, }; /** * Version number for preferences. Must be incremented every time a migration is necessary. */ - public static final int VERSION = 4; + private static final int VERSION = 6; - public static void initMigrations(final Context context, final boolean isFirstRun) { + public static void runMigrationsIfNeeded(@NonNull final Context context) { // setup migrations and check if there is something to do sp = PreferenceManager.getDefaultSharedPreferences(context); final String lastPrefVersionKey = context.getString(R.string.last_used_preferences_version); final int lastPrefVersion = sp.getInt(lastPrefVersionKey, 0); // no migration to run, already up to date - if (isFirstRun) { + if (App.getApp().isFirstRun()) { sp.edit().putInt(lastPrefVersionKey, VERSION).apply(); return; } else if (lastPrefVersion == VERSION) { @@ -193,7 +229,7 @@ public final class SettingMigrations { return oldVersion >= currentVersion; } - protected abstract void migrate(Context context); + protected abstract void migrate(@NonNull Context context); } diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java index 3ee6668bf..529e53442 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java @@ -266,7 +266,7 @@ public class SettingsActivity extends AppCompatActivity implements */ private void ensureSearchRepresentsApplicationState() { // Check if the update settings are available - if (!ReleaseVersionUtil.isReleaseApk()) { + if (!ReleaseVersionUtil.INSTANCE.isReleaseApk()) { SettingsResourceRegistry.getInstance() .getEntryByPreferencesResId(R.xml.update_settings) .setSearchable(false); diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java index 78ddb3786..06e0a7c1e 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsResourceRegistry.java @@ -40,6 +40,8 @@ public final class SettingsResourceRegistry { add(PlayerNotificationSettingsFragment.class, R.xml.player_notification_settings); add(UpdateSettingsFragment.class, R.xml.update_settings); add(VideoAudioSettingsFragment.class, R.xml.video_audio_settings); + add(ExoPlayerSettingsFragment.class, R.xml.exoplayer_settings); + add(BackupRestoreSettingsFragment.class, R.xml.backup_restore_settings); } private SettingRegistryEntry add( diff --git a/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java index 1043e88c2..b8d0aa556 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java @@ -1,40 +1,35 @@ package org.schabi.newpipe.settings; +import android.app.AlertDialog; +import android.content.Context; import android.os.Bundle; import android.widget.Toast; import androidx.preference.Preference; +import androidx.preference.PreferenceManager; import org.schabi.newpipe.NewVersionWorker; import org.schabi.newpipe.R; public class UpdateSettingsFragment extends BasePreferenceFragment { - private final Preference.OnPreferenceChangeListener updatePreferenceChange - = (preference, checkForUpdates) -> { + private final Preference.OnPreferenceChangeListener updatePreferenceChange = (p, nVal) -> { + final boolean checkForUpdates = (boolean) nVal; defaultPreferences.edit() - .putBoolean(getString(R.string.update_app_key), (boolean) checkForUpdates).apply(); + .putBoolean(getString(R.string.update_app_key), checkForUpdates) + .apply(); - if ((boolean) checkForUpdates) { - checkNewVersionNow(); + if (checkForUpdates) { + NewVersionWorker.enqueueNewVersionCheckingWork(requireContext(), true); } return true; }; - private final Preference.OnPreferenceClickListener manualUpdateClick - = preference -> { + private final Preference.OnPreferenceClickListener manualUpdateClick = preference -> { Toast.makeText(getContext(), R.string.checking_updates_toast, Toast.LENGTH_SHORT).show(); - checkNewVersionNow(); + NewVersionWorker.enqueueNewVersionCheckingWork(requireContext(), true); return true; }; - private void checkNewVersionNow() { - // Search for updates immediately when update checks are enabled. - // Reset the expire time. This is necessary to check for an update immediately. - defaultPreferences.edit() - .putLong(getString(R.string.update_expiry_key), 0).apply(); - NewVersionWorker.enqueueNewVersionCheckingWork(getContext()); - } - @Override public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { addPreferencesFromResourceRegistry(); @@ -44,4 +39,38 @@ public class UpdateSettingsFragment extends BasePreferenceFragment { findPreference(getString(R.string.manual_update_key)) .setOnPreferenceClickListener(manualUpdateClick); } + + public static void askForConsentToUpdateChecks(final Context context) { + new AlertDialog.Builder(context) + .setTitle(context.getString(R.string.check_for_updates)) + .setMessage(context.getString(R.string.auto_update_check_description)) + .setPositiveButton(context.getString(R.string.yes), (d, w) -> { + d.dismiss(); + setAutoUpdateCheckEnabled(context, true); + }) + .setNegativeButton(R.string.no, (d, w) -> { + d.dismiss(); + // set explicitly to false, since the default is true on previous versions + setAutoUpdateCheckEnabled(context, false); + }) + .show(); + } + + private static void setAutoUpdateCheckEnabled(final Context context, final boolean enabled) { + PreferenceManager.getDefaultSharedPreferences(context) + .edit() + .putBoolean(context.getString(R.string.update_app_key), enabled) + .putBoolean(context.getString(R.string.update_check_consent_key), true) + .apply(); + } + + /** + * Whether the user was asked for consent to automatically check for app updates. + * @param context + * @return true if the user was asked for consent, false otherwise + */ + public static boolean wasUserAskedForConsent(final Context context) { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.update_check_consent_key), false); + } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java index 039f00c1d..a1f563724 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java @@ -13,6 +13,7 @@ import androidx.preference.ListPreference; import com.google.android.material.snackbar.Snackbar; import org.schabi.newpipe.R; +import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.PermissionHelper; import java.util.LinkedList; @@ -26,15 +27,15 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment { addPreferencesFromResourceRegistry(); updateSeekOptions(); - - listener = (sharedPreferences, s) -> { + updateResolutionOptions(); + listener = (sharedPreferences, key) -> { // on M and above, if user chooses to minimise to popup player on exit // and the app doesn't have display over other apps permission, // show a snackbar to let the user give permission if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M - && s.equals(getString(R.string.minimize_on_exit_key))) { - final String newSetting = sharedPreferences.getString(s, null); + && getString(R.string.minimize_on_exit_key).equals(key)) { + final String newSetting = sharedPreferences.getString(key, null); if (newSetting != null && newSetting.equals(getString(R.string.minimize_on_exit_popup_key)) && !Settings.canDrawOverlays(getContext())) { @@ -46,12 +47,86 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment { .show(); } - } else if (s.equals(getString(R.string.use_inexact_seek_key))) { + } else if (getString(R.string.use_inexact_seek_key).equals(key)) { updateSeekOptions(); + } else if (getString(R.string.show_higher_resolutions_key).equals(key)) { + updateResolutionOptions(); } }; } + /** + * Update default resolution, default popup resolution & mobile data resolution options. + *
+ * Show high resolutions when "Show higher resolution" option is enabled. + * Set default resolution to "best resolution" when "Show higher resolution" option + * is disabled. + */ + private void updateResolutionOptions() { + final Resources resources = getResources(); + final boolean showHigherResolutions = getPreferenceManager().getSharedPreferences() + .getBoolean(resources.getString(R.string.show_higher_resolutions_key), false); + + // get sorted resolution lists + final List resolutionListDescriptions = ListHelper.getSortedResolutionList( + resources, + R.array.resolution_list_description, + R.array.high_resolution_list_descriptions, + showHigherResolutions); + final List resolutionListValues = ListHelper.getSortedResolutionList( + resources, + R.array.resolution_list_values, + R.array.high_resolution_list_values, + showHigherResolutions); + final List limitDataUsageResolutionValues = ListHelper.getSortedResolutionList( + resources, + R.array.limit_data_usage_values_list, + R.array.high_resolution_limit_data_usage_values_list, + showHigherResolutions); + final List limitDataUsageResolutionDescriptions = ListHelper + .getSortedResolutionList(resources, + R.array.limit_data_usage_description_list, + R.array.high_resolution_list_descriptions, + showHigherResolutions); + + // get resolution preferences + final ListPreference defaultResolution = findPreference( + getString(R.string.default_resolution_key)); + final ListPreference defaultPopupResolution = findPreference( + getString(R.string.default_popup_resolution_key)); + final ListPreference mobileDataResolution = findPreference( + getString(R.string.limit_mobile_data_usage_key)); + + // update resolution preferences with new resolutions, entries & values for each + defaultResolution.setEntries(resolutionListDescriptions.toArray(new String[0])); + defaultResolution.setEntryValues(resolutionListValues.toArray(new String[0])); + defaultPopupResolution.setEntries(resolutionListDescriptions.toArray(new String[0])); + defaultPopupResolution.setEntryValues(resolutionListValues.toArray(new String[0])); + mobileDataResolution.setEntries( + limitDataUsageResolutionDescriptions.toArray(new String[0])); + mobileDataResolution.setEntryValues(limitDataUsageResolutionValues.toArray(new String[0])); + + // if "Show higher resolution" option is disabled, + // set default resolution to "best resolution" + if (!showHigherResolutions) { + if (ListHelper.isHighResolutionSelected(defaultResolution.getValue(), + R.array.high_resolution_list_values, + resources)) { + defaultResolution.setValueIndex(0); + } + if (ListHelper.isHighResolutionSelected(defaultPopupResolution.getValue(), + R.array.high_resolution_list_values, + resources)) { + defaultPopupResolution.setValueIndex(0); + } + if (ListHelper.isHighResolutionSelected(mobileDataResolution.getValue(), + R.array.high_resolution_limit_data_usage_values_list, + resources)) { + mobileDataResolution.setValueIndex(0); + } + } + } + /** * Update fast-forward/-rewind seek duration options * according to language and inexact seek setting. diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java index 62455d682..7dfddef20 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java +++ b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationActionsPreference.java @@ -1,38 +1,28 @@ package org.schabi.newpipe.settings.custom; +import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_RECREATE_NOTIFICATION; + import android.content.Context; import android.content.Intent; import android.content.SharedPreferences; -import android.graphics.drawable.Drawable; +import android.os.Build; import android.util.AttributeSet; -import android.view.LayoutInflater; import android.view.View; -import android.view.ViewGroup; import android.widget.CheckBox; -import android.widget.ImageView; -import android.widget.LinearLayout; -import android.widget.RadioButton; -import android.widget.RadioGroup; import android.widget.TextView; import android.widget.Toast; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; -import androidx.appcompat.content.res.AppCompatResources; -import androidx.core.graphics.drawable.DrawableCompat; -import androidx.core.widget.TextViewCompat; import androidx.preference.Preference; import androidx.preference.PreferenceViewHolder; +import org.schabi.newpipe.App; import org.schabi.newpipe.R; -import org.schabi.newpipe.player.MainPlayer; -import org.schabi.newpipe.player.NotificationConstants; -import org.schabi.newpipe.util.DeviceUtils; -import org.schabi.newpipe.util.ThemeHelper; -import org.schabi.newpipe.views.FocusOverlayView; +import org.schabi.newpipe.player.notification.NotificationConstants; +import java.util.ArrayList; import java.util.List; +import java.util.stream.IntStream; public class NotificationActionsPreference extends Preference { @@ -42,8 +32,9 @@ public class NotificationActionsPreference extends Preference { } - @Nullable private NotificationSlot[] notificationSlots = null; - @Nullable private List compactSlots = null; + private NotificationSlot[] notificationSlots; + private List compactSlots; + //////////////////////////////////////////////////////////////////////////// // Lifecycle @@ -53,6 +44,11 @@ public class NotificationActionsPreference extends Preference { public void onBindViewHolder(@NonNull final PreferenceViewHolder holder) { super.onBindViewHolder(holder); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + ((TextView) holder.itemView.findViewById(R.id.summary)) + .setText(R.string.notification_actions_summary_android13); + } + holder.itemView.setClickable(false); setupActions(holder.itemView); } @@ -61,7 +57,9 @@ public class NotificationActionsPreference extends Preference { public void onDetached() { super.onDetached(); saveChanges(); - getContext().sendBroadcast(new Intent(MainPlayer.ACTION_RECREATE_NOTIFICATION)); + // set package to this app's package to prevent the intent from being seen outside + getContext().sendBroadcast(new Intent(ACTION_RECREATE_NOTIFICATION) + .setPackage(App.PACKAGE_NAME)); } @@ -70,13 +68,27 @@ public class NotificationActionsPreference extends Preference { //////////////////////////////////////////////////////////////////////////// private void setupActions(@NonNull final View view) { - compactSlots = - NotificationConstants.getCompactSlotsFromPreferences( - getContext(), getSharedPreferences(), 5); - notificationSlots = new NotificationSlot[5]; - for (int i = 0; i < 5; i++) { - notificationSlots[i] = new NotificationSlot(i, view); + compactSlots = new ArrayList<>(NotificationConstants.getCompactSlotsFromPreferences( + getContext(), getSharedPreferences())); + notificationSlots = IntStream.range(0, 5) + .mapToObj(i -> new NotificationSlot(getContext(), getSharedPreferences(), i, view, + compactSlots.contains(i), this::onToggleCompactSlot)) + .toArray(NotificationSlot[]::new); + } + + private void onToggleCompactSlot(final int i, final CheckBox checkBox) { + if (checkBox.isChecked()) { + compactSlots.remove((Integer) i); + } else if (compactSlots.size() < 3) { + compactSlots.add(i); + } else { + Toast.makeText(getContext(), + R.string.notification_actions_at_most_three, + Toast.LENGTH_SHORT).show(); + return; } + + checkBox.toggle(); } @@ -96,148 +108,10 @@ public class NotificationActionsPreference extends Preference { for (int i = 0; i < 5; i++) { editor.putInt(getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]), - notificationSlots[i].selectedAction); + notificationSlots[i].getSelectedAction()); } editor.apply(); } } - - - //////////////////////////////////////////////////////////////////////////// - // Notification action - //////////////////////////////////////////////////////////////////////////// - - private static final int[] SLOT_ITEMS = { - R.id.notificationAction0, - R.id.notificationAction1, - R.id.notificationAction2, - R.id.notificationAction3, - R.id.notificationAction4, - }; - - private static final int[] SLOT_TITLES = { - R.string.notification_action_0_title, - R.string.notification_action_1_title, - R.string.notification_action_2_title, - R.string.notification_action_3_title, - R.string.notification_action_4_title, - }; - - private class NotificationSlot { - - final int i; - @NotificationConstants.Action int selectedAction; - - ImageView icon; - TextView summary; - - NotificationSlot(final int actionIndex, final View parentView) { - this.i = actionIndex; - - final View view = parentView.findViewById(SLOT_ITEMS[i]); - setupSelectedAction(view); - setupTitle(view); - setupCheckbox(view); - } - - void setupTitle(final View view) { - ((TextView) view.findViewById(R.id.notificationActionTitle)) - .setText(SLOT_TITLES[i]); - view.findViewById(R.id.notificationActionClickableArea).setOnClickListener( - v -> openActionChooserDialog()); - } - - void setupCheckbox(final View view) { - final CheckBox compactSlotCheckBox = view.findViewById(R.id.notificationActionCheckBox); - compactSlotCheckBox.setChecked(compactSlots.contains(i)); - view.findViewById(R.id.notificationActionCheckBoxClickableArea).setOnClickListener( - v -> { - if (compactSlotCheckBox.isChecked()) { - compactSlots.remove((Integer) i); - } else if (compactSlots.size() < 3) { - compactSlots.add(i); - } else { - Toast.makeText(getContext(), - R.string.notification_actions_at_most_three, - Toast.LENGTH_SHORT).show(); - return; - } - - compactSlotCheckBox.toggle(); - }); - } - - void setupSelectedAction(final View view) { - icon = view.findViewById(R.id.notificationActionIcon); - summary = view.findViewById(R.id.notificationActionSummary); - selectedAction = getSharedPreferences().getInt( - getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]), - NotificationConstants.SLOT_DEFAULTS[i]); - updateInfo(); - } - - void updateInfo() { - if (NotificationConstants.ACTION_ICONS[selectedAction] == 0) { - icon.setImageDrawable(null); - } else { - icon.setImageDrawable(AppCompatResources.getDrawable(getContext(), - NotificationConstants.ACTION_ICONS[selectedAction])); - } - - summary.setText(NotificationConstants.getActionName(getContext(), selectedAction)); - } - - void openActionChooserDialog() { - final LayoutInflater inflater = LayoutInflater.from(getContext()); - final LinearLayout rootLayout = (LinearLayout) inflater.inflate( - R.layout.single_choice_dialog_view, null, false); - final RadioGroup radioGroup = rootLayout.findViewById(android.R.id.list); - - final AlertDialog alertDialog = new AlertDialog.Builder(getContext()) - .setTitle(SLOT_TITLES[i]) - .setView(radioGroup) - .setCancelable(true) - .create(); - - final View.OnClickListener radioButtonsClickListener = v -> { - selectedAction = NotificationConstants.SLOT_ALLOWED_ACTIONS[i][v.getId()]; - updateInfo(); - alertDialog.dismiss(); - }; - - for (int id = 0; id < NotificationConstants.SLOT_ALLOWED_ACTIONS[i].length; ++id) { - final int action = NotificationConstants.SLOT_ALLOWED_ACTIONS[i][id]; - final RadioButton radioButton - = (RadioButton) inflater.inflate(R.layout.list_radio_icon_item, null); - - // if present set action icon with correct color - if (NotificationConstants.ACTION_ICONS[action] != 0) { - Drawable drawable = AppCompatResources.getDrawable(getContext(), - NotificationConstants.ACTION_ICONS[action]); - if (drawable != null) { - final int color = ThemeHelper.resolveColorFromAttr(getContext(), - android.R.attr.textColorPrimary); - drawable = DrawableCompat.wrap(drawable).mutate(); - DrawableCompat.setTint(drawable, color); - TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(radioButton, - null, null, drawable, null); - } - } - - radioButton.setText(NotificationConstants.getActionName(getContext(), action)); - radioButton.setChecked(action == selectedAction); - radioButton.setId(id); - radioButton.setLayoutParams(new RadioGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); - radioButton.setOnClickListener(radioButtonsClickListener); - radioGroup.addView(radioButton); - } - alertDialog.show(); - - if (DeviceUtils.isTv(getContext())) { - FocusOverlayView.setupFocusObserver(alertDialog); - } - } - } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java new file mode 100644 index 000000000..981ba3e75 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/settings/custom/NotificationSlot.java @@ -0,0 +1,172 @@ +package org.schabi.newpipe.settings.custom; + +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.ColorStateList; +import android.os.Build; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.CheckBox; +import android.widget.ImageView; +import android.widget.RadioButton; +import android.widget.RadioGroup; +import android.widget.TextView; + +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.content.res.AppCompatResources; +import androidx.core.widget.TextViewCompat; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.ListRadioIconItemBinding; +import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding; +import org.schabi.newpipe.player.notification.NotificationConstants; +import org.schabi.newpipe.util.DeviceUtils; +import org.schabi.newpipe.util.ThemeHelper; +import org.schabi.newpipe.views.FocusOverlayView; + +import java.util.Objects; +import java.util.function.BiConsumer; + +class NotificationSlot { + + private static final int[] SLOT_ITEMS = { + R.id.notificationAction0, + R.id.notificationAction1, + R.id.notificationAction2, + R.id.notificationAction3, + R.id.notificationAction4, + }; + + private static final int[] SLOT_TITLES = { + R.string.notification_action_0_title, + R.string.notification_action_1_title, + R.string.notification_action_2_title, + R.string.notification_action_3_title, + R.string.notification_action_4_title, + }; + + private final int i; + private @NotificationConstants.Action int selectedAction; + private final Context context; + private final BiConsumer onToggleCompactSlot; + + private ImageView icon; + private TextView summary; + + NotificationSlot(final Context context, + final SharedPreferences prefs, + final int actionIndex, + final View parentView, + final boolean isCompactSlotChecked, + final BiConsumer onToggleCompactSlot) { + this.context = context; + this.i = actionIndex; + this.onToggleCompactSlot = onToggleCompactSlot; + + selectedAction = Objects.requireNonNull(prefs).getInt( + context.getString(NotificationConstants.SLOT_PREF_KEYS[i]), + NotificationConstants.SLOT_DEFAULTS[i]); + final View view = parentView.findViewById(SLOT_ITEMS[i]); + + // only show the last two notification slots on Android 13+ + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || i >= 3) { + setupSelectedAction(view); + setupTitle(view); + setupCheckbox(view, isCompactSlotChecked); + } else { + view.setVisibility(View.GONE); + } + } + + void setupTitle(final View view) { + ((TextView) view.findViewById(R.id.notificationActionTitle)) + .setText(SLOT_TITLES[i]); + view.findViewById(R.id.notificationActionClickableArea).setOnClickListener( + v -> openActionChooserDialog()); + } + + void setupCheckbox(final View view, final boolean isCompactSlotChecked) { + final CheckBox compactSlotCheckBox = view.findViewById(R.id.notificationActionCheckBox); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + // there are no compact slots to customize on Android 13+ + compactSlotCheckBox.setVisibility(View.GONE); + view.findViewById(R.id.notificationActionCheckBoxClickableArea) + .setVisibility(View.GONE); + return; + } + + compactSlotCheckBox.setChecked(isCompactSlotChecked); + view.findViewById(R.id.notificationActionCheckBoxClickableArea).setOnClickListener( + v -> onToggleCompactSlot.accept(i, compactSlotCheckBox)); + } + + void setupSelectedAction(final View view) { + icon = view.findViewById(R.id.notificationActionIcon); + summary = view.findViewById(R.id.notificationActionSummary); + updateInfo(); + } + + void updateInfo() { + if (NotificationConstants.ACTION_ICONS[selectedAction] == 0) { + icon.setImageDrawable(null); + } else { + icon.setImageDrawable(AppCompatResources.getDrawable(context, + NotificationConstants.ACTION_ICONS[selectedAction])); + } + + summary.setText(NotificationConstants.getActionName(context, selectedAction)); + } + + void openActionChooserDialog() { + final LayoutInflater inflater = LayoutInflater.from(context); + final SingleChoiceDialogViewBinding binding = + SingleChoiceDialogViewBinding.inflate(inflater); + + final AlertDialog alertDialog = new AlertDialog.Builder(context) + .setTitle(SLOT_TITLES[i]) + .setView(binding.getRoot()) + .setCancelable(true) + .create(); + + final View.OnClickListener radioButtonsClickListener = v -> { + selectedAction = NotificationConstants.ALL_ACTIONS[v.getId()]; + updateInfo(); + alertDialog.dismiss(); + }; + + for (int id = 0; id < NotificationConstants.ALL_ACTIONS.length; ++id) { + final int action = NotificationConstants.ALL_ACTIONS[id]; + final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater) + .getRoot(); + + // if present set action icon with correct color + final int iconId = NotificationConstants.ACTION_ICONS[action]; + if (iconId != 0) { + radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, iconId, 0); + + final var color = ColorStateList.valueOf(ThemeHelper + .resolveColorFromAttr(context, android.R.attr.textColorPrimary)); + TextViewCompat.setCompoundDrawableTintList(radioButton, color); + } + + radioButton.setText(NotificationConstants.getActionName(context, action)); + radioButton.setChecked(action == selectedAction); + radioButton.setId(id); + radioButton.setLayoutParams(new RadioGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)); + radioButton.setOnClickListener(radioButtonsClickListener); + binding.list.addView(radioButton); + } + alertDialog.show(); + + if (DeviceUtils.isTv(context)) { + FocusOverlayView.setupFocusObserver(alertDialog); + } + } + + @NotificationConstants.Action + public int getSelectedAction() { + return selectedAction; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigAdapter.kt b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigAdapter.kt index 6ae264bb5..f61aa72ab 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigAdapter.kt +++ b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigAdapter.kt @@ -1,15 +1,13 @@ package org.schabi.newpipe.settings.notifications import android.view.LayoutInflater -import android.view.View import android.view.ViewGroup -import android.widget.CheckedTextView -import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.DiffUtil +import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView -import org.schabi.newpipe.R import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.database.subscription.SubscriptionEntity +import org.schabi.newpipe.databinding.ItemNotificationConfigBinding import org.schabi.newpipe.settings.notifications.NotificationModeConfigAdapter.SubscriptionHolder /** @@ -19,85 +17,46 @@ import org.schabi.newpipe.settings.notifications.NotificationModeConfigAdapter.S */ class NotificationModeConfigAdapter( private val listener: ModeToggleListener -) : RecyclerView.Adapter() { - - private val differ = AsyncListDiffer(this, DiffCallback()) - - init { - setHasStableIds(true) - } - - override fun onCreateViewHolder(viewGroup: ViewGroup, i: Int): SubscriptionHolder { - val view = LayoutInflater.from(viewGroup.context) - .inflate(R.layout.item_notification_config, viewGroup, false) - return SubscriptionHolder(view, listener) - } - - override fun onBindViewHolder(subscriptionHolder: SubscriptionHolder, i: Int) { - subscriptionHolder.bind(differ.currentList[i]) - } - - fun getItem(position: Int): SubscriptionItem = differ.currentList[position] - - override fun getItemCount() = differ.currentList.size - - override fun getItemId(position: Int): Long { - return differ.currentList[position].id - } - - fun getCurrentList(): List = differ.currentList - - fun update(newData: List) { - differ.submitList( - newData.map { - SubscriptionItem( - id = it.uid, - title = it.name, - notificationMode = it.notificationMode, - serviceId = it.serviceId, - url = it.url - ) - } +) : ListAdapter(DiffCallback) { + override fun onCreateViewHolder(parent: ViewGroup, i: Int): SubscriptionHolder { + return SubscriptionHolder( + ItemNotificationConfigBinding + .inflate(LayoutInflater.from(parent.context), parent, false) ) } - data class SubscriptionItem( - val id: Long, - val title: String, - @NotificationMode - val notificationMode: Int, - val serviceId: Int, - val url: String - ) + override fun onBindViewHolder(holder: SubscriptionHolder, position: Int) { + holder.bind(currentList[position]) + } - class SubscriptionHolder( - itemView: View, - private val listener: ModeToggleListener - ) : RecyclerView.ViewHolder(itemView), View.OnClickListener { - - private val checkedTextView = itemView as CheckedTextView + fun update(newData: List) { + val items = newData.map { + SubscriptionItem(it.uid, it.name, it.notificationMode, it.serviceId, it.url) + } + submitList(items) + } + inner class SubscriptionHolder( + private val itemBinding: ItemNotificationConfigBinding + ) : RecyclerView.ViewHolder(itemBinding.root) { init { - itemView.setOnClickListener(this) + itemView.setOnClickListener { + val mode = if (itemBinding.root.isChecked) { + NotificationMode.DISABLED + } else { + NotificationMode.ENABLED + } + listener.onModeChange(bindingAdapterPosition, mode) + } } fun bind(data: SubscriptionItem) { - checkedTextView.text = data.title - checkedTextView.isChecked = data.notificationMode != NotificationMode.DISABLED - } - - override fun onClick(v: View) { - val mode = if (checkedTextView.isChecked) { - NotificationMode.DISABLED - } else { - NotificationMode.ENABLED - } - listener.onModeChange(bindingAdapterPosition, mode) + itemBinding.root.text = data.title + itemBinding.root.isChecked = data.notificationMode != NotificationMode.DISABLED } } - private class DiffCallback : DiffUtil.ItemCallback() { - + private object DiffCallback : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: SubscriptionItem, newItem: SubscriptionItem): Boolean { return oldItem.id == newItem.id } @@ -107,18 +66,27 @@ class NotificationModeConfigAdapter( } override fun getChangePayload(oldItem: SubscriptionItem, newItem: SubscriptionItem): Any? { - if (oldItem.notificationMode != newItem.notificationMode) { - return newItem.notificationMode + return if (oldItem.notificationMode != newItem.notificationMode) { + newItem.notificationMode } else { - return super.getChangePayload(oldItem, newItem) + super.getChangePayload(oldItem, newItem) } } } - interface ModeToggleListener { + fun interface ModeToggleListener { /** * Triggered when the UI representation of a notification mode is changed. */ fun onModeChange(position: Int, @NotificationMode mode: Int) } } + +data class SubscriptionItem( + val id: Long, + val title: String, + @NotificationMode + val notificationMode: Int, + val serviceId: Int, + val url: String +) diff --git a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigFragment.kt index 9021fd68c..581768c30 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigFragment.kt +++ b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigFragment.kt @@ -1,5 +1,6 @@ package org.schabi.newpipe.settings.notifications +import android.content.Context import android.os.Bundle import android.view.LayoutInflater import android.view.Menu @@ -8,30 +9,36 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment -import androidx.recyclerview.widget.RecyclerView import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.Disposable import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.R import org.schabi.newpipe.database.subscription.NotificationMode +import org.schabi.newpipe.databinding.FragmentChannelsNotificationsBinding import org.schabi.newpipe.local.subscription.SubscriptionManager -import org.schabi.newpipe.settings.notifications.NotificationModeConfigAdapter.ModeToggleListener /** * [NotificationModeConfigFragment] is a settings fragment * which allows changing the [NotificationMode] of all subscribed channels. * The [NotificationMode] can either be changed one by one or toggled for all channels. */ -class NotificationModeConfigFragment : Fragment(), ModeToggleListener { +class NotificationModeConfigFragment : Fragment() { + private var _binding: FragmentChannelsNotificationsBinding? = null + private val binding get() = _binding!! - private lateinit var updaters: CompositeDisposable + private val disposables = CompositeDisposable() private var loader: Disposable? = null - private var adapter: NotificationModeConfigAdapter? = null + private lateinit var adapter: NotificationModeConfigAdapter + private lateinit var subscriptionManager: SubscriptionManager + + override fun onAttach(context: Context) { + super.onAttach(context) + subscriptionManager = SubscriptionManager(context) + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - updaters = CompositeDisposable() setHasOptionsMenu(true) } @@ -39,28 +46,34 @@ class NotificationModeConfigFragment : Fragment(), ModeToggleListener { inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?, - ): View = inflater.inflate(R.layout.fragment_channels_notifications, container, false) + ): View { + _binding = FragmentChannelsNotificationsBinding.inflate(inflater, container, false) + return binding.root + } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - val recyclerView: RecyclerView = view.findViewById(R.id.recycler_view) - adapter = NotificationModeConfigAdapter(this) - recyclerView.adapter = adapter + adapter = NotificationModeConfigAdapter { position, mode -> + // Notification mode has been changed via the UI. + // Now change it in the database. + updateNotificationMode(adapter.currentList[position], mode) + } + binding.recyclerView.adapter = adapter loader?.dispose() - loader = SubscriptionManager(requireContext()) - .subscriptions() + loader = subscriptionManager.subscriptions() .observeOn(AndroidSchedulers.mainThread()) - .subscribe { newData -> adapter?.update(newData) } + .subscribe(adapter::update) } override fun onDestroyView() { loader?.dispose() loader = null + _binding = null super.onDestroyView() } override fun onDestroy() { - updaters.dispose() + disposables.dispose() super.onDestroy() } @@ -79,41 +92,20 @@ class NotificationModeConfigFragment : Fragment(), ModeToggleListener { } } - override fun onModeChange(position: Int, @NotificationMode mode: Int) { - // Notification mode has been changed via the UI. - // Now change it in the database. - val subscription = adapter?.getItem(position) ?: return - updaters.add( - SubscriptionManager(requireContext()) - .updateNotificationMode( - subscription.serviceId, - subscription.url, - mode - ) - .subscribeOn(Schedulers.io()) - .subscribe() - ) - } - private fun toggleAll() { - val subscriptions = adapter?.getCurrentList() ?: return - val mode = subscriptions.firstOrNull()?.notificationMode ?: return + val mode = adapter.currentList.firstOrNull()?.notificationMode ?: return val newMode = when (mode) { NotificationMode.DISABLED -> NotificationMode.ENABLED else -> NotificationMode.DISABLED } - val subscriptionManager = SubscriptionManager(requireContext()) - updaters.add( - CompositeDisposable( - subscriptions.map { item -> - subscriptionManager.updateNotificationMode( - serviceId = item.serviceId, - url = item.url, - mode = newMode - ).subscribeOn(Schedulers.io()) - .subscribe() - } - ) + adapter.currentList.forEach { updateNotificationMode(it, newMode) } + } + + private fun updateNotificationMode(item: SubscriptionItem, @NotificationMode mode: Int) { + disposables.add( + subscriptionManager.updateNotificationMode(item.serviceId, item.url, mode) + .subscribeOn(Schedulers.io()) + .subscribe() ) } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.java index 7c231cafb..68b0010c4 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.java +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceFuzzySearchFunction.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.settings.preferencesearch; import android.text.TextUtils; +import android.util.Pair; import org.apache.commons.text.similarity.FuzzyScore; @@ -8,6 +9,7 @@ import java.util.Comparator; import java.util.Locale; import java.util.Map; import java.util.function.Function; +import java.util.stream.Collectors; import java.util.stream.Stream; public class PreferenceFuzzySearchFunction @@ -31,7 +33,7 @@ public class PreferenceFuzzySearchFunction // 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()) + .sorted(Comparator.comparingDouble(FuzzySearchSpecificDTO::getScore).reversed()) .map(FuzzySearchSpecificDTO::getItem) // Limit the amount of search results .limit(20); @@ -72,39 +74,22 @@ public class PreferenceFuzzySearchFunction ); private final PreferenceSearchItem item; - private final float score; + private final double score; - FuzzySearchSpecificDTO( - final PreferenceSearchItem item, - final String keyword) { + FuzzySearchSpecificDTO(final PreferenceSearchItem item, final String keyword) { this.item = item; - - float attributeScoreSum = 0; - int countOfAttributesWithScore = 0; - for (final Map.Entry, 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; - } + this.score = WEIGHT_MAP.entrySet().stream() + .map(entry -> new Pair<>(entry.getKey().apply(item), entry.getValue())) + .filter(pair -> !pair.first.isEmpty()) + .collect(Collectors.averagingDouble(pair -> + FUZZY_SCORE.fuzzyScore(pair.first, keyword) * pair.second)); } public PreferenceSearchItem getItem() { return item; } - public float getScore() { + public double getScore() { return score; } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceParser.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceParser.java index 1f507c7f1..b925e8b5f 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceParser.java +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceParser.java @@ -9,13 +9,13 @@ import androidx.annotation.Nullable; import androidx.annotation.XmlRes; import androidx.preference.PreferenceManager; +import org.schabi.newpipe.util.Localization; 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). @@ -54,7 +54,7 @@ public class PreferenceParser { if (xpp.getEventType() == XmlPullParser.START_TAG) { final PreferenceSearchItem result = parseSearchResult( xpp, - joinBreadcrumbs(breadcrumbs), + Localization.concatenateStrings(" > ", breadcrumbs), resId ); @@ -82,12 +82,6 @@ public class PreferenceParser { return results; } - private String joinBreadcrumbs(final List breadcrumbs) { - return breadcrumbs.stream() - .filter(crumb -> !TextUtils.isEmpty(crumb)) - .collect(Collectors.joining(" > ")); - } - private String getAttribute( final XmlPullParser xpp, @NonNull final String attribute diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.java index 02fbf9577..d6e2021a1 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.java @@ -1,54 +1,48 @@ 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.DiffUtil; +import androidx.recyclerview.widget.ListAdapter; 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 { - private List dataset = new ArrayList<>(); + extends ListAdapter { private Consumer onItemClickListener; + PreferenceSearchAdapter() { + super(new PreferenceCallback()); + } + @NonNull @Override - public PreferenceViewHolder onCreateViewHolder( - @NonNull final ViewGroup parent, - final int viewType - ) { - return new PreferenceViewHolder( - SettingsPreferencesearchListItemResultBinding.inflate( - LayoutInflater.from(parent.getContext()), - parent, - false)); + 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); + public void onBindViewHolder(@NonNull final PreferenceViewHolder holder, final int position) { + final PreferenceSearchItem item = getItem(position); holder.binding.title.setText(item.getTitle()); - if (TextUtils.isEmpty(item.getSummary())) { + if (item.getSummary().isEmpty()) { holder.binding.summary.setVisibility(View.GONE); } else { holder.binding.summary.setVisibility(View.VISIBLE); holder.binding.summary.setText(item.getSummary()); } - if (TextUtils.isEmpty(item.getBreadcrumbs())) { + if (item.getBreadcrumbs().isEmpty()) { holder.binding.breadcrumbs.setVisibility(View.GONE); } else { holder.binding.breadcrumbs.setVisibility(View.VISIBLE); @@ -62,16 +56,6 @@ class PreferenceSearchAdapter }); } - void setContent(final List items) { - dataset = new ArrayList<>(items); - this.notifyDataSetChanged(); - } - - @Override - public int getItemCount() { - return dataset.size(); - } - void setOnItemClickListener(final Consumer onItemClickListener) { this.onItemClickListener = onItemClickListener; } @@ -84,4 +68,19 @@ class PreferenceSearchAdapter this.binding = binding; } } + + private static class PreferenceCallback extends DiffUtil.ItemCallback { + @Override + public boolean areItemsTheSame(@NonNull final PreferenceSearchItem oldItem, + @NonNull final PreferenceSearchItem newItem) { + return oldItem.getKey().equals(newItem.getKey()); + } + + @Override + public boolean areContentsTheSame(@NonNull final PreferenceSearchItem oldItem, + @NonNull final PreferenceSearchItem newItem) { + return oldItem.getAllRelevantSearchFields().equals(newItem + .getAllRelevantSearchFields()); + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java index a445ea309..1ded181c8 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchConfiguration.java @@ -3,8 +3,6 @@ package org.schabi.newpipe.settings.preferencesearch; import androidx.preference.PreferenceCategory; import androidx.preference.PreferenceScreen; -import java.util.Arrays; -import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.stream.Stream; @@ -12,9 +10,9 @@ import java.util.stream.Stream; public class PreferenceSearchConfiguration { private PreferenceSearchFunction searcher = new PreferenceFuzzySearchFunction(); - private final List parserIgnoreElements = Collections.singletonList( + private final List parserIgnoreElements = List.of( PreferenceCategory.class.getSimpleName()); - private final List parserContainerElements = Arrays.asList( + private final List parserContainerElements = List.of( PreferenceCategory.class.getSimpleName(), PreferenceScreen.class.getSimpleName()); diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java index 308abbc4e..9d169d660 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java @@ -1,7 +1,6 @@ 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; @@ -13,7 +12,6 @@ import androidx.recyclerview.widget.LinearLayoutManager; import org.schabi.newpipe.databinding.SettingsPreferencesearchFragmentBinding; -import java.util.ArrayList; import java.util.List; /** @@ -54,13 +52,8 @@ public class PreferenceSearchFragment extends Fragment { return; } - final List results = - !TextUtils.isEmpty(keyword) - ? searcher.searchFor(keyword) - : new ArrayList<>(); - - adapter.setContent(new ArrayList<>(results)); - + final List results = searcher.searchFor(keyword); + adapter.submitList(results); setEmptyViewShown(results.isEmpty()); } diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.java index 98d2a5d84..33856326c 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.java +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchItem.java @@ -3,7 +3,6 @@ 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; @@ -92,11 +91,7 @@ public class PreferenceSearchItem { } public List getAllRelevantSearchFields() { - return Arrays.asList( - getTitle(), - getSummary(), - getEntries(), - getBreadcrumbs()); + return List.of(getTitle(), getSummary(), getEntries(), getBreadcrumbs()); } @NonNull diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.java index 418a3ea46..7eae5c128 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.java +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchResultHighlighter.java @@ -6,7 +6,6 @@ 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; @@ -65,8 +64,7 @@ public final class PreferenceSearchResultHighlighter { recyclerView.findViewHolderForAdapterPosition(position); if (holder != null) { final Drawable background = holder.itemView.getBackground(); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP - && background instanceof RippleDrawable) { + if (background instanceof RippleDrawable) { showRippleAnimation((RippleDrawable) background); return; } diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearcher.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearcher.java index 176dc5d14..b3efc8dd1 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearcher.java +++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearcher.java @@ -3,6 +3,7 @@ package org.schabi.newpipe.settings.preferencesearch; import android.text.TextUtils; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.stream.Collectors; @@ -21,7 +22,7 @@ public class PreferenceSearcher { List searchFor(final String keyword) { if (TextUtils.isEmpty(keyword)) { - return new ArrayList<>(); + return Collections.emptyList(); } return configuration.getSearcher() diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java index 73aec4a7b..289c824ba 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/ChooseTabsFragment.java @@ -1,5 +1,8 @@ package org.schabi.newpipe.settings.tabs; +import static org.schabi.newpipe.settings.tabs.Tab.typeFrom; +import static org.schabi.newpipe.util.ServiceHelper.getNameOfServiceById; + import android.annotation.SuppressLint; import android.app.Dialog; import android.content.Context; @@ -28,7 +31,6 @@ 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.NewPipe; import org.schabi.newpipe.settings.SelectChannelFragment; import org.schabi.newpipe.settings.SelectKioskFragment; import org.schabi.newpipe.settings.SelectPlaylistFragment; @@ -39,8 +41,6 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -import static org.schabi.newpipe.settings.tabs.Tab.typeFrom; - public class ChooseTabsFragment extends Fragment { private TabsManager tabsManager; @@ -374,36 +374,31 @@ public class ChooseTabsFragment extends Fragment { return; } - final String tabName; + tabNameView.setText(getTabName(type, tab)); + tabIconView.setImageResource(tab.getTabIconRes(requireContext())); + } + + private String getTabName(@NonNull final Tab.Type type, @NonNull final Tab tab) { switch (type) { case BLANK: - tabName = getString(R.string.blank_page_summary); - break; + return getString(R.string.blank_page_summary); case DEFAULT_KIOSK: - tabName = getString(R.string.default_kiosk_page_summary); - break; + return getString(R.string.default_kiosk_page_summary); case KIOSK: - tabName = NewPipe.getNameOfService(((Tab.KioskTab) tab) - .getKioskServiceId()) + "/" + tab.getTabName(requireContext()); - break; + return getNameOfServiceById(((Tab.KioskTab) tab).getKioskServiceId()) + + "/" + tab.getTabName(requireContext()); case CHANNEL: - tabName = NewPipe.getNameOfService(((Tab.ChannelTab) tab) - .getChannelServiceId()) + "/" + tab.getTabName(requireContext()); - break; + return getNameOfServiceById(((Tab.ChannelTab) tab).getChannelServiceId()) + + "/" + tab.getTabName(requireContext()); case PLAYLIST: final int serviceId = ((Tab.PlaylistTab) tab).getPlaylistServiceId(); final String serviceName = serviceId == -1 ? getString(R.string.local) - : NewPipe.getNameOfService(serviceId); - tabName = serviceName + "/" + tab.getTabName(requireContext()); - break; + : getNameOfServiceById(serviceId); + return serviceName + "/" + tab.getTabName(requireContext()); default: - tabName = tab.getTabName(requireContext()); - break; + return tab.getTabName(requireContext()); } - - tabNameView.setText(tabName); - tabIconView.setImageResource(tab.getTabIconRes(requireContext())); } @SuppressLint("ClickableViewAccessibility") diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java index 6b1d70a86..7e3f5d0c8 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java +++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/Tab.java @@ -248,7 +248,7 @@ public abstract class Tab { @DrawableRes @Override public int getTabIconRes(final Context context) { - return R.drawable.ic_rss_feed; + return R.drawable.ic_subscriptions; } @Override diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsJsonHelper.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsJsonHelper.java index 057ca50f0..30676477c 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsJsonHelper.java +++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsJsonHelper.java @@ -10,8 +10,6 @@ import com.grack.nanojson.JsonStringWriter; import com.grack.nanojson.JsonWriter; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; import java.util.List; /** @@ -20,11 +18,11 @@ import java.util.List; public final class TabsJsonHelper { private static final String JSON_TABS_ARRAY_KEY = "tabs"; - private static final List FALLBACK_INITIAL_TABS_LIST = Collections.unmodifiableList( - Arrays.asList( - Tab.Type.DEFAULT_KIOSK.getTab(), - Tab.Type.SUBSCRIPTIONS.getTab(), - Tab.Type.BOOKMARKS.getTab())); + private static final List FALLBACK_INITIAL_TABS_LIST = List.of( + Tab.Type.DEFAULT_KIOSK.getTab(), + Tab.Type.FEED.getTab(), + Tab.Type.SUBSCRIPTIONS.getTab(), + Tab.Type.BOOKMARKS.getTab()); private TabsJsonHelper() { } diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.java index 2836fe52b..7dcbee56f 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.java +++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/TabsManager.java @@ -73,10 +73,8 @@ public final class TabsManager { private SharedPreferences.OnSharedPreferenceChangeListener getPreferenceChangeListener() { return (sp, key) -> { - if (key.equals(savedTabsKey)) { - if (savedTabsChangeListener != null) { - savedTabsChangeListener.onTabsChanged(); - } + if (savedTabsKey.equals(key) && savedTabsChangeListener != null) { + savedTabsChangeListener.onTabsChanged(); } }; } diff --git a/app/src/main/java/org/schabi/newpipe/streams/DataReader.java b/app/src/main/java/org/schabi/newpipe/streams/DataReader.java index dc6e29d7d..68225fbab 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/DataReader.java +++ b/app/src/main/java/org/schabi/newpipe/streams/DataReader.java @@ -82,8 +82,8 @@ public class DataReader { public long readLong() throws IOException { primitiveRead(LONG_SIZE); - final long high - = primitive[0] << 24 | primitive[1] << 16 | primitive[2] << 8 | primitive[3]; + final long high = + primitive[0] << 24 | primitive[1] << 16 | primitive[2] << 8 | primitive[3]; final long low = primitive[4] << 24 | primitive[5] << 16 | primitive[6] << 8 | primitive[7]; return high << 32 | low; } diff --git a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java index 889cc85e6..807f190b4 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/Mp4FromDashWriter.java @@ -307,8 +307,8 @@ public class Mp4FromDashWriter { outWrite(makeMdat(totalSampleSize, is64)); final int[] sampleIndex = new int[readers.length]; - final int[] sizes - = new int[singleSampleBuffer > 0 ? singleSampleBuffer : SAMPLES_PER_CHUNK]; + final int[] sizes = + new int[singleSampleBuffer > 0 ? singleSampleBuffer : SAMPLES_PER_CHUNK]; final int[] sync = new int[singleSampleBuffer > 0 ? singleSampleBuffer : SAMPLES_PER_CHUNK]; int written = readers.length; diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java b/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java index 8253ad6af..678974cce 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java +++ b/app/src/main/java/org/schabi/newpipe/streams/WebMReader.java @@ -348,8 +348,7 @@ public class WebMReader { ensure(elemTrackEntry); } - final WebMTrack[] entries = new WebMTrack[trackEntries.size()]; - trackEntries.toArray(entries); + final WebMTrack[] entries = trackEntries.toArray(new WebMTrack[0]); for (final WebMTrack entry : entries) { switch (entry.trackType) { diff --git a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java index 2b69f23ac..530959d96 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java @@ -652,7 +652,7 @@ public class WebMWriter implements Closeable { final int offset = withLength ? 1 : 0; final byte[] buffer = new byte[offset + length]; - final long marker = (long) Math.floor((length - 1f) / 8f); + final long marker = Math.floorDiv(length - 1, 8); int shift = 0; for (int i = length - 1; i >= 0; i--, shift += 8) { diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java b/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java index feca89f02..0fe2e0408 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java +++ b/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java @@ -6,30 +6,43 @@ import android.content.Intent; import android.database.Cursor; import android.net.Uri; import android.os.Build; +import android.os.storage.StorageManager; +import android.os.storage.StorageVolume; import android.provider.DocumentsContract; +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; import androidx.documentfile.provider.DocumentFile; import org.schabi.newpipe.settings.NewPipeSettings; import org.schabi.newpipe.util.FilePickerActivityHelper; -import java.io.File; import java.io.IOException; import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.ArrayList; import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.Stream; import static android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME; import static android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; +import us.shandian.giga.util.Utility; + public class StoredDirectoryHelper { + private static final String TAG = StoredDirectoryHelper.class.getSimpleName(); public static final int PERMISSION_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; - private File ioTree; + private Path ioTree; private DocumentFile docTree; private Context context; @@ -41,7 +54,7 @@ public class StoredDirectoryHelper { this.tag = tag; if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(path.getScheme())) { - this.ioTree = new File(URI.create(path.toString())); + ioTree = Paths.get(URI.create(path.toString())); return; } @@ -53,10 +66,6 @@ public class StoredDirectoryHelper { throw new IOException(e); } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - throw new IOException("Storage Access Framework with Directory API is not available"); - } - this.docTree = DocumentFile.fromTreeUri(context, path); if (this.docTree == null) { @@ -69,13 +78,17 @@ public class StoredDirectoryHelper { } public StoredFileHelper createUniqueFile(final String name, final String mime) { - final ArrayList matches = new ArrayList<>(); + final List matches = new ArrayList<>(); final String[] filename = splitFilename(name); - final String lcFilename = filename[0].toLowerCase(); + final String lcFileName = filename[0].toLowerCase(); - if (docTree == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - for (final File file : ioTree.listFiles()) { - addIfStartWith(matches, lcFilename, file.getName()); + if (docTree == null) { + try (Stream stream = Files.list(ioTree)) { + matches.addAll(stream.map(path -> path.getFileName().toString().toLowerCase()) + .filter(fileName -> fileName.startsWith(lcFileName)) + .collect(Collectors.toList())); + } catch (final IOException e) { + Log.e(TAG, "Exception while traversing " + ioTree, e); } } else { // warning: SAF file listing is very slow @@ -87,37 +100,37 @@ public class StoredDirectoryHelper { final ContentResolver cr = context.getContentResolver(); try (Cursor cursor = cr.query(docTreeChildren, projection, selection, - new String[]{lcFilename}, null)) { + new String[]{lcFileName}, null)) { if (cursor != null) { while (cursor.moveToNext()) { - addIfStartWith(matches, lcFilename, cursor.getString(0)); + addIfStartWith(matches, lcFileName, cursor.getString(0)); } } } } - if (matches.size() < 1) { + if (matches.isEmpty()) { return createFile(name, mime, true); - } else { - // check if the filename is in use - String lcName = name.toLowerCase(); - for (final String testName : matches) { - if (testName.equals(lcName)) { - lcName = null; - break; - } - } + } - // check if not in use - if (lcName != null) { - return createFile(name, mime, true); + // check if the filename is in use + String lcName = name.toLowerCase(); + for (final String testName : matches) { + if (testName.equals(lcName)) { + lcName = null; + break; } } + // create file if filename not in use + if (lcName != null) { + return createFile(name, mime, true); + } + Collections.sort(matches, String::compareTo); for (int i = 1; i < 1000; i++) { - if (Collections.binarySearch(matches, makeFileName(lcFilename, i, filename[1])) < 0) { + if (Collections.binarySearch(matches, makeFileName(lcFileName, i, filename[1])) < 0) { return createFile(makeFileName(filename[0], i, filename[1]), mime, true); } } @@ -146,11 +159,11 @@ public class StoredDirectoryHelper { } public Uri getUri() { - return docTree == null ? Uri.fromFile(ioTree) : docTree.getUri(); + return docTree == null ? Uri.fromFile(ioTree.toFile()) : docTree.getUri(); } public boolean exists() { - return docTree == null ? ioTree.exists() : docTree.exists(); + return docTree == null ? Files.exists(ioTree) : docTree.exists(); } /** @@ -162,10 +175,48 @@ public class StoredDirectoryHelper { return docTree == null; } + /** + * Get free memory of the storage partition (root of the directory). + * @return amount of free memory in the volume of current directory (bytes) + */ + @RequiresApi(api = Build.VERSION_CODES.N) // Necessary for `getStorageVolume()` + public long getFreeMemory() { + final Uri uri = getUri(); + final StorageManager storageManager = (StorageManager) context. + getSystemService(Context.STORAGE_SERVICE); + final List volumes = storageManager.getStorageVolumes(); + + final String docId = DocumentsContract.getDocumentId(uri); + final String[] split = docId.split(":"); + if (split.length > 0) { + final String volumeId = split[0]; + + for (final StorageVolume volume : volumes) { + // if the volume is an internal system volume + if (volume.isPrimary() && volumeId.equalsIgnoreCase("primary")) { + return Utility.getSystemFreeMemory(); + } + + // if the volume is a removable volume (normally an SD card) + if (volume.isRemovable() && !volume.isPrimary()) { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + try { + final String sdCardUUID = volume.getUuid(); + return storageManager.getAllocatableBytes(UUID.fromString(sdCardUUID)); + } catch (final Exception e) { + // do nothing + } + } + } + } + } + return Long.MAX_VALUE; + } + /** * Only using Java I/O. Creates the directory named by this abstract pathname, including any - * necessary but nonexistent parent directories. Note that if this - * operation fails it may have succeeded in creating some of the necessary + * necessary but nonexistent parent directories. + * Note that if this operation fails it may have succeeded in creating some of the necessary * parent directories. * * @return true if and only if the directory was created, @@ -174,7 +225,12 @@ public class StoredDirectoryHelper { */ public boolean mkdirs() { if (docTree == null) { - return ioTree.exists() || ioTree.mkdirs(); + try { + Files.createDirectories(ioTree); + } catch (final IOException e) { + Log.e(TAG, "Error while creating directories at " + ioTree, e); + } + return Files.exists(ioTree); } if (docTree.exists()) { @@ -211,8 +267,8 @@ public class StoredDirectoryHelper { public Uri findFile(final String filename) { if (docTree == null) { - final File res = new File(ioTree, filename); - return res.exists() ? Uri.fromFile(res) : null; + final Path res = ioTree.resolve(filename); + return Files.exists(res) ? Uri.fromFile(res.toFile()) : null; } final DocumentFile res = findFileSAFHelper(context, docTree, filename); @@ -220,7 +276,7 @@ public class StoredDirectoryHelper { } public boolean canWrite() { - return docTree == null ? ioTree.canWrite() : docTree.canWrite(); + return docTree == null ? Files.isWritable(ioTree) : docTree.canWrite(); } /** @@ -235,14 +291,14 @@ public class StoredDirectoryHelper { @NonNull @Override public String toString() { - return (docTree == null ? Uri.fromFile(ioTree) : docTree.getUri()).toString(); + return (docTree == null ? Uri.fromFile(ioTree.toFile()) : docTree.getUri()).toString(); } //////////////////// // Utils /////////////////// - private static void addIfStartWith(final ArrayList list, @NonNull final String base, + private static void addIfStartWith(final List list, @NonNull final String base, final String str) { if (isNullOrEmpty(str)) { return; @@ -253,6 +309,12 @@ public class StoredDirectoryHelper { } } + /** + * Splits the filename into the name and extension. + * + * @param filename The filename to split + * @return A String array with the name at index 0 and extension at index 1 + */ private static String[] splitFilename(@NonNull final String filename) { final int dotIndex = filename.lastIndexOf('.'); @@ -264,7 +326,7 @@ public class StoredDirectoryHelper { } private static String makeFileName(final String name, final int idx, final String ext) { - return name.concat(" (").concat(String.valueOf(idx)).concat(")").concat(ext); + return name + "(" + idx + ")" + ext; } /** @@ -277,7 +339,7 @@ public class StoredDirectoryHelper { */ static DocumentFile findFileSAFHelper(@Nullable final Context context, final DocumentFile tree, final String filename) { - if (context == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + if (context == null) { return tree.findFile(filename); // warning: this is very slow } diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java b/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java index 9fe4a9340..5404426c4 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java +++ b/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java @@ -1,6 +1,5 @@ package org.schabi.newpipe.streams.io; -import android.annotation.TargetApi; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; @@ -24,6 +23,9 @@ import java.io.File; import java.io.IOException; import java.io.Serializable; import java.net.URI; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import us.shandian.giga.io.FileStream; import us.shandian.giga.io.FileStreamSAF; @@ -37,7 +39,7 @@ public class StoredFileHelper implements Serializable { private transient DocumentFile docFile; private transient DocumentFile docTree; - private transient File ioFile; + private transient Path ioPath; private transient Context context; protected String source; @@ -50,7 +52,8 @@ public class StoredFileHelper implements Serializable { public StoredFileHelper(final Context context, final Uri uri, final String mime) { if (FilePickerActivityHelper.isOwnFileUri(context, uri)) { - ioFile = Utils.getFileForUri(uri); + final File ioFile = Utils.getFileForUri(uri); + ioPath = ioFile.toPath(); source = Uri.fromFile(ioFile).toString(); } else { docFile = DocumentFile.fromSingleUri(context, uri); @@ -74,7 +77,6 @@ public class StoredFileHelper implements Serializable { this.tag = tag; } - @TargetApi(Build.VERSION_CODES.LOLLIPOP) StoredFileHelper(@Nullable final Context context, final DocumentFile tree, final String filename, final String mime, final boolean safe) throws IOException { @@ -102,29 +104,20 @@ public class StoredFileHelper implements Serializable { this.srcType = this.docFile.getType(); } - StoredFileHelper(final File location, final String filename, final String mime) + StoredFileHelper(final Path location, final String filename, final String mime) throws IOException { - this.ioFile = new File(location, filename); + ioPath = location.resolve(filename); - if (this.ioFile.exists()) { - if (!this.ioFile.isFile() && !this.ioFile.delete()) { - throw new IOException("The filename is already in use by non-file entity " - + "and cannot overwrite it"); - } - } else { - if (!this.ioFile.createNewFile()) { - throw new IOException("Cannot create the file"); - } - } + Files.deleteIfExists(ioPath); + Files.createFile(ioPath); - this.source = Uri.fromFile(this.ioFile).toString(); - this.sourceTree = Uri.fromFile(location).toString(); + source = Uri.fromFile(ioPath.toFile()).toString(); + sourceTree = Uri.fromFile(location.toFile()).toString(); - this.srcName = ioFile.getName(); - this.srcType = mime; + srcName = ioPath.getFileName().toString(); + srcType = mime; } - @TargetApi(Build.VERSION_CODES.KITKAT) public StoredFileHelper(final Context context, @Nullable final Uri parent, @NonNull final Uri path, final String tag) throws IOException { this.tag = tag; @@ -132,12 +125,12 @@ public class StoredFileHelper implements Serializable { if (path.getScheme() == null || path.getScheme().equalsIgnoreCase(ContentResolver.SCHEME_FILE)) { - this.ioFile = new File(URI.create(this.source)); + this.ioPath = Paths.get(URI.create(this.source)); } else { final DocumentFile file = DocumentFile.fromSingleUri(context, path); if (file == null) { - throw new RuntimeException("SAF not available"); + throw new IOException("SAF not available"); } this.context = context; @@ -190,7 +183,7 @@ public class StoredFileHelper implements Serializable { assertValid(); if (docFile == null) { - return new FileStream(ioFile); + return new FileStream(ioPath.toFile()); } else { return new FileStreamSAF(context.getContentResolver(), docFile.getUri()); } @@ -214,7 +207,7 @@ public class StoredFileHelper implements Serializable { public Uri getUri() { assertValid(); - return docFile == null ? Uri.fromFile(ioFile) : docFile.getUri(); + return docFile == null ? Uri.fromFile(ioPath.toFile()) : docFile.getUri(); } public Uri getParentUri() { @@ -236,7 +229,12 @@ public class StoredFileHelper implements Serializable { return true; } if (docFile == null) { - return ioFile.delete(); + try { + return Files.deleteIfExists(ioPath); + } catch (final IOException e) { + Log.e(TAG, "Exception while deleting " + ioPath, e); + return false; + } } final boolean res = docFile.delete(); @@ -255,21 +253,30 @@ public class StoredFileHelper implements Serializable { public long length() { assertValid(); - return docFile == null ? ioFile.length() : docFile.length(); + if (docFile == null) { + try { + return Files.size(ioPath); + } catch (final IOException e) { + Log.e(TAG, "Exception while getting the size of " + ioPath, e); + return 0; + } + } else { + return docFile.length(); + } } public boolean canWrite() { if (source == null) { return false; } - return docFile == null ? ioFile.canWrite() : docFile.canWrite(); + return docFile == null ? Files.isWritable(ioPath) : docFile.canWrite(); } public String getName() { if (source == null) { return srcName; } else if (docFile == null) { - return ioFile.getName(); + return ioPath.getFileName().toString(); } final String name = docFile.getName(); @@ -290,12 +297,11 @@ public class StoredFileHelper implements Serializable { } public boolean existsAsFile() { - if (source == null || (docFile == null && ioFile == null)) { + if (source == null || (docFile == null && ioPath == null)) { if (DEBUG) { Log.d(TAG, "existsAsFile called but something is null: source = [" + (source == null ? "null => storage is invalid" : source) - + "], docFile = [" + (docFile == null ? "null" : docFile) - + "], ioFile = [" + (ioFile == null ? "null" : ioFile) + "]"); + + "], docFile = [" + docFile + "], ioPath = [" + ioPath + "]"); } return false; } @@ -303,7 +309,7 @@ public class StoredFileHelper implements Serializable { // WARNING: DocumentFile.exists() and DocumentFile.isFile() methods are slow // docFile.isVirtual() means it is non-physical? return docFile == null - ? (ioFile.exists() && ioFile.isFile()) + ? Files.isRegularFile(ioPath) : (docFile.exists() && docFile.isFile()); } @@ -313,8 +319,10 @@ public class StoredFileHelper implements Serializable { if (docFile == null) { try { - result = ioFile.createNewFile(); + Files.createFile(ioPath); + result = true; } catch (final IOException e) { + Log.e(TAG, "Exception while creating " + ioPath, e); return false; } } else if (docTree == null) { @@ -335,7 +343,8 @@ public class StoredFileHelper implements Serializable { } if (result) { - source = (docFile == null ? Uri.fromFile(ioFile) : docFile.getUri()).toString(); + source = (docFile == null ? Uri.fromFile(ioPath.toFile()) : docFile.getUri()) + .toString(); srcName = getName(); srcType = getType(); } @@ -355,7 +364,7 @@ public class StoredFileHelper implements Serializable { docTree = null; docFile = null; - ioFile = null; + ioPath = null; context = null; } @@ -386,7 +395,7 @@ public class StoredFileHelper implements Serializable { } if (this.isDirect()) { - return this.ioFile.getPath().equalsIgnoreCase(storage.ioFile.getPath()); + return this.ioPath.equals(storage.ioPath); } return DocumentsContract.getDocumentId(this.docFile.getUri()) diff --git a/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java b/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java new file mode 100644 index 000000000..90689052e --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/AudioTrackAdapter.java @@ -0,0 +1,94 @@ +package org.schabi.newpipe.util; + +import android.content.Context; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.BaseAdapter; +import android.widget.ImageView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper; + +import java.io.Serializable; +import java.util.List; +import java.util.stream.Collectors; + +/** + * A list adapter for groups of {@link AudioStream}s (audio tracks). + */ +public class AudioTrackAdapter extends BaseAdapter { + private final AudioTracksWrapper tracksWrapper; + + public AudioTrackAdapter(final AudioTracksWrapper tracksWrapper) { + this.tracksWrapper = tracksWrapper; + } + + @Override + public int getCount() { + return tracksWrapper.size(); + } + + @Override + public List getItem(final int position) { + return tracksWrapper.getTracksList().get(position).getStreamsList(); + } + + @Override + public long getItemId(final int position) { + return position; + } + + @Override + public View getView(final int position, final View convertView, final ViewGroup parent) { + final var context = parent.getContext(); + final View view; + if (convertView == null) { + view = LayoutInflater.from(context).inflate( + R.layout.stream_quality_item, parent, false); + } else { + view = convertView; + } + + final ImageView woSoundIconView = view.findViewById(R.id.wo_sound_icon); + final TextView formatNameView = view.findViewById(R.id.stream_format_name); + final TextView qualityView = view.findViewById(R.id.stream_quality); + final TextView sizeView = view.findViewById(R.id.stream_size); + + final List streams = getItem(position); + final AudioStream stream = streams.get(0); + + woSoundIconView.setVisibility(View.GONE); + sizeView.setVisibility(View.VISIBLE); + + if (stream.getAudioTrackId() != null) { + formatNameView.setText(stream.getAudioTrackId()); + } + qualityView.setText(Localization.audioTrackName(context, stream)); + + return view; + } + + public static class AudioTracksWrapper implements Serializable { + private final List> tracksList; + + public AudioTracksWrapper(@NonNull final List> groupedAudioStreams, + @Nullable final Context context) { + this.tracksList = groupedAudioStreams.stream().map(streams -> + new StreamInfoWrapper<>(streams, context)).collect(Collectors.toList()); + } + + public List> getTracksList() { + return tracksList; + } + + public int size() { + return tracksList.size(); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java b/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java new file mode 100644 index 000000000..8e8d38490 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/ChannelTabHelper.java @@ -0,0 +1,151 @@ +package org.schabi.newpipe.util; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.annotation.StringRes; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.channel.tabs.ChannelTabs; +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; + +import java.util.List; +import java.util.Set; + +public final class ChannelTabHelper { + private ChannelTabHelper() { + } + + /** + * @param tab the channel tab to check + * @return whether the tab should contain (playable) streams or not + */ + public static boolean isStreamsTab(final String tab) { + switch (tab) { + case ChannelTabs.VIDEOS: + case ChannelTabs.TRACKS: + case ChannelTabs.SHORTS: + case ChannelTabs.LIVESTREAMS: + return true; + default: + return false; + } + } + + /** + * @param tab the channel tab link handler to check + * @return whether the tab should contain (playable) streams or not + */ + public static boolean isStreamsTab(final ListLinkHandler tab) { + final List contentFilters = tab.getContentFilters(); + if (contentFilters.isEmpty()) { + return false; // this should never happen, but check just to be sure + } else { + return isStreamsTab(contentFilters.get(0)); + } + } + + @StringRes + private static int getShowTabKey(final String tab) { + switch (tab) { + case ChannelTabs.VIDEOS: + return R.string.show_channel_tabs_videos; + case ChannelTabs.TRACKS: + return R.string.show_channel_tabs_tracks; + case ChannelTabs.SHORTS: + return R.string.show_channel_tabs_shorts; + case ChannelTabs.LIVESTREAMS: + return R.string.show_channel_tabs_livestreams; + case ChannelTabs.CHANNELS: + return R.string.show_channel_tabs_channels; + case ChannelTabs.PLAYLISTS: + return R.string.show_channel_tabs_playlists; + case ChannelTabs.ALBUMS: + return R.string.show_channel_tabs_albums; + default: + return -1; + } + } + + @StringRes + private static int getFetchFeedTabKey(final String tab) { + switch (tab) { + case ChannelTabs.VIDEOS: + return R.string.fetch_channel_tabs_videos; + case ChannelTabs.TRACKS: + return R.string.fetch_channel_tabs_tracks; + case ChannelTabs.SHORTS: + return R.string.fetch_channel_tabs_shorts; + case ChannelTabs.LIVESTREAMS: + return R.string.fetch_channel_tabs_livestreams; + default: + return -1; + } + } + + @StringRes + public static int getTranslationKey(final String tab) { + switch (tab) { + case ChannelTabs.VIDEOS: + return R.string.channel_tab_videos; + case ChannelTabs.TRACKS: + return R.string.channel_tab_tracks; + case ChannelTabs.SHORTS: + return R.string.channel_tab_shorts; + case ChannelTabs.LIVESTREAMS: + return R.string.channel_tab_livestreams; + case ChannelTabs.CHANNELS: + return R.string.channel_tab_channels; + case ChannelTabs.PLAYLISTS: + return R.string.channel_tab_playlists; + case ChannelTabs.ALBUMS: + return R.string.channel_tab_albums; + default: + return R.string.unknown_content; + } + } + + public static boolean showChannelTab(final Context context, + final SharedPreferences sharedPreferences, + @StringRes final int key) { + final Set enabledTabs = sharedPreferences.getStringSet( + context.getString(R.string.show_channel_tabs_key), null); + if (enabledTabs == null) { + return true; // default to true + } else { + return enabledTabs.contains(context.getString(key)); + } + } + + public static boolean showChannelTab(final Context context, + final SharedPreferences sharedPreferences, + final String tab) { + final int key = ChannelTabHelper.getShowTabKey(tab); + if (key == -1) { + return false; + } + return showChannelTab(context, sharedPreferences, key); + } + + public static boolean fetchFeedChannelTab(final Context context, + final SharedPreferences sharedPreferences, + final ListLinkHandler tab) { + final List contentFilters = tab.getContentFilters(); + if (contentFilters.isEmpty()) { + return false; // this should never happen, but check just to be sure + } + + final int key = ChannelTabHelper.getFetchFeedTabKey(contentFilters.get(0)); + if (key == -1) { + return false; + } + + final Set enabledTabs = sharedPreferences.getStringSet( + context.getString(R.string.feed_fetch_channel_tabs_key), null); + if (enabledTabs == null) { + return true; // default to true + } else { + return enabledTabs.contains(context.getString(key)); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java b/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java deleted file mode 100644 index 7c87e664b..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java +++ /dev/null @@ -1,71 +0,0 @@ -package org.schabi.newpipe.util; - -import android.text.Layout; -import android.text.Selection; -import android.text.Spannable; -import android.text.Spanned; -import android.text.style.ClickableSpan; -import android.text.style.URLSpan; -import android.view.MotionEvent; -import android.view.View; -import android.widget.TextView; - -import org.schabi.newpipe.util.external_communication.ShareUtils; -import org.schabi.newpipe.util.external_communication.InternalUrlsHandler; - -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -public class CommentTextOnTouchListener implements View.OnTouchListener { - public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener(); - - @Override - public boolean onTouch(final View v, final MotionEvent event) { - if (!(v instanceof TextView)) { - return false; - } - final TextView widget = (TextView) v; - final Object text = widget.getText(); - if (text instanceof Spanned) { - final Spannable buffer = (Spannable) text; - - final int action = event.getAction(); - - if (action == MotionEvent.ACTION_UP - || action == MotionEvent.ACTION_DOWN) { - int x = (int) event.getX(); - int y = (int) event.getY(); - - x -= widget.getTotalPaddingLeft(); - y -= widget.getTotalPaddingTop(); - - x += widget.getScrollX(); - y += widget.getScrollY(); - - final Layout layout = widget.getLayout(); - final int line = layout.getLineForVertical(y); - final int off = layout.getOffsetForHorizontal(line, x); - - final ClickableSpan[] link = buffer.getSpans(off, off, - ClickableSpan.class); - - if (link.length != 0) { - if (action == MotionEvent.ACTION_UP) { - if (link[0] instanceof URLSpan) { - final String url = ((URLSpan) link[0]).getURL(); - if (!InternalUrlsHandler.handleUrlCommentsTimestamp( - new CompositeDisposable(), v.getContext(), url)) { - ShareUtils.openUrlInBrowser(v.getContext(), url, false); - } - } - } else if (action == MotionEvent.ACTION_DOWN) { - Selection.setSelection(buffer, - buffer.getSpanStart(link[0]), - buffer.getSpanEnd(link[0])); - } - return true; - } - } - } - return false; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/CookieUtils.java b/app/src/main/java/org/schabi/newpipe/util/CookieUtils.java deleted file mode 100644 index d970ec472..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/CookieUtils.java +++ /dev/null @@ -1,25 +0,0 @@ -package org.schabi.newpipe.util; - -import android.text.TextUtils; - -import java.util.Arrays; -import java.util.Collection; -import java.util.HashSet; -import java.util.Set; - -public final class CookieUtils { - private CookieUtils() { - } - - public static String concatCookies(final Collection cookieStrings) { - final Set cookieSet = new HashSet<>(); - for (final String cookies : cookieStrings) { - cookieSet.addAll(splitCookies(cookies)); - } - return TextUtils.join("; ", cookieSet).trim(); - } - - public static Set splitCookies(final String cookies) { - return new HashSet<>(Arrays.asList(cookies.split("; *"))); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/DependentPreferenceHelper.java b/app/src/main/java/org/schabi/newpipe/util/DependentPreferenceHelper.java new file mode 100644 index 000000000..9591beddb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/DependentPreferenceHelper.java @@ -0,0 +1,51 @@ +package org.schabi.newpipe.util; + +import android.content.Context; +import android.content.SharedPreferences; + +import androidx.preference.PreferenceManager; + +import org.schabi.newpipe.R; + +/** + * For preferences with dependencies and multiple use case, + * this class can be used to reduce the lines of code. + */ +public final class DependentPreferenceHelper { + + private DependentPreferenceHelper() { + // no instance + } + + /** + * Option `Resume playback` depends on `Watch history`, this method can be used to retrieve if + * `Resume playback` and its dependencies are all enabled. + * + * @param context the Android context + * @return returns true if `Resume playback` and `Watch history` are both enabled + */ + public static boolean getResumePlaybackEnabled(final Context context) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + + return prefs.getBoolean(context.getString( + R.string.enable_watch_history_key), true) + && prefs.getBoolean(context.getString( + R.string.enable_playback_resume_key), true); + } + + /** + * Option `Position in lists` depends on `Watch history`, this method can be used to retrieve if + * `Position in lists` and its dependencies are all enabled. + * + * @param context the Android context + * @return returns true if `Positions in lists` and `Watch history` are both enabled + */ + public static boolean getPositionsInListsEnabled(final Context context) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + + return prefs.getBoolean(context.getString( + R.string.enable_watch_history_key), true) + && prefs.getBoolean(context.getString( + R.string.enable_playback_state_lists_key), true); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java b/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java index a4ff5ff19..e9678c2b0 100644 --- a/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java +++ b/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java @@ -1,14 +1,22 @@ package org.schabi.newpipe.util; +import static android.content.Context.INPUT_SERVICE; + +import android.annotation.SuppressLint; import android.app.UiModeManager; import android.content.Context; import android.content.pm.PackageManager; import android.content.res.Configuration; +import android.graphics.Point; +import android.hardware.input.InputManager; import android.os.BatteryManager; import android.os.Build; import android.provider.Settings; import android.util.TypedValue; +import android.view.InputDevice; import android.view.KeyEvent; +import android.view.WindowInsets; +import android.view.WindowManager; import androidx.annotation.Dimension; import androidx.annotation.NonNull; @@ -19,27 +27,99 @@ import androidx.preference.PreferenceManager; import org.schabi.newpipe.App; import org.schabi.newpipe.R; +import java.lang.reflect.Method; + public final class DeviceUtils { private static final String AMAZON_FEATURE_FIRE_TV = "amazon.hardware.fire_tv"; + private static final boolean SAMSUNG = Build.MANUFACTURER.equals("samsung"); private static Boolean isTV = null; private static Boolean isFireTV = null; - /* - * Devices that do not support media tunneling + /** + *

The app version code that corresponds to the last update + * of the media tunneling device blacklist.

+ *

The value of this variable needs to be updated everytime a new device that does not + * support media tunneling to match the upcoming version code.

+ * @see #shouldSupportMediaTunneling() + */ + public static final int MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION = 994; + + // region: devices not supporting media tunneling / media tunneling blacklist + /** + *

Formuler Z8 Pro, Z8, CC, Z Alpha, Z+ Neo.

+ *

Blacklist reason: black screen

+ *

Board: HiSilicon Hi3798MV200

*/ - // Formuler Z8 Pro, Z8, CC, Z Alpha, Z+ Neo private static final boolean HI3798MV200 = Build.VERSION.SDK_INT == 24 && Build.DEVICE.equals("Hi3798MV200"); - // Zephir TS43UHD-2 + /** + *

Zephir TS43UHD-2.

+ *

Blacklist reason: black screen

+ */ private static final boolean CVT_MT5886_EU_1G = Build.VERSION.SDK_INT == 24 && Build.DEVICE.equals("cvt_mt5886_eu_1g"); - // Hilife TV + /** + * Hilife TV. + *

Blacklist reason: black screen

+ */ private static final boolean REALTEKATV = Build.VERSION.SDK_INT == 25 && Build.DEVICE.equals("RealtekATV"); - // Philips QM16XE + /** + *

Phillips 4K (O)LED TV.

+ * Supports custom ROMs with different API levels + */ + private static final boolean PH7M_EU_5596 = Build.VERSION.SDK_INT >= 26 + && Build.DEVICE.equals("PH7M_EU_5596"); + /** + *

Philips QM16XE.

+ *

Blacklist reason: black screen

+ */ private static final boolean QM16XE_U = Build.VERSION.SDK_INT == 23 && Build.DEVICE.equals("QM16XE_U"); + /** + *

Sony Bravia VH1.

+ *

Processor: MT5895

+ *

Blacklist reason: fullscreen crash / stuttering

+ */ + private static final boolean BRAVIA_VH1 = Build.VERSION.SDK_INT == 29 + && Build.DEVICE.equals("BRAVIA_VH1"); + /** + *

Sony Bravia VH2.

+ *

Blacklist reason: fullscreen crash; this includes model A90J as reported in + * + * #9023

+ */ + private static final boolean BRAVIA_VH2 = Build.VERSION.SDK_INT == 29 + && Build.DEVICE.equals("BRAVIA_VH2"); + /** + *

Sony Bravia Android TV platform 2.

+ * Uses a MediaTek MT5891 (MT5596) SoC. + * @see + * https://github.com/CiNcH83/bravia_atv2 + */ + private static final boolean BRAVIA_ATV2 = Build.DEVICE.equals("BRAVIA_ATV2"); + /** + *

Sony Bravia Android TV platform 3 4K.

+ *

Uses ARM MT5891 and a {@link #BRAVIA_ATV2} motherboard.

+ * + * @see + * https://browser.geekbench.com/v4/cpu/9101105 + */ + private static final boolean BRAVIA_ATV3_4K = Build.DEVICE.equals("BRAVIA_ATV3_4K"); + /** + *

Panasonic 4KTV-JUP.

+ *

Blacklist reason: fullscreen crash

+ */ + private static final boolean TX_50JXW834 = Build.DEVICE.equals("TX_50JXW834"); + /** + *

Bouygtel4K / Bouygues Telecom Bbox 4K.

+ *

Blacklist reason: black screen; reported at + * + * #10122

+ */ + private static final boolean HMB9213NW = Build.DEVICE.equals("HMB9213NW"); + // endregion private DeviceUtils() { } @@ -65,7 +145,7 @@ public final class DeviceUtils { boolean isTv = ContextCompat.getSystemService(context, UiModeManager.class) .getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION || isFireTv() - || pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION); + || pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK); // from https://stackoverflow.com/a/58932366 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { @@ -77,14 +157,86 @@ public final class DeviceUtils { && pm.hasSystemFeature(PackageManager.FEATURE_ETHERNET)); } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - isTv = isTv || pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK); - } - DeviceUtils.isTV = isTv; return DeviceUtils.isTV; } + /** + * Checks if the device is in desktop or DeX mode. This function should only + * be invoked once on view load as it is using reflection for the DeX checks. + * @param context the context to use for services and config. + * @return true if the Android device is in desktop mode or using DeX. + */ + @SuppressWarnings("JavaReflectionMemberAccess") + public static boolean isDesktopMode(@NonNull final Context context) { + // Adapted from https://stackoverflow.com/a/64615568 + // to check for all input devices that have an active cursor + final InputManager im = (InputManager) context.getSystemService(INPUT_SERVICE); + for (final int id : im.getInputDeviceIds()) { + final InputDevice inputDevice = im.getInputDevice(id); + if (inputDevice.supportsSource(InputDevice.SOURCE_BLUETOOTH_STYLUS) + || inputDevice.supportsSource(InputDevice.SOURCE_MOUSE) + || inputDevice.supportsSource(InputDevice.SOURCE_STYLUS) + || inputDevice.supportsSource(InputDevice.SOURCE_TOUCHPAD) + || inputDevice.supportsSource(InputDevice.SOURCE_TRACKBALL)) { + return true; + } + } + + final UiModeManager uiModeManager = + ContextCompat.getSystemService(context, UiModeManager.class); + if (uiModeManager != null + && uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_DESK) { + return true; + } + + if (!SAMSUNG) { + return false; + // DeX is Samsung-specific, skip the checks below on non-Samsung devices + } + // DeX check for standalone and multi-window mode, from: + // https://developer.samsung.com/samsung-dex/modify-optimizing.html + try { + final Configuration config = context.getResources().getConfiguration(); + final Class configClass = config.getClass(); + final int semDesktopModeEnabledConst = + configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass); + final int currentMode = + configClass.getField("semDesktopModeEnabled").getInt(config); + if (semDesktopModeEnabledConst == currentMode) { + return true; + } + } catch (final NoSuchFieldException | IllegalAccessException ignored) { + // Device doesn't seem to support DeX + } + + @SuppressLint("WrongConstant") final Object desktopModeManager = context + .getApplicationContext() + .getSystemService("desktopmode"); + + if (desktopModeManager != null) { + try { + final Method getDesktopModeStateMethod = desktopModeManager.getClass() + .getDeclaredMethod("getDesktopModeState"); + final Object desktopModeState = getDesktopModeStateMethod + .invoke(desktopModeManager); + final Class desktopModeStateClass = desktopModeState.getClass(); + final Method getEnabledMethod = desktopModeStateClass + .getDeclaredMethod("getEnabled"); + final int enabledStatus = (int) getEnabledMethod.invoke(desktopModeState); + if (enabledStatus == desktopModeStateClass + .getDeclaredField("ENABLED").getInt(desktopModeStateClass)) { + return true; + } + } catch (final Exception ignored) { + // Device does not support DeX 3.0 or something went wrong when trying to determine + // if it supports this feature + } + } + + return false; + } + public static boolean isTablet(@NonNull final Context context) { final String tabletModeSetting = PreferenceManager.getDefaultSharedPreferences(context) .getString(context.getString(R.string.tablet_mode_key), ""); @@ -128,19 +280,6 @@ public final class DeviceUtils { context.getResources().getDisplayMetrics()); } - /** - * Some devices have broken tunneled video playback but claim to support it. - * See https://github.com/TeamNewPipe/NewPipe/issues/5911 - * @return false if Kitkat (does not support tunneling) or affected device - */ - public static boolean shouldSupportMediaTunneling() { - return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP - && !HI3798MV200 - && !CVT_MT5886_EU_1G - && !REALTEKATV - && !QM16XE_U; - } - public static boolean isLandscape(final Context context) { return context.getResources().getDisplayMetrics().heightPixels < context.getResources() .getDisplayMetrics().widthPixels; @@ -156,4 +295,44 @@ public final class DeviceUtils { Settings.Global.ANIMATOR_DURATION_SCALE, 1F) != 0F; } + + public static int getWindowHeight(@NonNull final WindowManager windowManager) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + final var windowMetrics = windowManager.getCurrentWindowMetrics(); + final var windowInsets = windowMetrics.getWindowInsets(); + final var insets = windowInsets.getInsetsIgnoringVisibility( + WindowInsets.Type.navigationBars() | WindowInsets.Type.displayCutout()); + return windowMetrics.getBounds().height() - (insets.top + insets.bottom); + } else { + final Point point = new Point(); + windowManager.getDefaultDisplay().getSize(point); + return point.y; + } + } + + /** + *

Some devices have broken tunneled video playback but claim to support it.

+ *

This can cause a black video player surface while attempting to play a video or + * crashes while entering or exiting the full screen player. + * The issue effects Android TVs most commonly. + * See #5911 and + * #9023 for more info.

+ * @Note Update {@link #MEDIA_TUNNELING_DEVICE_BLACKLIST_VERSION} + * when adding a new device to the method. + * @return {@code false} if affected device; {@code true} otherwise + */ + public static boolean shouldSupportMediaTunneling() { + // Maintainers note: update MEDIA_TUNNELING_DEVICES_UPDATE_APP_VERSION_CODE + return !HI3798MV200 + && !CVT_MT5886_EU_1G + && !REALTEKATV + && !QM16XE_U + && !BRAVIA_VH1 + && !BRAVIA_VH2 + && !BRAVIA_ATV2 + && !BRAVIA_ATV3_4K + && !PH7M_EU_5596 + && !TX_50JXW834 + && !HMB9213NW; + } } diff --git a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java index 27009efd1..066d5f570 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -20,12 +20,14 @@ package org.schabi.newpipe.util; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; +import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD; import android.content.Context; import android.util.Log; import android.view.View; import android.widget.TextView; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.text.HtmlCompat; import androidx.preference.PreferenceManager; @@ -35,23 +37,21 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.Info; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage; -import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.MetaInfo; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.Page; -import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.channel.ChannelInfo; +import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo; import org.schabi.newpipe.extractor.comments.CommentsInfo; import org.schabi.newpipe.extractor.comments.CommentsInfoItem; -import org.schabi.newpipe.extractor.feed.FeedExtractor; -import org.schabi.newpipe.extractor.feed.FeedInfo; import org.schabi.newpipe.extractor.kiosk.KioskInfo; +import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.search.SearchInfo; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor; -import org.schabi.newpipe.util.external_communication.TextLinkifier; +import org.schabi.newpipe.util.text.TextLinkifier; import java.util.Collections; import java.util.List; @@ -114,46 +114,43 @@ public final class ExtractorHelper { public static Single getStreamInfo(final int serviceId, final String url, final boolean forceLoad) { checkServiceId(serviceId); - return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.STREAM, + return checkCache(forceLoad, serviceId, url, InfoCache.Type.STREAM, Single.fromCallable(() -> StreamInfo.getInfo(NewPipe.getService(serviceId), url))); } public static Single getChannelInfo(final int serviceId, final String url, final boolean forceLoad) { checkServiceId(serviceId); - return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.CHANNEL, + return checkCache(forceLoad, serviceId, url, InfoCache.Type.CHANNEL, Single.fromCallable(() -> ChannelInfo.getInfo(NewPipe.getService(serviceId), url))); } - public static Single> getMoreChannelItems(final int serviceId, - final String url, - final Page nextPage) { - checkServiceId(serviceId); - return Single.fromCallable(() -> - ChannelInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage)); - } - - public static Single> getFeedInfoFallbackToChannelInfo( - final int serviceId, final String url) { - final Maybe> maybeFeedInfo = Maybe.fromCallable(() -> { - final StreamingService service = NewPipe.getService(serviceId); - final FeedExtractor feedExtractor = service.getFeedExtractor(url); - - if (feedExtractor == null) { - return null; - } - - return FeedInfo.getInfo(feedExtractor); - }); - - return maybeFeedInfo.switchIfEmpty(getChannelInfo(serviceId, url, true)); - } - - public static Single getCommentsInfo(final int serviceId, final String url, + public static Single getChannelTab(final int serviceId, + final ListLinkHandler listLinkHandler, final boolean forceLoad) { checkServiceId(serviceId); - return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.COMMENT, + return checkCache(forceLoad, serviceId, + listLinkHandler.getUrl(), InfoCache.Type.CHANNEL_TAB, + Single.fromCallable(() -> + ChannelTabInfo.getInfo(NewPipe.getService(serviceId), listLinkHandler))); + } + + public static Single> getMoreChannelTabItems( + final int serviceId, + final ListLinkHandler listLinkHandler, + final Page nextPage) { + checkServiceId(serviceId); + return Single.fromCallable(() -> + ChannelTabInfo.getMoreItems(NewPipe.getService(serviceId), + listLinkHandler, nextPage)); + } + + public static Single getCommentsInfo(final int serviceId, + final String url, + final boolean forceLoad) { + checkServiceId(serviceId); + return checkCache(forceLoad, serviceId, url, InfoCache.Type.COMMENTS, Single.fromCallable(() -> CommentsInfo.getInfo(NewPipe.getService(serviceId), url))); } @@ -167,11 +164,20 @@ public final class ExtractorHelper { CommentsInfo.getMoreItems(NewPipe.getService(serviceId), info, nextPage)); } + public static Single> getMoreCommentItems( + final int serviceId, + final String url, + final Page nextPage) { + checkServiceId(serviceId); + return Single.fromCallable(() -> + CommentsInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage)); + } + public static Single getPlaylistInfo(final int serviceId, final String url, final boolean forceLoad) { checkServiceId(serviceId); - return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.PLAYLIST, + return checkCache(forceLoad, serviceId, url, InfoCache.Type.PLAYLIST, Single.fromCallable(() -> PlaylistInfo.getInfo(NewPipe.getService(serviceId), url))); } @@ -184,9 +190,10 @@ public final class ExtractorHelper { PlaylistInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage)); } - public static Single getKioskInfo(final int serviceId, final String url, + public static Single getKioskInfo(final int serviceId, + final String url, final boolean forceLoad) { - return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.PLAYLIST, + return checkCache(forceLoad, serviceId, url, InfoCache.Type.KIOSK, Single.fromCallable(() -> KioskInfo.getInfo(NewPipe.getService(serviceId), url))); } @@ -198,7 +205,7 @@ public final class ExtractorHelper { } /*////////////////////////////////////////////////////////////////////////// - // Utils + // Cache //////////////////////////////////////////////////////////////////////////*/ /** @@ -210,25 +217,26 @@ public final class ExtractorHelper { * @param forceLoad whether to force loading from the network instead of from the cache * @param serviceId the service to load from * @param url the URL to load - * @param infoType the {@link InfoItem.InfoType} of the item + * @param cacheType the {@link InfoCache.Type} of the item * @param loadFromNetwork the {@link Single} to load the item from the network * @return a {@link Single} that loads the item */ private static Single checkCache(final boolean forceLoad, - final int serviceId, final String url, - final InfoItem.InfoType infoType, - final Single loadFromNetwork) { + final int serviceId, + @NonNull final String url, + @NonNull final InfoCache.Type cacheType, + @NonNull final Single loadFromNetwork) { checkServiceId(serviceId); final Single actualLoadFromNetwork = loadFromNetwork - .doOnSuccess(info -> CACHE.putInfo(serviceId, url, info, infoType)); + .doOnSuccess(info -> CACHE.putInfo(serviceId, url, info, cacheType)); final Single load; if (forceLoad) { - CACHE.removeInfo(serviceId, url, infoType); + CACHE.removeInfo(serviceId, url, cacheType); load = actualLoadFromNetwork; } else { - load = Maybe.concat(ExtractorHelper.loadFromCache(serviceId, url, infoType), - actualLoadFromNetwork.toMaybe()) + load = Maybe.concat(ExtractorHelper.loadFromCache(serviceId, url, cacheType), + actualLoadFromNetwork.toMaybe()) .firstElement() // Take the first valid .toSingle(); } @@ -239,18 +247,20 @@ public final class ExtractorHelper { /** * Default implementation uses the {@link InfoCache} to get cached results. * - * @param the item type's class that extends {@link Info} - * @param serviceId the service to load from - * @param url the URL to load - * @param infoType the {@link InfoItem.InfoType} of the item + * @param the item type's class that extends {@link Info} + * @param serviceId the service to load from + * @param url the URL to load + * @param cacheType the {@link InfoCache.Type} of the item * @return a {@link Single} that loads the item */ - private static Maybe loadFromCache(final int serviceId, final String url, - final InfoItem.InfoType infoType) { + private static Maybe loadFromCache( + final int serviceId, + @NonNull final String url, + @NonNull final InfoCache.Type cacheType) { checkServiceId(serviceId); return Maybe.defer(() -> { //noinspection unchecked - final I info = (I) CACHE.getFromKey(serviceId, url, infoType); + final I info = (I) CACHE.getFromKey(serviceId, url, cacheType); if (MainActivity.DEBUG) { Log.d(TAG, "loadFromCache() called, info > " + info); } @@ -264,20 +274,27 @@ public final class ExtractorHelper { }); } - public static boolean isCached(final int serviceId, final String url, - final InfoItem.InfoType infoType) { - return null != loadFromCache(serviceId, url, infoType).blockingGet(); + public static boolean isCached(final int serviceId, + @NonNull final String url, + @NonNull final InfoCache.Type cacheType) { + return null != loadFromCache(serviceId, url, cacheType).blockingGet(); } + + /*////////////////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////////////////*/ + /** * Formats the text contained in the meta info list as HTML and puts it into the text view, * while also making the separator visible. If the list is null or empty, or the user chose not * to see meta information, both the text view and the separator are hidden - * @param metaInfos a list of meta information, can be null or empty - * @param metaInfoTextView the text view in which to show the formatted HTML + * + * @param metaInfos a list of meta information, can be null or empty + * @param metaInfoTextView the text view in which to show the formatted HTML * @param metaInfoSeparator another view to be shown or hidden accordingly to the text view - * @param disposables disposables created by the method are added here and their lifecycle - * should be handled by the calling class + * @param disposables disposables created by the method are added here and their lifecycle + * should be handled by the calling class */ public static void showMetaInfoInTextView(@Nullable final List metaInfos, final TextView metaInfoTextView, @@ -286,7 +303,7 @@ public final class ExtractorHelper { final Context context = metaInfoTextView.getContext(); if (metaInfos == null || metaInfos.isEmpty() || !PreferenceManager.getDefaultSharedPreferences(context).getBoolean( - context.getString(R.string.show_meta_info_key), true)) { + context.getString(R.string.show_meta_info_key), true)) { metaInfoTextView.setVisibility(View.GONE); metaInfoSeparator.setVisibility(View.GONE); @@ -319,8 +336,9 @@ public final class ExtractorHelper { } metaInfoSeparator.setVisibility(View.VISIBLE); - TextLinkifier.createLinksFromHtmlBlock(metaInfoTextView, stringBuilder.toString(), - HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, disposables); + TextLinkifier.fromHtml(metaInfoTextView, stringBuilder.toString(), + HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, null, disposables, + SET_LINK_MOVEMENT_METHOD); } } diff --git a/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java b/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java index 20d8ce30c..d7fb39651 100644 --- a/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java @@ -76,7 +76,7 @@ public class FilePickerActivityHelper extends com.nononsenseapps.filepicker.File public static class CustomFilePickerFragment extends FilePickerFragment { @Override - public View onCreateView(final LayoutInflater inflater, final ViewGroup container, + public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { return super.onCreateView(inflater, container, savedInstanceState); } @@ -138,7 +138,7 @@ public class FilePickerActivityHelper extends com.nononsenseapps.filepicker.File } @Override - public void onLoadFinished(final Loader> loader, + public void onLoadFinished(@NonNull final Loader> loader, final SortedList data) { super.onLoadFinished(loader, data); layoutManager.scrollToPosition(0); diff --git a/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.java b/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.java index edcb565a0..bc15f3f02 100644 --- a/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.java +++ b/app/src/main/java/org/schabi/newpipe/util/FilenameUtils.java @@ -7,6 +7,7 @@ import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; +import java.util.regex.Matcher; import java.util.regex.Pattern; public final class FilenameUtils { @@ -51,7 +52,7 @@ public final class FilenameUtils { final Pattern pattern = Pattern.compile(charset); - return createFilename(title, pattern, replacementChar); + return createFilename(title, pattern, Matcher.quoteReplacement(replacementChar)); } /** diff --git a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java index a07f05828..b9c91f8a5 100644 --- a/app/src/main/java/org/schabi/newpipe/util/InfoCache.java +++ b/app/src/main/java/org/schabi/newpipe/util/InfoCache.java @@ -27,7 +27,6 @@ import androidx.collection.LruCache; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.extractor.Info; -import org.schabi.newpipe.extractor.InfoItem; import java.util.Map; @@ -48,14 +47,27 @@ public final class InfoCache { // no instance } + /** + * Identifies the type of {@link Info} to put into the cache. + */ + public enum Type { + STREAM, + CHANNEL, + CHANNEL_TAB, + COMMENTS, + PLAYLIST, + KIOSK, + } + public static InfoCache getInstance() { return INSTANCE; } @NonNull - private static String keyOf(final int serviceId, @NonNull final String url, - @NonNull final InfoItem.InfoType infoType) { - return serviceId + url + infoType.toString(); + private static String keyOf(final int serviceId, + @NonNull final String url, + @NonNull final Type cacheType) { + return serviceId + ":" + cacheType.ordinal() + ":" + url; } private static void removeStaleCache() { @@ -83,19 +95,22 @@ public final class InfoCache { } @Nullable - public Info getFromKey(final int serviceId, @NonNull final String url, - @NonNull final InfoItem.InfoType infoType) { + public Info getFromKey(final int serviceId, + @NonNull final String url, + @NonNull final Type cacheType) { if (DEBUG) { Log.d(TAG, "getFromKey() called with: " + "serviceId = [" + serviceId + "], url = [" + url + "]"); } synchronized (LRU_CACHE) { - return getInfo(keyOf(serviceId, url, infoType)); + return getInfo(keyOf(serviceId, url, cacheType)); } } - public void putInfo(final int serviceId, @NonNull final String url, @NonNull final Info info, - @NonNull final InfoItem.InfoType infoType) { + public void putInfo(final int serviceId, + @NonNull final String url, + @NonNull final Info info, + @NonNull final Type cacheType) { if (DEBUG) { Log.d(TAG, "putInfo() called with: info = [" + info + "]"); } @@ -103,18 +118,19 @@ public final class InfoCache { final long expirationMillis = ServiceHelper.getCacheExpirationMillis(info.getServiceId()); synchronized (LRU_CACHE) { final CacheData data = new CacheData(info, expirationMillis); - LRU_CACHE.put(keyOf(serviceId, url, infoType), data); + LRU_CACHE.put(keyOf(serviceId, url, cacheType), data); } } - public void removeInfo(final int serviceId, @NonNull final String url, - @NonNull final InfoItem.InfoType infoType) { + public void removeInfo(final int serviceId, + @NonNull final String url, + @NonNull final Type cacheType) { if (DEBUG) { Log.d(TAG, "removeInfo() called with: " + "serviceId = [" + serviceId + "], url = [" + url + "]"); } synchronized (LRU_CACHE) { - LRU_CACHE.remove(keyOf(serviceId, url, infoType)); + LRU_CACHE.remove(keyOf(serviceId, url, cacheType)); } } diff --git a/app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java b/app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java index 71c0d3944..a709dc32e 100644 --- a/app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java +++ b/app/src/main/java/org/schabi/newpipe/util/KeyboardUtil.java @@ -24,7 +24,19 @@ public final class KeyboardUtil { if (editText.requestFocus()) { final InputMethodManager imm = ContextCompat.getSystemService(activity, InputMethodManager.class); - imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED); + if (!imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED)) { + /* + * Sometimes the keyboard can't be shown because Android's ImeFocusController is in + * a incorrect state e.g. when animations are disabled or the unfocus event of the + * previous view arrives in the wrong moment (see #7647 for details). + * The invalid state can be fixed by to re-focusing the editText. + */ + editText.clearFocus(); + editText.requestFocus(); + + // Try again + imm.showSoftInput(editText, InputMethodManager.SHOW_FORCED); + } } } diff --git a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java index c3ccef87c..f1904565d 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ListHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ListHelper.java @@ -1,7 +1,10 @@ package org.schabi.newpipe.util; +import static org.schabi.newpipe.extractor.ServiceList.YouTube; + import android.content.Context; import android.content.SharedPreferences; +import android.content.res.Resources; import android.net.ConnectivityManager; import androidx.annotation.NonNull; @@ -13,6 +16,9 @@ import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.AudioTrackType; +import org.schabi.newpipe.extractor.stream.DeliveryMethod; +import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.VideoStream; import java.util.ArrayList; @@ -20,35 +26,55 @@ import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; -import java.util.HashSet; import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.Set; +import java.util.function.Predicate; import java.util.stream.Collectors; public final class ListHelper { // Video format in order of quality. 0=lowest quality, n=highest quality private static final List VIDEO_FORMAT_QUALITY_RANKING = - Arrays.asList(MediaFormat.v3GPP, MediaFormat.WEBM, MediaFormat.MPEG_4); + List.of(MediaFormat.v3GPP, MediaFormat.WEBM, MediaFormat.MPEG_4); // Audio format in order of quality. 0=lowest quality, n=highest quality private static final List AUDIO_FORMAT_QUALITY_RANKING = - Arrays.asList(MediaFormat.MP3, MediaFormat.WEBMA, MediaFormat.M4A); - // Audio format in order of efficiency. 0=most efficient, n=least efficient + List.of(MediaFormat.MP3, MediaFormat.WEBMA, MediaFormat.M4A); + // Audio format in order of efficiency. 0=least efficient, n=most efficient private static final List AUDIO_FORMAT_EFFICIENCY_RANKING = - Arrays.asList(MediaFormat.WEBMA, MediaFormat.M4A, MediaFormat.MP3); + List.of(MediaFormat.MP3, MediaFormat.M4A, MediaFormat.WEBMA); + // Use a Set for better performance + private static final Set HIGH_RESOLUTION_LIST = Set.of("1440p", "2160p"); + // Audio track types in order of priority. 0=lowest, n=highest + private static final List AUDIO_TRACK_TYPE_RANKING = + List.of(AudioTrackType.DESCRIPTIVE, AudioTrackType.DUBBED, AudioTrackType.ORIGINAL); + // Audio track types in order of priority when descriptive audio is preferred. + private static final List AUDIO_TRACK_TYPE_RANKING_DESCRIPTIVE = + List.of(AudioTrackType.ORIGINAL, AudioTrackType.DUBBED, AudioTrackType.DESCRIPTIVE); - private static final Set HIGH_RESOLUTION_LIST - // Uses a HashSet for better performance - = new HashSet<>(Arrays.asList("1440p", "2160p", "1440p60", "2160p60")); + /** + * List of supported YouTube Itag ids. + * The original order is kept. + * @see {@link org.schabi.newpipe.extractor.services.youtube.ItagItem#ITAG_LIST} + */ + private static final List SUPPORTED_ITAG_IDS = + List.of( + 17, 36, // video v3GPP + 18, 34, 35, 59, 78, 22, 37, 38, // video MPEG4 + 43, 44, 45, 46, // video webm + 171, 172, 139, 140, 141, 249, 250, 251, // audio + 160, 133, 134, 135, 212, 136, 298, 137, 299, 266, // video only + 278, 242, 243, 244, 245, 246, 247, 248, 271, 272, 302, 303, 308, 313, 315 + ); private ListHelper() { } /** - * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) * @param context Android app context * @param videoStreams list of the video streams to check * @return index of the video stream with the default index + * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) */ public static int getDefaultResolutionIndex(final Context context, final List videoStreams) { @@ -58,11 +84,11 @@ public final class ListHelper { } /** - * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) * @param context Android app context * @param videoStreams list of the video streams to check * @param defaultResolution the default resolution to look for * @return index of the video stream with the default index + * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) */ public static int getResolutionIndex(final Context context, final List videoStreams, @@ -71,10 +97,10 @@ public final class ListHelper { } /** - * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) - * @param context Android app context - * @param videoStreams list of the video streams to check + * @param context Android app context + * @param videoStreams list of the video streams to check * @return index of the video stream with the default index + * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) */ public static int getPopupDefaultResolutionIndex(final Context context, final List videoStreams) { @@ -84,11 +110,11 @@ public final class ListHelper { } /** - * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) * @param context Android app context * @param videoStreams list of the video streams to check * @param defaultResolution the default resolution to look for * @return index of the video stream with the default index + * @see #getDefaultResolutionIndex(String, String, MediaFormat, List) */ public static int getPopupResolutionIndex(final Context context, final List videoStreams, @@ -98,16 +124,94 @@ public final class ListHelper { public static int getDefaultAudioFormat(final Context context, final List audioStreams) { - final MediaFormat defaultFormat = getDefaultFormat(context, - R.string.default_audio_format_key, R.string.default_audio_format_value); + return getAudioIndexByHighestRank(audioStreams, + getAudioTrackComparator(context).thenComparing(getAudioFormatComparator(context))); + } - // If the user has chosen to limit resolution to conserve mobile data - // usage then we should also limit our audio usage. - if (isLimitingDataUsage(context)) { - return getMostCompactAudioIndex(defaultFormat, audioStreams); - } else { - return getHighestQualityAudioIndex(defaultFormat, audioStreams); + public static int getDefaultAudioTrackGroup(final Context context, + final List> groupedAudioStreams) { + if (groupedAudioStreams == null || groupedAudioStreams.isEmpty()) { + return -1; } + + final Comparator cmp = getAudioTrackComparator(context); + final List highestRanked = groupedAudioStreams.stream() + .max((o1, o2) -> cmp.compare(o1.get(0), o2.get(0))) + .orElse(null); + return groupedAudioStreams.indexOf(highestRanked); + } + + public static int getAudioFormatIndex(final Context context, + final List audioStreams, + @Nullable final String trackId) { + if (trackId != null) { + for (int i = 0; i < audioStreams.size(); i++) { + final AudioStream s = audioStreams.get(i); + if (s.getAudioTrackId() != null + && s.getAudioTrackId().equals(trackId)) { + return i; + } + } + } + return getDefaultAudioFormat(context, audioStreams); + } + + /** + * Return a {@link Stream} list which uses the given delivery method from a {@link Stream} + * list. + * + * @param streamList the original {@link Stream stream} list + * @param deliveryMethod the {@link DeliveryMethod delivery method} + * @param the item type's class that extends {@link Stream} + * @return a {@link Stream stream} list which uses the given delivery method + */ + @NonNull + public static List getStreamsOfSpecifiedDelivery( + @Nullable final List streamList, + final DeliveryMethod deliveryMethod) { + return getFilteredStreamList(streamList, + stream -> stream.getDeliveryMethod() == deliveryMethod); + } + + /** + * Return a {@link Stream} list which only contains URL streams and non-torrent streams. + * + * @param streamList the original stream list + * @param the item type's class that extends {@link Stream} + * @return a stream list which only contains URL streams and non-torrent streams + */ + @NonNull + public static List getUrlAndNonTorrentStreams( + @Nullable final List streamList) { + return getFilteredStreamList(streamList, + stream -> stream.isUrl() && stream.getDeliveryMethod() != DeliveryMethod.TORRENT); + } + + /** + * Return a {@link Stream} list which only contains streams which can be played by the player. + * + *

+ * Some formats are not supported, see {@link #SUPPORTED_ITAG_IDS} for more details. + * Torrent streams are also removed, because they cannot be retrieved, like OPUS streams using + * HLS as their delivery method, since they are not supported by ExoPlayer. + *

+ * + * @param the item type's class that extends {@link Stream} + * @param streamList the original stream list + * @param serviceId the service ID from which the streams' list comes from + * @return a stream list which only contains streams that can be played the player + */ + @NonNull + public static List getPlayableStreams( + @Nullable final List streamList, final int serviceId) { + final int youtubeServiceId = YouTube.getServiceId(); + return getFilteredStreamList(streamList, + stream -> stream.getDeliveryMethod() != DeliveryMethod.TORRENT + && (stream.getDeliveryMethod() != DeliveryMethod.HLS + || stream.getFormat() != MediaFormat.OPUS) + && (serviceId != youtubeServiceId + || stream.getItagItem() == null + || SUPPORTED_ITAG_IDS.contains(stream.getItagItem().id))); } /** @@ -129,8 +233,8 @@ public final class ListHelper { @Nullable final List videoOnlyStreams, final boolean ascendingOrder, final boolean preferVideoOnlyStreams) { - final SharedPreferences preferences - = PreferenceManager.getDefaultSharedPreferences(context); + final SharedPreferences preferences = + PreferenceManager.getDefaultSharedPreferences(context); final boolean showHigherResolutions = preferences.getBoolean( context.getString(R.string.show_higher_resolutions_key), false); @@ -141,14 +245,155 @@ public final class ListHelper { videoOnlyStreams, ascendingOrder, preferVideoOnlyStreams); } + /** + * Get a sorted list containing a set of default resolution info + * and additional resolution info if showHigherResolutions is true. + * + * @param resources the resources to get the resolutions from + * @param defaultResolutionKey the settings key of the default resolution + * @param additionalResolutionKey the settings key of the additional resolutions + * @param showHigherResolutions if higher resolutions should be included in the sorted list + * @return a sorted list containing the default and maybe additional resolutions + */ + public static List getSortedResolutionList( + final Resources resources, + final int defaultResolutionKey, + final int additionalResolutionKey, + final boolean showHigherResolutions) { + final List resolutions = new ArrayList<>(Arrays.asList( + resources.getStringArray(defaultResolutionKey))); + if (!showHigherResolutions) { + return resolutions; + } + final List additionalResolutions = Arrays.asList( + resources.getStringArray(additionalResolutionKey)); + // keep "best resolution" at the top + resolutions.addAll(1, additionalResolutions); + return resolutions; + } + + public static boolean isHighResolutionSelected(final String selectedResolution, + final int additionalResolutionKey, + final Resources resources) { + return Arrays.asList(resources.getStringArray( + additionalResolutionKey)) + .contains(selectedResolution); + } + + /** + * Filter the list of audio streams and return a list with the preferred stream for + * each audio track. Streams are sorted with the preferred language in the first position. + * + * @param context the context to search for the track to give preference + * @param audioStreams the list of audio streams + * @return the sorted, filtered list + */ + public static List getFilteredAudioStreams( + @NonNull final Context context, + @Nullable final List audioStreams) { + if (audioStreams == null) { + return Collections.emptyList(); + } + + final HashMap collectedStreams = new HashMap<>(); + + final Comparator cmp = getAudioFormatComparator(context); + + for (final AudioStream stream : audioStreams) { + if (stream.getDeliveryMethod() == DeliveryMethod.TORRENT + || (stream.getDeliveryMethod() == DeliveryMethod.HLS + && stream.getFormat() == MediaFormat.OPUS)) { + continue; + } + + final String trackId = Objects.toString(stream.getAudioTrackId(), ""); + + final AudioStream presentStream = collectedStreams.get(trackId); + if (presentStream == null || cmp.compare(stream, presentStream) > 0) { + collectedStreams.put(trackId, stream); + } + } + + // Filter unknown audio tracks if there are multiple tracks + if (collectedStreams.size() > 1) { + collectedStreams.remove(""); + } + + // Sort collected streams by name + return collectedStreams.values().stream().sorted(getAudioTrackNameComparator(context)) + .collect(Collectors.toList()); + } + + /** + * Group the list of audioStreams by their track ID and sort the resulting list by track name. + * + * @param context app context to get track names for sorting + * @param audioStreams list of audio streams + * @return list of audio streams lists representing individual tracks + */ + public static List> getGroupedAudioStreams( + @NonNull final Context context, + @Nullable final List audioStreams) { + if (audioStreams == null) { + return Collections.emptyList(); + } + + final HashMap> collectedStreams = new HashMap<>(); + + for (final AudioStream stream : audioStreams) { + final String trackId = Objects.toString(stream.getAudioTrackId(), ""); + if (collectedStreams.containsKey(trackId)) { + collectedStreams.get(trackId).add(stream); + } else { + final List list = new ArrayList<>(); + list.add(stream); + collectedStreams.put(trackId, list); + } + } + + // Filter unknown audio tracks if there are multiple tracks + if (collectedStreams.size() > 1) { + collectedStreams.remove(""); + } + + // Sort tracks alphabetically, sort track streams by quality + final Comparator nameCmp = getAudioTrackNameComparator(context); + final Comparator formatCmp = getAudioFormatComparator(context); + + return collectedStreams.values().stream() + .sorted((o1, o2) -> nameCmp.compare(o1.get(0), o2.get(0))) + .map(streams -> streams.stream().sorted(formatCmp).collect(Collectors.toList())) + .collect(Collectors.toList()); + } + /*////////////////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////////////////*/ - private static String computeDefaultResolution(final Context context, final int key, + /** + * Get a filtered stream list, by using Java 8 Stream's API and the given predicate. + * + * @param streamList the stream list to filter + * @param streamListPredicate the predicate which will be used to filter streams + * @param the item type's class that extends {@link Stream} + * @return a new stream list filtered using the given predicate + */ + private static List getFilteredStreamList( + @Nullable final List streamList, + final Predicate streamListPredicate) { + if (streamList == null) { + return Collections.emptyList(); + } + + return streamList.stream() + .filter(streamListPredicate) + .collect(Collectors.toList()); + } + + private static String computeDefaultResolution(@NonNull final Context context, final int key, final int value) { - final SharedPreferences preferences - = PreferenceManager.getDefaultSharedPreferences(context); + final SharedPreferences preferences = + PreferenceManager.getDefaultSharedPreferences(context); // Load the preferred resolution otherwise the best available String resolution = preferences != null @@ -165,19 +410,20 @@ public final class ListHelper { } /** - * Return the index of the default stream in the list, based on the parameters - * defaultResolution and defaultFormat. + * Return the index of the default stream in the list, that will be sorted in the process, based + * on the parameters defaultResolution and defaultFormat. * * @param defaultResolution the default resolution to look for * @param bestResolutionKey key of the best resolution * @param defaultFormat the default format to look for - * @param videoStreams list of the video streams to check - * @return index of the default resolution&format + * @param videoStreams a mutable list of the video streams to check (it will be sorted in + * place) + * @return index of the default resolution&format in the sorted videoStreams */ static int getDefaultResolutionIndex(final String defaultResolution, final String bestResolutionKey, final MediaFormat defaultFormat, - final List videoStreams) { + @Nullable final List videoStreams) { if (videoStreams == null || videoStreams.isEmpty()) { return -1; } @@ -187,8 +433,8 @@ public final class ListHelper { return 0; } - final int defaultStreamIndex - = getVideoStreamIndex(defaultResolution, defaultFormat, videoStreams); + final int defaultStreamIndex = + getVideoStreamIndex(defaultResolution, defaultFormat, videoStreams); // this is actually an error, // but maybe there is really no stream fitting to the default value. @@ -233,7 +479,9 @@ public final class ListHelper { .flatMap(List::stream) // Filter out higher resolutions (or not if high resolutions should always be shown) .filter(stream -> showHigherResolutions - || !HIGH_RESOLUTION_LIST.contains(stream.getResolution())) + || !HIGH_RESOLUTION_LIST.contains(stream.getResolution() + // Replace any frame rate with nothing + .replaceAll("p\\d+$", "p"))) .collect(Collectors.toList()); final HashMap hashMap = new HashMap<>(); @@ -275,74 +523,30 @@ public final class ListHelper { */ private static List sortStreamList(final List videoStreams, final boolean ascendingOrder) { - final Comparator comparator = ListHelper::compareVideoStreamResolution; + // Compares the quality of two video streams. + final Comparator comparator = Comparator.nullsLast(Comparator + .comparing(VideoStream::getResolution, ListHelper::compareVideoStreamResolution) + .thenComparingInt(s -> VIDEO_FORMAT_QUALITY_RANKING.indexOf(s.getFormat()))); Collections.sort(videoStreams, ascendingOrder ? comparator : comparator.reversed()); return videoStreams; } - /** - * Get the audio from the list with the highest quality. - * Format will be ignored if it yields no results. - * - * @param format The target format type or null if it doesn't matter - * @param audioStreams List of audio streams - * @return Index of audio stream that produces the most compact results or -1 if not found - */ - static int getHighestQualityAudioIndex(@Nullable final MediaFormat format, - @Nullable final List audioStreams) { - return getAudioIndexByHighestRank(format, audioStreams, - // Compares descending (last = highest rank) - (s1, s2) -> compareAudioStreamBitrate(s1, s2, AUDIO_FORMAT_QUALITY_RANKING) - ); - } - - /** - * Get the audio from the list with the lowest bitrate and most efficient format. - * Format will be ignored if it yields no results. - * - * @param format The target format type or null if it doesn't matter - * @param audioStreams List of audio streams - * @return Index of audio stream that produces the most compact results or -1 if not found - */ - static int getMostCompactAudioIndex(@Nullable final MediaFormat format, - @Nullable final List audioStreams) { - - return getAudioIndexByHighestRank(format, audioStreams, - // The "-" is important -> Compares ascending (first = highest rank) - (s1, s2) -> -compareAudioStreamBitrate(s1, s2, AUDIO_FORMAT_EFFICIENCY_RANKING) - ); - } - /** * Get the audio-stream from the list with the highest rank, depending on the comparator. * Format will be ignored if it yields no results. * - * @param targetedFormat The target format type or null if it doesn't matter - * @param audioStreams List of audio streams - * @param comparator The comparator used for determining the max/best/highest ranked value + * @param audioStreams List of audio streams + * @param comparator The comparator used for determining the max/best/highest ranked value * @return Index of audio stream that produces the highest ranked result or -1 if not found */ - private static int getAudioIndexByHighestRank(@Nullable final MediaFormat targetedFormat, - @Nullable final List audioStreams, - final Comparator comparator) { + static int getAudioIndexByHighestRank(@Nullable final List audioStreams, + final Comparator comparator) { if (audioStreams == null || audioStreams.isEmpty()) { return -1; } final AudioStream highestRankedAudioStream = audioStreams.stream() - .filter(audioStream -> targetedFormat == null - || audioStream.getFormat() == targetedFormat) - .max(comparator) - .orElse(null); - - if (highestRankedAudioStream == null) { - // Fallback: Ignore targetedFormat if not null - if (targetedFormat != null) { - return getAudioIndexByHighestRank(null, audioStreams, comparator); - } - // targetedFormat is already null -> return -1 - return -1; - } + .max(comparator).orElse(null); return audioStreams.indexOf(highestRankedAudioStream); } @@ -366,8 +570,9 @@ public final class ListHelper { * @param videoStreams the available video streams * @return the index of the preferred video stream */ - static int getVideoStreamIndex(final String targetResolution, final MediaFormat targetFormat, - final List videoStreams) { + static int getVideoStreamIndex(@NonNull final String targetResolution, + final MediaFormat targetFormat, + @NonNull final List videoStreams) { int fullMatchIndex = -1; int fullMatchNoRefreshIndex = -1; int resMatchOnlyIndex = -1; @@ -376,8 +581,9 @@ public final class ListHelper { final String targetResolutionNoRefresh = targetResolution.replaceAll("p\\d+$", "p"); for (int idx = 0; idx < videoStreams.size(); idx++) { - final MediaFormat format - = targetFormat == null ? null : videoStreams.get(idx).getFormat(); + final MediaFormat format = targetFormat == null + ? null + : videoStreams.get(idx).getFormat(); final String resolution = videoStreams.get(idx).getResolution(); final String resolutionNoRefresh = resolution.replaceAll("p\\d+$", "p"); @@ -428,7 +634,7 @@ public final class ListHelper { * @param videoStreams the list of video streams to check * @return the index of the preferred video stream */ - private static int getDefaultResolutionWithDefaultFormat(final Context context, + private static int getDefaultResolutionWithDefaultFormat(@NonNull final Context context, final String defaultResolution, final List videoStreams) { final MediaFormat defaultFormat = getDefaultFormat(context, @@ -437,28 +643,25 @@ public final class ListHelper { context.getString(R.string.best_resolution_key), defaultFormat, videoStreams); } - private static MediaFormat getDefaultFormat(final Context context, + @Nullable + private static MediaFormat getDefaultFormat(@NonNull final Context context, @StringRes final int defaultFormatKey, @StringRes final int defaultFormatValueKey) { - final SharedPreferences preferences - = PreferenceManager.getDefaultSharedPreferences(context); + final SharedPreferences preferences = + PreferenceManager.getDefaultSharedPreferences(context); final String defaultFormat = context.getString(defaultFormatValueKey); final String defaultFormatString = preferences.getString( - context.getString(defaultFormatKey), defaultFormat); + context.getString(defaultFormatKey), + defaultFormat + ); - MediaFormat defaultMediaFormat = getMediaFormatFromKey(context, defaultFormatString); - if (defaultMediaFormat == null) { - preferences.edit().putString(context.getString(defaultFormatKey), defaultFormat) - .apply(); - defaultMediaFormat = getMediaFormatFromKey(context, defaultFormat); - } - - return defaultMediaFormat; + return getMediaFormatFromKey(context, defaultFormatString); } - private static MediaFormat getMediaFormatFromKey(final Context context, - final String formatKey) { + @Nullable + private static MediaFormat getMediaFormatFromKey(@NonNull final Context context, + @NonNull final String formatKey) { MediaFormat format = null; if (formatKey.equals(context.getString(R.string.video_webm_key))) { format = MediaFormat.WEBM; @@ -474,59 +677,23 @@ public final class ListHelper { return format; } - // Compares the quality of two audio streams - private static int compareAudioStreamBitrate(final AudioStream streamA, - final AudioStream streamB, - final List formatRanking) { - if (streamA == null) { - return -1; - } - if (streamB == null) { + private static int compareVideoStreamResolution(@NonNull final String r1, + @NonNull final String r2) { + try { + final int res1 = Integer.parseInt(r1.replaceAll("0p\\d+$", "1") + .replaceAll("[^\\d.]", "")); + final int res2 = Integer.parseInt(r2.replaceAll("0p\\d+$", "1") + .replaceAll("[^\\d.]", "")); + return res1 - res2; + } catch (final NumberFormatException e) { + // Consider the first one greater because we don't know if the two streams are + // different or not (a NumberFormatException was thrown so we don't know the resolution + // of one stream or of all streams) return 1; } - if (streamA.getAverageBitrate() < streamB.getAverageBitrate()) { - return -1; - } - if (streamA.getAverageBitrate() > streamB.getAverageBitrate()) { - return 1; - } - - // Same bitrate and format - return formatRanking.indexOf(streamA.getFormat()) - - formatRanking.indexOf(streamB.getFormat()); } - private static int compareVideoStreamResolution(final String r1, final String r2) { - final int res1 = Integer.parseInt(r1.replaceAll("0p\\d+$", "1") - .replaceAll("[^\\d.]", "")); - final int res2 = Integer.parseInt(r2.replaceAll("0p\\d+$", "1") - .replaceAll("[^\\d.]", "")); - return res1 - res2; - } - - // Compares the quality of two video streams. - private static int compareVideoStreamResolution(final VideoStream streamA, - final VideoStream streamB) { - if (streamA == null) { - return -1; - } - if (streamB == null) { - return 1; - } - - final int resComp = compareVideoStreamResolution(streamA.getResolution(), - streamB.getResolution()); - if (resComp != 0) { - return resComp; - } - - // Same bitrate and format - return ListHelper.VIDEO_FORMAT_QUALITY_RANKING.indexOf(streamA.getFormat()) - - ListHelper.VIDEO_FORMAT_QUALITY_RANKING.indexOf(streamB.getFormat()); - } - - - private static boolean isLimitingDataUsage(final Context context) { + static boolean isLimitingDataUsage(@NonNull final Context context) { return getResolutionLimit(context) != null; } @@ -536,11 +703,11 @@ public final class ListHelper { * @param context App context * @return maximum resolution allowed or null if there is no maximum */ - private static String getResolutionLimit(final Context context) { + private static String getResolutionLimit(@NonNull final Context context) { String resolutionLimit = null; if (isMeteredNetwork(context)) { - final SharedPreferences preferences - = PreferenceManager.getDefaultSharedPreferences(context); + final SharedPreferences preferences = + PreferenceManager.getDefaultSharedPreferences(context); final String defValue = context.getString(R.string.limit_data_usage_none_key); final String value = preferences.getString( context.getString(R.string.limit_mobile_data_usage_key), defValue); @@ -555,13 +722,159 @@ public final class ListHelper { * @param context App context * @return {@code true} if connected to a metered network */ - public static boolean isMeteredNetwork(final Context context) { - final ConnectivityManager manager - = ContextCompat.getSystemService(context, ConnectivityManager.class); + public static boolean isMeteredNetwork(@NonNull final Context context) { + final ConnectivityManager manager = + ContextCompat.getSystemService(context, ConnectivityManager.class); if (manager == null || manager.getActiveNetworkInfo() == null) { return false; } return manager.isActiveNetworkMetered(); } + + /** + * Get a {@link Comparator} to compare {@link AudioStream}s by their format and bitrate. + * + *

The preferred stream will be ordered last.

+ * + * @param context app context + * @return Comparator + */ + private static Comparator getAudioFormatComparator( + final @NonNull Context context) { + final MediaFormat defaultFormat = getDefaultFormat(context, + R.string.default_audio_format_key, R.string.default_audio_format_value); + return getAudioFormatComparator(defaultFormat, isLimitingDataUsage(context)); + } + + /** + * Get a {@link Comparator} to compare {@link AudioStream}s by their format and bitrate. + * + *

The preferred stream will be ordered last.

+ * + * @param defaultFormat the default format to look for + * @param limitDataUsage choose low bitrate audio stream + * @return Comparator + */ + static Comparator getAudioFormatComparator( + @Nullable final MediaFormat defaultFormat, final boolean limitDataUsage) { + final List formatRanking = limitDataUsage + ? AUDIO_FORMAT_EFFICIENCY_RANKING : AUDIO_FORMAT_QUALITY_RANKING; + + Comparator bitrateComparator = + Comparator.comparingInt(AudioStream::getAverageBitrate); + if (limitDataUsage) { + bitrateComparator = bitrateComparator.reversed(); + } + + return Comparator.comparing(AudioStream::getFormat, (o1, o2) -> { + if (defaultFormat != null) { + return Boolean.compare(o1 == defaultFormat, o2 == defaultFormat); + } + return 0; + }).thenComparing(bitrateComparator).thenComparingInt( + stream -> formatRanking.indexOf(stream.getFormat())); + } + + /** + * Get a {@link Comparator} to compare {@link AudioStream}s by their tracks. + * + *

Tracks will be compared this order:

+ *
    + *
  1. If {@code preferOriginalAudio}: use original audio
  2. + *
  3. Language matches {@code preferredLanguage}
  4. + *
  5. + * Track type ranks highest in this order: + * Original > Dubbed > Descriptive + *

    If {@code preferDescriptiveAudio}: + * Descriptive > Dubbed > Original

    + *
  6. + *
  7. Language is English
  8. + *
+ * + *

The preferred track will be ordered last.

+ * + * @param context App context + * @return Comparator + */ + private static Comparator getAudioTrackComparator( + @NonNull final Context context) { + final SharedPreferences preferences = + PreferenceManager.getDefaultSharedPreferences(context); + final Locale preferredLanguage = Localization.getPreferredLocale(context); + final boolean preferOriginalAudio = + preferences.getBoolean(context.getString(R.string.prefer_original_audio_key), + false); + final boolean preferDescriptiveAudio = + preferences.getBoolean(context.getString(R.string.prefer_descriptive_audio_key), + false); + + return getAudioTrackComparator(preferredLanguage, preferOriginalAudio, + preferDescriptiveAudio); + } + + /** + * Get a {@link Comparator} to compare {@link AudioStream}s by their tracks. + * + *

Tracks will be compared this order:

+ *
    + *
  1. If {@code preferOriginalAudio}: use original audio
  2. + *
  3. Language matches {@code preferredLanguage}
  4. + *
  5. + * Track type ranks highest in this order: + * Original > Dubbed > Descriptive + *

    If {@code preferDescriptiveAudio}: + * Descriptive > Dubbed > Original

    + *
  6. + *
  7. Language is English
  8. + *
+ * + *

The preferred track will be ordered last.

+ * + * @param preferredLanguage Preferred audio stream language + * @param preferOriginalAudio Get the original audio track regardless of its language + * @param preferDescriptiveAudio Prefer the descriptive audio track if available + * @return Comparator + */ + static Comparator getAudioTrackComparator( + final Locale preferredLanguage, + final boolean preferOriginalAudio, + final boolean preferDescriptiveAudio) { + final String langCode = preferredLanguage.getISO3Language(); + final List trackTypeRanking = preferDescriptiveAudio + ? AUDIO_TRACK_TYPE_RANKING_DESCRIPTIVE : AUDIO_TRACK_TYPE_RANKING; + + return Comparator.comparing(AudioStream::getAudioTrackType, (o1, o2) -> { + if (preferOriginalAudio) { + return Boolean.compare( + o1 == AudioTrackType.ORIGINAL, o2 == AudioTrackType.ORIGINAL); + } + return 0; + }).thenComparing(AudioStream::getAudioLocale, + Comparator.nullsFirst(Comparator.comparing( + locale -> locale.getISO3Language().equals(langCode)))) + .thenComparing(AudioStream::getAudioTrackType, + Comparator.nullsFirst(Comparator.comparingInt(trackTypeRanking::indexOf))) + .thenComparing(AudioStream::getAudioLocale, + Comparator.nullsFirst(Comparator.comparing( + locale -> locale.getISO3Language().equals( + Locale.ENGLISH.getISO3Language())))); + } + + /** + * Get a {@link Comparator} to compare {@link AudioStream}s by their languages and track types + * for alphabetical sorting. + * + * @param context app context for localization + * @return Comparator + */ + private static Comparator getAudioTrackNameComparator( + @NonNull final Context context) { + final Locale appLoc = Localization.getAppLocale(context); + + return Comparator.comparing(AudioStream::getAudioLocale, Comparator.nullsLast( + Comparator.comparing(locale -> locale.getDisplayName(appLoc)))) + .thenComparing(AudioStream::getAudioTrackType, Comparator.nullsLast( + Comparator.naturalOrder())); + } } diff --git a/app/src/main/java/org/schabi/newpipe/util/Localization.java b/app/src/main/java/org/schabi/newpipe/util/Localization.java index b222f6abf..5d73d21f0 100644 --- a/app/src/main/java/org/schabi/newpipe/util/Localization.java +++ b/app/src/main/java/org/schabi/newpipe/util/Localization.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.util; +import static org.schabi.newpipe.MainActivity.DEBUG; + import android.annotation.SuppressLint; import android.content.Context; import android.content.SharedPreferences; @@ -11,8 +13,10 @@ import android.text.TextUtils; import android.util.DisplayMetrics; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.PluralsRes; import androidx.annotation.StringRes; +import androidx.core.math.MathUtils; import androidx.preference.PreferenceManager; import org.ocpsoft.prettytime.PrettyTime; @@ -20,6 +24,9 @@ import org.ocpsoft.prettytime.units.Decade; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.localization.ContentCountry; +import org.schabi.newpipe.extractor.localization.DateWrapper; +import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.AudioTrackType; import java.math.BigDecimal; import java.math.RoundingMode; @@ -31,6 +38,7 @@ import java.time.format.FormatStyle; import java.util.Arrays; import java.util.List; import java.util.Locale; +import java.util.stream.Collectors; /* @@ -54,7 +62,6 @@ import java.util.Locale; */ public final class Localization { - public static final String DOT_SEPARATOR = " • "; private static PrettyTime prettyTime; @@ -62,43 +69,23 @@ public final class Localization { @NonNull public static String concatenateStrings(final String... strings) { - return concatenateStrings(Arrays.asList(strings)); + return concatenateStrings(DOT_SEPARATOR, Arrays.asList(strings)); } @NonNull - public static String concatenateStrings(final List strings) { - if (strings.isEmpty()) { - return ""; - } - - final StringBuilder stringBuilder = new StringBuilder(); - stringBuilder.append(strings.get(0)); - - for (int i = 1; i < strings.size(); i++) { - final String string = strings.get(i); - if (!TextUtils.isEmpty(string)) { - stringBuilder.append(DOT_SEPARATOR).append(strings.get(i)); - } - } - - return stringBuilder.toString(); + public static String concatenateStrings(final String delimiter, final List strings) { + return strings.stream() + .filter(string -> !TextUtils.isEmpty(string)) + .collect(Collectors.joining(delimiter)); } public static org.schabi.newpipe.extractor.localization.Localization getPreferredLocalization( final Context context) { - final String contentLanguage = PreferenceManager - .getDefaultSharedPreferences(context) - .getString(context.getString(R.string.content_language_key), - context.getString(R.string.default_localization_key)); - if (contentLanguage.equals(context.getString(R.string.default_localization_key))) { - return org.schabi.newpipe.extractor.localization.Localization - .fromLocale(Locale.getDefault()); - } return org.schabi.newpipe.extractor.localization.Localization - .fromLocalizationCode(contentLanguage); + .fromLocale(getPreferredLocale(context)); } - public static ContentCountry getPreferredContentCountry(final Context context) { + public static ContentCountry getPreferredContentCountry(@NonNull final Context context) { final String contentCountry = PreferenceManager.getDefaultSharedPreferences(context) .getString(context.getString(R.string.content_country_key), context.getString(R.string.default_localization_key)); @@ -108,52 +95,43 @@ public final class Localization { return new ContentCountry(contentCountry); } - public static Locale getPreferredLocale(final Context context) { - final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); - - final String languageCode = sp.getString(context.getString(R.string.content_language_key), - context.getString(R.string.default_localization_key)); - - try { - if (languageCode.length() == 2) { - return new Locale(languageCode); - } else if (languageCode.contains("_")) { - final String country = languageCode.substring(languageCode.indexOf("_")); - return new Locale(languageCode.substring(0, 2), country); - } - } catch (final Exception ignored) { - } - - return Locale.getDefault(); + public static Locale getPreferredLocale(@NonNull final Context context) { + return getLocaleFromPrefs(context, R.string.content_language_key); } - public static String localizeNumber(final Context context, final long number) { + public static Locale getAppLocale(@NonNull final Context context) { + return getLocaleFromPrefs(context, R.string.app_language_key); + } + + public static String localizeNumber(@NonNull final Context context, final long number) { return localizeNumber(context, (double) number); } - public static String localizeNumber(final Context context, final double number) { + public static String localizeNumber(@NonNull final Context context, final double number) { final NumberFormat nf = NumberFormat.getInstance(getAppLocale(context)); return nf.format(number); } - public static String formatDate(final OffsetDateTime offsetDateTime, final Context context) { + public static String formatDate(@NonNull final Context context, + @NonNull final OffsetDateTime offsetDateTime) { return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM) .withLocale(getAppLocale(context)).format(offsetDateTime .atZoneSameInstant(ZoneId.systemDefault())); } @SuppressLint("StringFormatInvalid") - public static String localizeUploadDate(final Context context, - final OffsetDateTime offsetDateTime) { - return context.getString(R.string.upload_date_text, formatDate(offsetDateTime, context)); + public static String localizeUploadDate(@NonNull final Context context, + @NonNull final OffsetDateTime offsetDateTime) { + return context.getString(R.string.upload_date_text, formatDate(context, offsetDateTime)); } - public static String localizeViewCount(final Context context, final long viewCount) { + public static String localizeViewCount(@NonNull final Context context, final long viewCount) { return getQuantity(context, R.plurals.views, R.string.no_views, viewCount, localizeNumber(context, viewCount)); } - public static String localizeStreamCount(final Context context, final long streamCount) { + public static String localizeStreamCount(@NonNull final Context context, + final long streamCount) { switch ((int) streamCount) { case (int) ListExtractor.ITEM_COUNT_UNKNOWN: return ""; @@ -167,7 +145,8 @@ public final class Localization { } } - public static String localizeStreamCountMini(final Context context, final long streamCount) { + public static String localizeStreamCountMini(@NonNull final Context context, + final long streamCount) { switch ((int) streamCount) { case (int) ListExtractor.ITEM_COUNT_UNKNOWN: return ""; @@ -180,12 +159,13 @@ public final class Localization { } } - public static String localizeWatchingCount(final Context context, final long watchingCount) { + public static String localizeWatchingCount(@NonNull final Context context, + final long watchingCount) { return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount, localizeNumber(context, watchingCount)); } - public static String shortCount(final Context context, final long count) { + public static String shortCount(@NonNull final Context context, final long count) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { return CompactDecimalFormat.getInstance(getAppLocale(context), CompactDecimalFormat.CompactStyle.SHORT).format(count); @@ -193,66 +173,90 @@ public final class Localization { final double value = (double) count; if (count >= 1000000000) { - return localizeNumber(context, round(value / 1000000000, 1)) + return localizeNumber(context, round(value / 1000000000)) + context.getString(R.string.short_billion); } else if (count >= 1000000) { - return localizeNumber(context, round(value / 1000000, 1)) + return localizeNumber(context, round(value / 1000000)) + context.getString(R.string.short_million); } else if (count >= 1000) { - return localizeNumber(context, round(value / 1000, 1)) + return localizeNumber(context, round(value / 1000)) + context.getString(R.string.short_thousand); } else { return localizeNumber(context, value); } } - public static String listeningCount(final Context context, final long listeningCount) { + public static String listeningCount(@NonNull final Context context, final long listeningCount) { return getQuantity(context, R.plurals.listening, R.string.no_one_listening, listeningCount, shortCount(context, listeningCount)); } - public static String shortWatchingCount(final Context context, final long watchingCount) { + public static String shortWatchingCount(@NonNull final Context context, + final long watchingCount) { return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount, shortCount(context, watchingCount)); } - public static String shortViewCount(final Context context, final long viewCount) { + public static String shortViewCount(@NonNull final Context context, final long viewCount) { return getQuantity(context, R.plurals.views, R.string.no_views, viewCount, shortCount(context, viewCount)); } - public static String shortSubscriberCount(final Context context, final long subscriberCount) { + public static String shortSubscriberCount(@NonNull final Context context, + final long subscriberCount) { return getQuantity(context, R.plurals.subscribers, R.string.no_subscribers, subscriberCount, shortCount(context, subscriberCount)); } - public static String downloadCount(final Context context, final int downloadCount) { + public static String downloadCount(@NonNull final Context context, final int downloadCount) { return getQuantity(context, R.plurals.download_finished_notification, 0, downloadCount, shortCount(context, downloadCount)); } - public static String deletedDownloadCount(final Context context, final int deletedCount) { + public static String deletedDownloadCount(@NonNull final Context context, + final int deletedCount) { return getQuantity(context, R.plurals.deleted_downloads_toast, 0, deletedCount, shortCount(context, deletedCount)); } - private static String getQuantity(final Context context, @PluralsRes final int pluralId, - @StringRes final int zeroCaseStringId, final long count, - final String formattedCount) { - if (count == 0) { - return context.getString(zeroCaseStringId); - } - - // As we use the already formatted count - // is not the responsibility of this method handle long numbers - // (it probably will fall in the "other" category, - // or some language have some specific rule... then we have to change it) - final int safeCount = count > Integer.MAX_VALUE ? Integer.MAX_VALUE - : count < Integer.MIN_VALUE ? Integer.MIN_VALUE : (int) count; - return context.getResources().getQuantityString(pluralId, safeCount, formattedCount); + public static String replyCount(@NonNull final Context context, final int replyCount) { + return getQuantity(context, R.plurals.replies, 0, replyCount, + String.valueOf(replyCount)); } + /** + * @param context the Android context + * @param likeCount the like count, possibly negative if unknown + * @return if {@code likeCount} is smaller than {@code 0}, the string {@code "-"}, otherwise + * the result of calling {@link #shortCount(Context, long)} on the like count + */ + public static String likeCount(@NonNull final Context context, final int likeCount) { + if (likeCount < 0) { + return "-"; + } else { + return shortCount(context, likeCount); + } + } + + /** + * Get a readable text for a duration in the format {@code days:hours:minutes:seconds}. + * Prepended zeros are removed. + * @param duration the duration in seconds + * @return a formatted duration String or {@code 0:00} if the duration is zero. + */ public static String getDurationString(final long duration) { + return getDurationString(duration, true); + } + + /** + * Get a readable text for a duration in the format {@code days:hours:minutes:seconds+}. + * Prepended zeros are removed. If the given duration is incomplete, a plus is appended to the + * duration string. + * @param duration the duration in seconds + * @param isDurationComplete whether the given duration is complete or whether info is missing + * @return a formatted duration String or {@code 0:00} if the duration is zero. + */ + public static String getDurationString(final long duration, final boolean isDurationComplete) { final String output; final long days = duration / (24 * 60 * 60L); /* greater than a day */ @@ -270,7 +274,8 @@ public final class Localization { } else { output = String.format(Locale.US, "%d:%02d", minutes, seconds); } - return output; + final String durationPostfix = isDurationComplete ? "" : "+"; + return output + durationPostfix; } /** @@ -284,7 +289,8 @@ public final class Localization { * @return duration in a human readable string. */ @NonNull - public static String localizeDuration(final Context context, final int durationInSecs) { + public static String localizeDuration(@NonNull final Context context, + final int durationInSecs) { if (durationInSecs < 0) { throw new IllegalArgumentException("duration can not be negative"); } @@ -307,70 +313,135 @@ public final class Localization { } } + /** + * Get the localized name of an audio track. + * + *

Examples of results returned by this method:

+ *
    + *
  • English (original)
  • + *
  • English (descriptive)
  • + *
  • Spanish (dubbed)
  • + *
+ * + * @param context the context used to get the app language + * @param track an {@link AudioStream} of the track + * @return the localized name of the audio track + */ + public static String audioTrackName(@NonNull final Context context, final AudioStream track) { + final String name; + if (track.getAudioLocale() != null) { + name = track.getAudioLocale().getDisplayLanguage(getAppLocale(context)); + } else if (track.getAudioTrackName() != null) { + name = track.getAudioTrackName(); + } else { + name = context.getString(R.string.unknown_audio_track); + } + + if (track.getAudioTrackType() != null) { + final String trackType = audioTrackType(context, track.getAudioTrackType()); + if (trackType != null) { + return context.getString(R.string.audio_track_name, name, trackType); + } + } + return name; + } + + @Nullable + private static String audioTrackType(@NonNull final Context context, + final AudioTrackType trackType) { + switch (trackType) { + case ORIGINAL: + return context.getString(R.string.audio_track_type_original); + case DUBBED: + return context.getString(R.string.audio_track_type_dubbed); + case DESCRIPTIVE: + return context.getString(R.string.audio_track_type_descriptive); + } + return null; + } + /*////////////////////////////////////////////////////////////////////////// // Pretty Time //////////////////////////////////////////////////////////////////////////*/ - public static void initPrettyTime(final PrettyTime time) { + public static void initPrettyTime(@NonNull final PrettyTime time) { prettyTime = time; // Do not use decades as YouTube doesn't either. prettyTime.removeUnit(Decade.class); } - public static PrettyTime resolvePrettyTime(final Context context) { + public static PrettyTime resolvePrettyTime(@NonNull final Context context) { return new PrettyTime(getAppLocale(context)); } - public static String relativeTime(final OffsetDateTime offsetDateTime) { + public static String relativeTime(@NonNull final OffsetDateTime offsetDateTime) { return prettyTime.formatUnrounded(offsetDateTime); } - private static void changeAppLanguage(final Locale loc, final Resources res) { - final DisplayMetrics dm = res.getDisplayMetrics(); - final Configuration conf = res.getConfiguration(); - conf.setLocale(loc); - res.updateConfiguration(conf, dm); - } - - public static Locale getAppLocale(final Context context) { - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - String lang = prefs.getString(context.getString(R.string.app_language_key), "en"); - final Locale loc; - if (lang.equals(context.getString(R.string.default_localization_key))) { - loc = Locale.getDefault(); - } else if (lang.matches(".*-.*")) { - //to differentiate different versions of the language - //for example, pt (portuguese in Portugal) and pt-br (portuguese in Brazil) - final String[] localisation = lang.split("-"); - lang = localisation[0]; - final String country = localisation[1]; - loc = new Locale(lang, country); + /** + * @param context the Android context; if {@code null} then even if in debug mode and the + * setting is enabled, {@code textual} will not be shown next to {@code parsed} + * @param parsed the textual date or time ago parsed by NewPipeExtractor, or {@code null} if + * the extractor could not parse it + * @param textual the original textual date or time ago string as provided by services + * @return {@link #relativeTime(OffsetDateTime)} is used if {@code parsed != null}, otherwise + * {@code textual} is returned. If in debug mode, {@code context != null}, + * {@code parsed != null} and the relevant setting is enabled, {@code textual} will + * be appended to the returned string for debugging purposes. + */ + public static String relativeTimeOrTextual(@Nullable final Context context, + @Nullable final DateWrapper parsed, + final String textual) { + if (parsed == null) { + return textual; + } else if (DEBUG && context != null && PreferenceManager + .getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.show_original_time_ago_key), false)) { + return relativeTime(parsed.offsetDateTime()) + " (" + textual + ")"; } else { - loc = new Locale(lang); + return relativeTime(parsed.offsetDateTime()); } - return loc; } public static void assureCorrectAppLanguage(final Context c) { - changeAppLanguage(getAppLocale(c), c.getResources()); + final Resources res = c.getResources(); + final DisplayMetrics dm = res.getDisplayMetrics(); + final Configuration conf = res.getConfiguration(); + conf.setLocale(getAppLocale(c)); + res.updateConfiguration(conf, dm); } - private static double round(final double value, final int places) { - return new BigDecimal(value).setScale(places, RoundingMode.HALF_UP).doubleValue(); - } + private static Locale getLocaleFromPrefs(@NonNull final Context context, + @StringRes final int prefKey) { + final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context); + final String defaultKey = context.getString(R.string.default_localization_key); + final String languageCode = sp.getString(context.getString(prefKey), defaultKey); - /** - * Workaround to match normalized captions like english to English or deutsch to Deutsch. - * @param list the list to search into - * @param toFind the string to look for - * @return whether the string was found or not - */ - public static boolean containsCaseInsensitive(final List list, final String toFind) { - for (final String i : list) { - if (i.equalsIgnoreCase(toFind)) { - return true; - } + if (languageCode.equals(defaultKey)) { + return Locale.getDefault(); + } else { + return Locale.forLanguageTag(languageCode); } - return false; + } + + private static double round(final double value) { + return new BigDecimal(value).setScale(1, RoundingMode.HALF_UP).doubleValue(); + } + + private static String getQuantity(@NonNull final Context context, + @PluralsRes final int pluralId, + @StringRes final int zeroCaseStringId, + final long count, + final String formattedCount) { + if (count == 0) { + return context.getString(zeroCaseStringId); + } + + // As we use the already formatted count + // is not the responsibility of this method handle long numbers + // (it probably will fall in the "other" category, + // or some language have some specific rule... then we have to change it) + final int safeCount = (int) MathUtils.clamp(count, Integer.MIN_VALUE, Integer.MAX_VALUE); + return context.getResources().getQuantityString(pluralId, safeCount, formattedCount); } } diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java index e55114a2d..5dee32371 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.util; -import static org.schabi.newpipe.util.external_communication.ShareUtils.installApp; +import static android.text.TextUtils.isEmpty; +import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams; import android.annotation.SuppressLint; import android.app.Activity; @@ -17,6 +18,7 @@ import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.core.content.ContextCompat; import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentTransaction; @@ -29,10 +31,13 @@ import org.schabi.newpipe.RouterActivity; import org.schabi.newpipe.about.AboutActivity; import org.schabi.newpipe.database.feed.model.FeedGroupEntity; import org.schabi.newpipe.download.DownloadActivity; +import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.DeliveryMethod; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfoItem; @@ -40,6 +45,7 @@ import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.fragments.MainFragment; import org.schabi.newpipe.fragments.detail.VideoDetailFragment; import org.schabi.newpipe.fragments.list.channel.ChannelFragment; +import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment; import org.schabi.newpipe.fragments.list.kiosk.KioskFragment; import org.schabi.newpipe.fragments.list.playlist.PlaylistFragment; import org.schabi.newpipe.fragments.list.search.SearchFragment; @@ -49,10 +55,10 @@ import org.schabi.newpipe.local.history.StatisticsPlaylistFragment; import org.schabi.newpipe.local.playlist.LocalPlaylistFragment; import org.schabi.newpipe.local.subscription.SubscriptionFragment; import org.schabi.newpipe.local.subscription.SubscriptionsImportFragment; -import org.schabi.newpipe.player.MainPlayer; -import org.schabi.newpipe.player.MainPlayer.PlayerType; import org.schabi.newpipe.player.PlayQueueActivity; import org.schabi.newpipe.player.Player; +import org.schabi.newpipe.player.PlayerService; +import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.playqueue.PlayQueue; @@ -60,7 +66,7 @@ import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.settings.SettingsActivity; import org.schabi.newpipe.util.external_communication.ShareUtils; -import java.util.ArrayList; +import java.util.List; public final class NavigationHelper { public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag"; @@ -88,7 +94,7 @@ public final class NavigationHelper { intent.putExtra(Player.PLAY_QUEUE_KEY, cacheKey); } } - intent.putExtra(Player.PLAYER_TYPE, MainPlayer.PlayerType.VIDEO.ordinal()); + intent.putExtra(Player.PLAYER_TYPE, PlayerType.MAIN.valueForIntent()); intent.putExtra(Player.RESUME_PLAYBACK, resumePlayback); return intent; @@ -153,15 +159,14 @@ public final class NavigationHelper { public static void playOnPopupPlayer(final Context context, final PlayQueue queue, final boolean resumePlayback) { - if (!PermissionHelper.isPopupEnabled(context)) { - PermissionHelper.showPopupEnablementToast(context); + if (!PermissionHelper.isPopupEnabledElseAsk(context)) { return; } 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()); + final Intent intent = getPlayerIntent(context, PlayerService.class, queue, resumePlayback); + intent.putExtra(Player.PLAYER_TYPE, PlayerType.POPUP.valueForIntent()); ContextCompat.startForegroundService(context, intent); } @@ -171,8 +176,8 @@ public final class NavigationHelper { 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()); + final Intent intent = getPlayerIntent(context, PlayerService.class, queue, resumePlayback); + intent.putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO.valueForIntent()); ContextCompat.startForegroundService(context, intent); } @@ -180,18 +185,22 @@ public final class NavigationHelper { public static void enqueueOnPlayer(final Context context, final PlayQueue queue, final PlayerType playerType) { - Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show(); - final Intent intent = getPlayerEnqueueIntent(context, MainPlayer.class, queue); + if (playerType == PlayerType.POPUP && !PermissionHelper.isPopupEnabledElseAsk(context)) { + return; + } - intent.putExtra(Player.PLAYER_TYPE, playerType.ordinal()); + Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show(); + final Intent intent = getPlayerEnqueueIntent(context, PlayerService.class, queue); + + intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent()); ContextCompat.startForegroundService(context, intent); } public static void enqueueOnPlayer(final Context context, final PlayQueue queue) { PlayerType playerType = PlayerHolder.getInstance().getType(); - if (!PlayerHolder.getInstance().isPlayerOpen()) { + if (playerType == null) { Log.e(TAG, "Enqueueing but no player is open; defaulting to background player"); - playerType = MainPlayer.PlayerType.AUDIO; + playerType = PlayerType.AUDIO; } enqueueOnPlayer(context, queue, playerType); @@ -200,14 +209,14 @@ public final class NavigationHelper { /* ENQUEUE NEXT */ public static void enqueueNextOnPlayer(final Context context, final PlayQueue queue) { PlayerType playerType = PlayerHolder.getInstance().getType(); - if (!PlayerHolder.getInstance().isPlayerOpen()) { + if (playerType == null) { Log.e(TAG, "Enqueueing next but no player is open; defaulting to background player"); - playerType = MainPlayer.PlayerType.AUDIO; + playerType = PlayerType.AUDIO; } Toast.makeText(context, R.string.enqueued_next, Toast.LENGTH_SHORT).show(); - final Intent intent = getPlayerEnqueueNextIntent(context, MainPlayer.class, queue); + final Intent intent = getPlayerEnqueueNextIntent(context, PlayerService.class, queue); - intent.putExtra(Player.PLAYER_TYPE, playerType.ordinal()); + intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent()); ContextCompat.startForegroundService(context, intent); } @@ -217,30 +226,47 @@ public final class NavigationHelper { public static void playOnExternalAudioPlayer(@NonNull final Context context, @NonNull final StreamInfo info) { - final int index = ListHelper.getDefaultAudioFormat(context, info.getAudioStreams()); - - if (index == -1) { + final List audioStreams = info.getAudioStreams(); + if (audioStreams == null || audioStreams.isEmpty()) { Toast.makeText(context, R.string.audio_streams_empty, Toast.LENGTH_SHORT).show(); return; } - final AudioStream audioStream = info.getAudioStreams().get(index); + final List audioStreamsForExternalPlayers = + getUrlAndNonTorrentStreams(audioStreams); + if (audioStreamsForExternalPlayers.isEmpty()) { + Toast.makeText(context, R.string.no_audio_streams_available_for_external_players, + Toast.LENGTH_SHORT).show(); + return; + } + + final int index = ListHelper.getDefaultAudioFormat(context, audioStreamsForExternalPlayers); + final AudioStream audioStream = audioStreamsForExternalPlayers.get(index); + playOnExternalPlayer(context, info.getName(), info.getUploaderName(), audioStream); } - public static void playOnExternalVideoPlayer(@NonNull final Context context, + public static void playOnExternalVideoPlayer(final Context context, @NonNull final StreamInfo info) { - final ArrayList videoStreamsList = new ArrayList<>( - ListHelper.getSortedStreamVideosList(context, info.getVideoStreams(), null, false, - false)); - final int index = ListHelper.getDefaultResolutionIndex(context, videoStreamsList); - - if (index == -1) { + final List videoStreams = info.getVideoStreams(); + if (videoStreams == null || videoStreams.isEmpty()) { Toast.makeText(context, R.string.video_streams_empty, Toast.LENGTH_SHORT).show(); return; } - final VideoStream videoStream = videoStreamsList.get(index); + final List videoStreamsForExternalPlayers = + ListHelper.getSortedStreamVideosList(context, + getUrlAndNonTorrentStreams(videoStreams), null, false, false); + if (videoStreamsForExternalPlayers.isEmpty()) { + Toast.makeText(context, R.string.no_video_streams_available_for_external_players, + Toast.LENGTH_SHORT).show(); + return; + } + + final int index = ListHelper.getDefaultResolutionIndex(context, + videoStreamsForExternalPlayers); + + final VideoStream videoStream = videoStreamsForExternalPlayers.get(index); playOnExternalPlayer(context, info.getName(), info.getUploaderName(), videoStream); } @@ -248,9 +274,48 @@ public final class NavigationHelper { @Nullable final String name, @Nullable final String artist, @NonNull final Stream stream) { + final DeliveryMethod deliveryMethod = stream.getDeliveryMethod(); + final String mimeType; + + if (!stream.isUrl() || deliveryMethod == DeliveryMethod.TORRENT) { + Toast.makeText(context, R.string.selected_stream_external_player_not_supported, + Toast.LENGTH_SHORT).show(); + return; + } + + switch (deliveryMethod) { + case PROGRESSIVE_HTTP: + if (stream.getFormat() == null) { + if (stream instanceof AudioStream) { + mimeType = "audio/*"; + } else if (stream instanceof VideoStream) { + mimeType = "video/*"; + } else { + // This should never be reached, because subtitles are not opened in + // external players + return; + } + } else { + mimeType = stream.getFormat().getMimeType(); + } + break; + case HLS: + mimeType = "application/x-mpegURL"; + break; + case DASH: + mimeType = "application/dash+xml"; + break; + case SS: + mimeType = "application/vnd.ms-sstr+xml"; + break; + default: + // Torrent streams are not exposed to external players + mimeType = ""; + } + final Intent intent = new Intent(); intent.setAction(Intent.ACTION_VIEW); - intent.setDataAndType(Uri.parse(stream.getUrl()), stream.getFormat().getMimeType()); + intent.setDataAndType(Uri.parse(stream.getContent()), mimeType); intent.putExtra(Intent.EXTRA_TITLE, name); intent.putExtra("title", name); intent.putExtra("artist", artist); @@ -261,17 +326,15 @@ public final class NavigationHelper { public static void resolveActivityOrAskToInstall(@NonNull final Context context, @NonNull final Intent intent) { - if (intent.resolveActivity(context.getPackageManager()) != null) { - ShareUtils.openIntentInApp(context, intent, false); - } else { + if (!ShareUtils.tryOpenIntentInApp(context, intent)) { if (context instanceof Activity) { new AlertDialog.Builder(context) .setMessage(R.string.no_player_found) - .setPositiveButton(R.string.install, - (dialog, which) -> ShareUtils.openUrlInBrowser(context, - context.getString(R.string.fdroid_vlc_url), false)) - .setNegativeButton(R.string.cancel, (dialog, which) - -> Log.i("NavigationHelper", "You unlocked a secret unicorn.")) + .setPositiveButton(R.string.install, (dialog, which) -> + ShareUtils.installApp(context, + context.getString(R.string.vlc_package))) + .setNegativeButton(R.string.cancel, (dialog, which) -> + Log.i("NavigationHelper", "You unlocked a secret unicorn.")) .show(); } else { Toast.makeText(context, R.string.no_player_found_toast, Toast.LENGTH_LONG).show(); @@ -355,14 +418,14 @@ public final class NavigationHelper { final boolean switchingPlayers) { final boolean autoPlay; - @Nullable final MainPlayer.PlayerType playerType = PlayerHolder.getInstance().getType(); - if (!PlayerHolder.getInstance().isPlayerOpen()) { + @Nullable final PlayerType playerType = PlayerHolder.getInstance().getType(); + if (playerType == null) { // no player open autoPlay = PlayerHelper.isAutoplayAllowedByUser(context); } else if (switchingPlayers) { // switching player to main player autoPlay = PlayerHolder.getInstance().isPlaying(); // keep play/pause state - } else if (playerType == MainPlayer.PlayerType.VIDEO) { + } else if (playerType == PlayerType.MAIN) { // opening new stream while already playing in main player autoPlay = PlayerHelper.isAutoplayAllowedByUser(context); } else { @@ -377,7 +440,7 @@ public final class NavigationHelper { // Situation when user switches from players to main player. All needed data is // here, we can start watching (assuming newQueue equals playQueue). // Starting directly in fullscreen if the previous player type was popup. - detailFragment.openVideoPlayer(playerType == MainPlayer.PlayerType.POPUP + detailFragment.openVideoPlayer(playerType == PlayerType.POPUP || PlayerHelper.isStartMainPlayerFullscreenEnabled(context)); } else { detailFragment.selectAndLoadVideo(serviceId, url, title, playQueue); @@ -418,6 +481,35 @@ public final class NavigationHelper { item.getServiceId(), uploaderUrl, item.getUploaderName()); } + /** + * Opens the comment author channel fragment, if the {@link CommentsInfoItem#getUploaderUrl()} + * of {@code comment} is non-null. Shows a UI-error snackbar if something goes wrong. + * + * @param activity the activity with the fragment manager and in which to show the snackbar + * @param comment the comment whose uploader/author will be opened + */ + public static void openCommentAuthorIfPresent(@NonNull final FragmentActivity activity, + @NonNull final CommentsInfoItem comment) { + if (isEmpty(comment.getUploaderUrl())) { + return; + } + try { + openChannelFragment(activity.getSupportFragmentManager(), comment.getServiceId(), + comment.getUploaderUrl(), comment.getUploaderName()); + } catch (final Exception e) { + ErrorUtil.showUiErrorSnackbar(activity, "Opening channel fragment", e); + } + } + + public static void openCommentRepliesFragment(@NonNull final FragmentActivity activity, + @NonNull final CommentsInfoItem comment) { + defaultTransaction(activity.getSupportFragmentManager()) + .replace(R.id.fragment_holder, new CommentRepliesFragment(comment), + CommentRepliesFragment.TAG) + .addToBackStack(CommentRepliesFragment.TAG) + .commit(); + } + public static void openPlaylistFragment(final FragmentManager fragmentManager, final int serviceId, final String url, @NonNull final String name) { @@ -505,11 +597,8 @@ public final class NavigationHelper { @Nullable final PlayQueue playQueue, final boolean switchingPlayers) { - final Intent intent = getOpenIntent(context, url, serviceId, - StreamingService.LinkType.STREAM); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - intent.putExtra(Constants.KEY_TITLE, title); - intent.putExtra(VideoDetailFragment.KEY_SWITCHING_PLAYERS, switchingPlayers); + final Intent intent = getStreamIntent(context, serviceId, url, title) + .putExtra(VideoDetailFragment.KEY_SWITCHING_PLAYERS, switchingPlayers); if (playQueue != null) { final String cacheKey = SerializedCache.getInstance().put(playQueue, PlayQueue.class); @@ -580,6 +669,11 @@ public final class NavigationHelper { return intent; } + public static void openPlayQueue(final Context context) { + final Intent intent = new Intent(context, PlayQueueActivity.class); + context.startActivity(intent); + } + /*////////////////////////////////////////////////////////////////////////// // Link handling //////////////////////////////////////////////////////////////////////////*/ @@ -617,32 +711,13 @@ public final class NavigationHelper { return getOpenIntent(context, url, serviceId, StreamingService.LinkType.CHANNEL); } - /** - * Start an activity to install Kore. - * - * @param context the context - */ - public static void installKore(final Context context) { - installApp(context, context.getString(R.string.kore_package)); - } - - /** - * Start Kore app to show a video on Kodi. - *

- * For a list of supported urls see the - * - * Kore source code - * . - * - * @param context the context to use - * @param videoURL the url to the video - */ - public static void playWithKore(final Context context, final Uri videoURL) { - final Intent intent = new Intent(Intent.ACTION_VIEW); - intent.setPackage(context.getString(R.string.kore_package)); - intent.setData(videoURL); - intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - context.startActivity(intent); + public static Intent getStreamIntent(final Context context, + final int serviceId, + final String url, + @Nullable final String title) { + return getOpenIntent(context, url, serviceId, StreamingService.LinkType.STREAM) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(Constants.KEY_TITLE, title); } /** diff --git a/app/src/main/java/org/schabi/newpipe/util/OnClickGesture.java b/app/src/main/java/org/schabi/newpipe/util/OnClickGesture.java index 5f44cab8b..ae8d86af1 100644 --- a/app/src/main/java/org/schabi/newpipe/util/OnClickGesture.java +++ b/app/src/main/java/org/schabi/newpipe/util/OnClickGesture.java @@ -2,15 +2,14 @@ package org.schabi.newpipe.util; import androidx.recyclerview.widget.RecyclerView; -public abstract class OnClickGesture { +public interface OnClickGesture { + void selected(T selectedItem); - public abstract void selected(T selectedItem); - - public void held(final T selectedItem) { + default void held(final T selectedItem) { // Optional gesture } - public void drag(final T selectedItem, final RecyclerView.ViewHolder viewHolder) { + default void drag(final T selectedItem, final RecyclerView.ViewHolder viewHolder) { // Optional gesture } } diff --git a/app/src/main/java/org/schabi/newpipe/util/PeertubeHelper.java b/app/src/main/java/org/schabi/newpipe/util/PeertubeHelper.java index dcc39eccf..34f99d262 100644 --- a/app/src/main/java/org/schabi/newpipe/util/PeertubeHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/PeertubeHelper.java @@ -17,7 +17,6 @@ import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; import java.util.ArrayList; -import java.util.Collections; import java.util.List; public final class PeertubeHelper { @@ -29,7 +28,7 @@ public final class PeertubeHelper { final String savedInstanceListKey = context.getString(R.string.peertube_instance_list_key); final String savedJson = sharedPreferences.getString(savedInstanceListKey, null); if (null == savedJson) { - return Collections.singletonList(getCurrentInstance()); + return List.of(getCurrentInstance()); } try { @@ -45,17 +44,16 @@ public final class PeertubeHelper { } return result; } catch (final JsonParserException e) { - return Collections.singletonList(getCurrentInstance()); + return List.of(getCurrentInstance()); } - } public static PeertubeInstance selectInstance(final PeertubeInstance instance, final Context context) { final SharedPreferences sharedPreferences = PreferenceManager .getDefaultSharedPreferences(context); - final String selectedInstanceKey - = context.getString(R.string.peertube_selected_instance_key); + final String selectedInstanceKey = + context.getString(R.string.peertube_selected_instance_key); final JsonStringWriter jsonWriter = JsonWriter.string().object(); jsonWriter.value("name", instance.getName()); jsonWriter.value("url", instance.getUrl()); diff --git a/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java b/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java index 160eb59cd..55193599e 100644 --- a/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java @@ -9,8 +9,6 @@ import android.content.pm.PackageManager; import android.net.Uri; import android.os.Build; import android.provider.Settings; -import android.view.Gravity; -import android.widget.TextView; import android.widget.Toast; import androidx.annotation.RequiresApi; @@ -21,6 +19,7 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.settings.NewPipeSettings; public final class PermissionHelper { + public static final int POST_NOTIFICATIONS_REQUEST_CODE = 779; public static final int DOWNLOAD_DIALOG_REQUEST_CODE = 778; public static final int DOWNLOADS_REQUEST_CODE = 777; @@ -37,7 +36,6 @@ public final class PermissionHelper { return checkWriteStoragePermissions(activity, requestCode); } - @RequiresApi(api = Build.VERSION_CODES.JELLY_BEAN) public static boolean checkReadStoragePermissions(final Activity activity, final int requestCode) { if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_EXTERNAL_STORAGE) @@ -72,8 +70,7 @@ public final class PermissionHelper { // No explanation needed, we can request the permission. ActivityCompat.requestPermissions(activity, - new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, - requestCode); + new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, requestCode); // PERMISSION_WRITE_STORAGE is an // app-defined int constant. The callback method gets the @@ -84,6 +81,18 @@ public final class PermissionHelper { return true; } + public static boolean checkPostNotificationsPermission(final Activity activity, + final int requestCode) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU + && ContextCompat.checkSelfPermission(activity, + Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions(activity, + new String[] {Manifest.permission.POST_NOTIFICATIONS}, requestCode); + return false; + } + return true; + } /** * In order to be able to draw over other apps, @@ -117,18 +126,21 @@ public final class PermissionHelper { } } - public static boolean isPopupEnabled(final Context context) { - return Build.VERSION.SDK_INT < Build.VERSION_CODES.M - || checkSystemAlertWindowPermission(context); - } - - public static void showPopupEnablementToast(final Context context) { - final Toast toast - = Toast.makeText(context, R.string.msg_popup_permission, Toast.LENGTH_LONG); - final TextView messageView = toast.getView().findViewById(android.R.id.message); - if (messageView != null) { - messageView.setGravity(Gravity.CENTER); + /** + * Determines whether the popup is enabled, and if it is not, starts the system activity to + * request the permission with {@link #checkSystemAlertWindowPermission(Context)} and shows a + * toast to the user explaining why the permission is needed. + * + * @param context the Android context + * @return whether the popup is enabled + */ + public static boolean isPopupEnabledElseAsk(final Context context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M + || checkSystemAlertWindowPermission(context)) { + return true; + } else { + Toast.makeText(context, R.string.msg_popup_permission, Toast.LENGTH_LONG).show(); + return false; } - toast.show(); } } diff --git a/app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.java b/app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.java new file mode 100644 index 000000000..9727c8083 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/PlayButtonHelper.java @@ -0,0 +1,90 @@ +package org.schabi.newpipe.util; + +import android.content.Context; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.preference.PreferenceManager; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.databinding.PlaylistControlBinding; +import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder; +import org.schabi.newpipe.player.PlayerType; + +/** + * Utility class for play buttons and their respective click listeners. + */ +public final class PlayButtonHelper { + + private PlayButtonHelper() { + // utility class + } + + /** + * Initialize {@link android.view.View.OnClickListener OnClickListener} + * and {@link android.view.View.OnLongClickListener OnLongClickListener} for playlist control + * buttons defined in {@link R.layout#playlist_control}. + * + * @param activity The activity to use for the {@link android.widget.Toast Toast}. + * @param playlistControlBinding The binding of the + * {@link R.layout#playlist_control playlist control layout}. + * @param fragment The fragment to get the play queue from. + */ + public static void initPlaylistControlClickListener( + @NonNull final AppCompatActivity activity, + @NonNull final PlaylistControlBinding playlistControlBinding, + @NonNull final PlaylistControlViewHolder fragment) { + // click listener + playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view -> { + NavigationHelper.playOnMainPlayer(activity, fragment.getPlayQueue()); + showHoldToAppendToastIfNeeded(activity); + }); + playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view -> { + NavigationHelper.playOnPopupPlayer(activity, fragment.getPlayQueue(), false); + showHoldToAppendToastIfNeeded(activity); + }); + playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view -> { + NavigationHelper.playOnBackgroundPlayer(activity, fragment.getPlayQueue(), false); + showHoldToAppendToastIfNeeded(activity); + }); + + // long click listener + playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> { + NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.POPUP); + return true; + }); + playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> { + NavigationHelper.enqueueOnPlayer(activity, fragment.getPlayQueue(), PlayerType.AUDIO); + return true; + }); + } + + /** + * Show the "hold to append" toast if the corresponding preference is enabled. + * + * @param context The context to show the toast. + */ + private static void showHoldToAppendToastIfNeeded(@NonNull final Context context) { + if (shouldShowHoldToAppendTip(context)) { + Toast.makeText(context, R.string.hold_to_append, Toast.LENGTH_SHORT).show(); + } + + } + + /** + * Check if the "hold to append" toast should be shown. + * + *

+ * The tip is shown if the corresponding preference is enabled. + * This is the default behaviour. + *

+ * + * @param context The context to get the preference. + * @return {@code true} if the tip should be shown, {@code false} otherwise. + */ + public static boolean shouldShowHoldToAppendTip(@NonNull final Context context) { + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean(context.getString(R.string.show_hold_to_append_key), true); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/RelatedItemInfo.java b/app/src/main/java/org/schabi/newpipe/util/RelatedItemInfo.java deleted file mode 100644 index f96bb0d54..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/RelatedItemInfo.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.schabi.newpipe.util; - -import org.schabi.newpipe.extractor.InfoItem; -import org.schabi.newpipe.extractor.ListInfo; -import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; -import org.schabi.newpipe.extractor.stream.StreamInfo; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -public class RelatedItemInfo extends ListInfo { - public RelatedItemInfo(final int serviceId, final ListLinkHandler listUrlIdHandler, - final String name) { - super(serviceId, listUrlIdHandler, name); - } - - public static RelatedItemInfo getInfo(final StreamInfo info) { - final ListLinkHandler handler = new ListLinkHandler( - info.getOriginalUrl(), info.getUrl(), info.getId(), Collections.emptyList(), null); - final RelatedItemInfo relatedItemInfo = new RelatedItemInfo( - info.getServiceId(), handler, info.getName()); - final List relatedItems = new ArrayList<>(info.getRelatedItems()); - relatedItemInfo.setRelatedItems(relatedItems); - return relatedItemInfo; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt b/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt index 21a9059e2..3ea19fa4f 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt +++ b/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt @@ -1,101 +1,39 @@ package org.schabi.newpipe.util import android.content.pm.PackageManager -import android.content.pm.Signature import androidx.core.content.pm.PackageInfoCompat import org.schabi.newpipe.App import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorUtil.Companion.createNotification import org.schabi.newpipe.error.UserAction -import java.io.ByteArrayInputStream -import java.io.InputStream -import java.security.MessageDigest -import java.security.NoSuchAlgorithmException -import java.security.cert.CertificateEncodingException -import java.security.cert.CertificateException -import java.security.cert.CertificateFactory -import java.security.cert.X509Certificate import java.time.Instant import java.time.ZonedDateTime import java.time.format.DateTimeFormatter object ReleaseVersionUtil { // Public key of the certificate that is used in NewPipe release versions - private const val RELEASE_CERT_PUBLIC_KEY_SHA1 = - "B0:2E:90:7C:1C:D6:FC:57:C3:35:F0:88:D0:8F:50:5F:94:E4:D2:15" + private const val RELEASE_CERT_PUBLIC_KEY_SHA256 = + "cb84069bd68116bafae5ee4ee5b08a567aa6d898404e7cb12f9e756df5cf5cab" - @JvmStatic - fun isReleaseApk(): Boolean { - return certificateSHA1Fingerprint == RELEASE_CERT_PUBLIC_KEY_SHA1 - } - - /** - * Method to get the APK's SHA1 key. See https://stackoverflow.com/questions/9293019/#22506133. - * - * @return String with the APK's SHA1 fingerprint in hexadecimal - */ - private val certificateSHA1Fingerprint: String - get() { - val app = App.getApp() - val signatures: List = try { - PackageInfoCompat.getSignatures(app.packageManager, app.packageName) - } catch (e: PackageManager.NameNotFoundException) { - showRequestError(app, e, "Could not find package info") - return "" - } - if (signatures.isEmpty()) { - return "" - } - val x509cert = try { - val cert = signatures[0].toByteArray() - val input: InputStream = ByteArrayInputStream(cert) - val cf = CertificateFactory.getInstance("X509") - cf.generateCertificate(input) as X509Certificate - } catch (e: CertificateException) { - showRequestError(app, e, "Certificate error") - return "" - } - - return try { - val md = MessageDigest.getInstance("SHA1") - val publicKey = md.digest(x509cert.encoded) - byte2HexFormatted(publicKey) - } catch (e: NoSuchAlgorithmException) { - showRequestError(app, e, "Could not retrieve SHA1 key") - "" - } catch (e: CertificateEncodingException) { - showRequestError(app, e, "Could not retrieve SHA1 key") - "" - } - } - - private fun byte2HexFormatted(arr: ByteArray): String { - val str = StringBuilder(arr.size * 2) - for (i in arr.indices) { - var h = Integer.toHexString(arr[i].toInt()) - val l = h.length - if (l == 1) { - h = "0$h" - } - if (l > 2) { - h = h.substring(l - 2, l) - } - str.append(h.uppercase()) - if (i < arr.size - 1) { - str.append(':') - } - } - return str.toString() - } - - private fun showRequestError(app: App, e: Exception, request: String) { - createNotification( - app, ErrorInfo(e, UserAction.CHECK_FOR_NEW_APP_VERSION, request) + @OptIn(ExperimentalStdlibApi::class) + val isReleaseApk by lazy { + @Suppress("NewApi") + val certificates = mapOf( + RELEASE_CERT_PUBLIC_KEY_SHA256.hexToByteArray() to PackageManager.CERT_INPUT_SHA256 ) + val app = App.getApp() + try { + PackageInfoCompat.hasSignatures(app.packageManager, app.packageName, certificates, false) + } catch (e: PackageManager.NameNotFoundException) { + createNotification( + app, ErrorInfo(e, UserAction.CHECK_FOR_NEW_APP_VERSION, "Could not find package info") + ) + false + } } fun isLastUpdateCheckExpired(expiry: Long): Boolean { - return Instant.ofEpochSecond(expiry).isBefore(Instant.now()) + return Instant.ofEpochSecond(expiry) < Instant.now() } /** @@ -104,13 +42,11 @@ object ReleaseVersionUtil { * @return Epoch second of expiry date time */ fun coerceUpdateCheckExpiry(expiryString: String?): Long { - val now = ZonedDateTime.now() - return expiryString?.let { - var expiry = - ZonedDateTime.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(expiryString)) - expiry = maxOf(expiry, now.plusHours(6)) - expiry = minOf(expiry, now.plusHours(72)) - expiry.toEpochSecond() - } ?: now.plusHours(6).toEpochSecond() + val nowPlus6Hours = ZonedDateTime.now().plusHours(6) + val expiry = expiryString?.let { + ZonedDateTime.from(DateTimeFormatter.RFC_1123_DATE_TIME.parse(it)) + .coerceIn(nowPlus6Hours, nowPlus6Hours.plusHours(66)) + } ?: nowPlus6Hours + return expiry.toEpochSecond() } } diff --git a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java index 8c697d327..69dc697fe 100644 --- a/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/SecondaryStreamHelper.java @@ -1,20 +1,24 @@ package org.schabi.newpipe.util; +import android.content.Context; + import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import org.schabi.newpipe.extractor.MediaFormat; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.VideoStream; -import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper; +import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper; import java.util.List; public class SecondaryStreamHelper { private final int position; - private final StreamSizeWrapper streams; + private final StreamInfoWrapper streams; - public SecondaryStreamHelper(final StreamSizeWrapper streams, final T selectedStream) { + public SecondaryStreamHelper(@NonNull final StreamInfoWrapper streams, + final T selectedStream) { this.streams = streams; this.position = streams.getStreamsList().indexOf(selectedStream); if (this.position < 0) { @@ -23,43 +27,42 @@ public class SecondaryStreamHelper { } /** - * Find the correct audio stream for the desired video stream. + * Finds an audio stream compatible with the provided video-only stream, so that the two streams + * can be combined in a single file by the downloader. If there are multiple available audio + * streams, chooses either the highest or the lowest quality one based on + * {@link ListHelper#isLimitingDataUsage(Context)}. * + * @param context Android context * @param audioStreams list of audio streams - * @param videoStream desired video ONLY stream - * @return selected audio stream or null if a candidate was not found + * @param videoStream desired video-ONLY stream + * @return the selected audio stream or null if a candidate was not found */ - public static AudioStream getAudioStreamFor(@NonNull final List audioStreams, + @Nullable + public static AudioStream getAudioStreamFor(@NonNull final Context context, + @NonNull final List audioStreams, @NonNull final VideoStream videoStream) { - switch (videoStream.getFormat()) { - case WEBM: - case MPEG_4:// ¿is mpeg-4 DASH? - break; - default: - return null; - } + final MediaFormat mediaFormat = videoStream.getFormat(); - final boolean m4v = videoStream.getFormat() == MediaFormat.MPEG_4; + if (mediaFormat == MediaFormat.WEBM) { + return audioStreams + .stream() + .filter(audioStream -> audioStream.getFormat() == MediaFormat.WEBMA + || audioStream.getFormat() == MediaFormat.WEBMA_OPUS) + .max(ListHelper.getAudioFormatComparator(MediaFormat.WEBMA, + ListHelper.isLimitingDataUsage(context))) + .orElse(null); - for (final AudioStream audio : audioStreams) { - if (audio.getFormat() == (m4v ? MediaFormat.M4A : MediaFormat.WEBMA)) { - return audio; - } - } + } else if (mediaFormat == MediaFormat.MPEG_4) { + return audioStreams + .stream() + .filter(audioStream -> audioStream.getFormat() == MediaFormat.M4A) + .max(ListHelper.getAudioFormatComparator(MediaFormat.M4A, + ListHelper.isLimitingDataUsage(context))) + .orElse(null); - if (m4v) { + } else { return null; } - - // retry, but this time in reverse order - for (int i = audioStreams.size() - 1; i >= 0; i--) { - final AudioStream audio = audioStreams.get(i); - if (audio.getFormat() == MediaFormat.WEBMA_OPUS) { - return audio; - } - } - - return null; } public T getStream() { diff --git a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java index d41493a7f..c712157b3 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ServiceHelper.java @@ -1,9 +1,13 @@ package org.schabi.newpipe.util; +import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; + import android.content.Context; import android.content.SharedPreferences; import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.preference.PreferenceManager; @@ -18,10 +22,9 @@ import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; +import java.util.Optional; import java.util.concurrent.TimeUnit; -import static org.schabi.newpipe.extractor.ServiceList.SoundCloud; - public final class ServiceHelper { private static final StreamingService DEFAULT_FALLBACK_SERVICE = ServiceList.YouTube; @@ -31,17 +34,17 @@ public final class ServiceHelper { public static int getIcon(final int serviceId) { switch (serviceId) { case 0: - return R.drawable.place_holder_youtube; + return R.drawable.ic_smart_display; case 1: - return R.drawable.place_holder_cloud; + return R.drawable.ic_cloud; case 2: - return R.drawable.place_holder_gadse; + return R.drawable.ic_placeholder_media_ccc; case 3: - return R.drawable.place_holder_peertube; + return R.drawable.ic_placeholder_peertube; case 4: - return R.drawable.place_holder_bandcamp; + return R.drawable.ic_placeholder_bandcamp; default: - return R.drawable.place_holder_circle; + return R.drawable.ic_circle; } } @@ -113,18 +116,45 @@ public final class ServiceHelper { } public static int getSelectedServiceId(final Context context) { + return Optional.ofNullable(getSelectedService(context)) + .orElse(DEFAULT_FALLBACK_SERVICE) + .getServiceId(); + } + + @Nullable + public static StreamingService getSelectedService(final Context context) { final String serviceName = PreferenceManager.getDefaultSharedPreferences(context) .getString(context.getString(R.string.current_service_key), context.getString(R.string.default_service_value)); - int serviceId; try { - serviceId = NewPipe.getService(serviceName).getServiceId(); + return NewPipe.getService(serviceName); } catch (final ExtractionException e) { - serviceId = DEFAULT_FALLBACK_SERVICE.getServiceId(); + return null; } + } - return serviceId; + @NonNull + public static String getNameOfServiceById(final int serviceId) { + return ServiceList.all().stream() + .filter(s -> s.getServiceId() == serviceId) + .findFirst() + .map(StreamingService::getServiceInfo) + .map(StreamingService.ServiceInfo::getName) + .orElse(""); + } + + /** + * @param serviceId the id of the service + * @return the service corresponding to the provided id + * @throws java.util.NoSuchElementException if there is no service with the provided id + */ + @NonNull + public static StreamingService getServiceById(final int serviceId) { + return ServiceList.all().stream() + .filter(s -> s.getServiceId() == serviceId) + .findFirst() + .orElseThrow(); } public static void setSelectedServiceId(final Context context, final int serviceId) { @@ -138,16 +168,6 @@ public final class ServiceHelper { setSelectedServicePreferences(context, serviceName); } - public static void setSelectedServiceId(final Context context, final String serviceName) { - final int serviceId = NewPipe.getIdOfService(serviceName); - if (serviceId == -1) { - setSelectedServicePreferences(context, - DEFAULT_FALLBACK_SERVICE.getServiceInfo().getName()); - } else { - setSelectedServicePreferences(context, serviceName); - } - } - private static void setSelectedServicePreferences(final Context context, final String serviceName) { PreferenceManager.getDefaultSharedPreferences(context).edit(). @@ -162,15 +182,6 @@ public final class ServiceHelper { } } - public static boolean isBeta(final StreamingService s) { - switch (s.getServiceInfo().getName()) { - case "YouTube": - return false; - default: - return true; - } - } - public static void initService(final Context context, final int serviceId) { if (serviceId == ServiceList.PeerTube.getServiceId()) { final SharedPreferences sharedPreferences = PreferenceManager diff --git a/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java b/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java index b8cd4ef69..6e9ea7a47 100644 --- a/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java +++ b/app/src/main/java/org/schabi/newpipe/util/SparseItemUtil.java @@ -1,7 +1,5 @@ package org.schabi.newpipe.util; -import static org.schabi.newpipe.extractor.stream.StreamType.AUDIO_LIVE_STREAM; -import static org.schabi.newpipe.extractor.stream.StreamType.LIVE_STREAM; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import android.content.Context; @@ -49,8 +47,8 @@ public final class SparseItemUtil { public static void fetchItemInfoIfSparse(@NonNull final Context context, @NonNull final StreamInfoItem item, @NonNull final Consumer callback) { - if (((item.getStreamType() == LIVE_STREAM || item.getStreamType() == AUDIO_LIVE_STREAM) - || item.getDuration() >= 0) && !isNullOrEmpty(item.getUploaderUrl())) { + if ((StreamTypeUtil.isLiveStream(item.getStreamType()) || item.getDuration() >= 0) + && !isNullOrEmpty(item.getUploaderUrl())) { // if the duration is >= 0 (provided that the item is not a livestream) and there is an // uploader url, probably all info is already there, so there is no need to fetch it callback.accept(new SinglePlayQueue(item)); @@ -99,10 +97,10 @@ public final class SparseItemUtil { * @param url url of the stream to load * @param callback callback to be called with the result */ - private static void fetchStreamInfoAndSaveToDatabase(@NonNull final Context context, - final int serviceId, - @NonNull final String url, - final Consumer callback) { + public static void fetchStreamInfoAndSaveToDatabase(@NonNull final Context context, + final int serviceId, + @NonNull final String url, + final Consumer callback) { Toast.makeText(context, R.string.loading_stream_details, Toast.LENGTH_SHORT).show(); ExtractorHelper.getStreamInfo(serviceId, url, false) .subscribeOn(Schedulers.io()) diff --git a/app/src/main/java/org/schabi/newpipe/util/StateSaver.java b/app/src/main/java/org/schabi/newpipe/util/StateSaver.java index 6ebdaee02..61fdb602f 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StateSaver.java +++ b/app/src/main/java/org/schabi/newpipe/util/StateSaver.java @@ -27,6 +27,7 @@ import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.os.BundleCompat; import org.schabi.newpipe.BuildConfig; import org.schabi.newpipe.MainActivity; @@ -46,8 +47,8 @@ import java.util.concurrent.ConcurrentHashMap; */ public final class StateSaver { public static final String KEY_SAVED_STATE = "key_saved_state"; - private static final ConcurrentHashMap> STATE_OBJECTS_HOLDER - = new ConcurrentHashMap<>(); + private static final ConcurrentHashMap> STATE_OBJECTS_HOLDER = + new ConcurrentHashMap<>(); private static final String TAG = "StateSaver"; private static final String CACHE_DIR_NAME = "state_cache"; private static String cacheDirPath; @@ -82,7 +83,8 @@ public final class StateSaver { return null; } - final SavedState savedState = outState.getParcelable(KEY_SAVED_STATE); + final SavedState savedState = BundleCompat.getParcelable( + outState, KEY_SAVED_STATE, SavedState.class); if (savedState == null) { return null; } @@ -107,8 +109,8 @@ public final class StateSaver { } try { - Queue savedObjects - = STATE_OBJECTS_HOLDER.remove(savedState.getPrefixFileSaved()); + Queue savedObjects = + STATE_OBJECTS_HOLDER.remove(savedState.getPrefixFileSaved()); if (savedObjects != null) { writeRead.readFrom(savedObjects); if (MainActivity.DEBUG) { @@ -309,7 +311,7 @@ public final class StateSaver { } /** - * Used for describe how to save/read the objects. + * Used for describing how to save/read the objects. *

* Queue was chosen by its FIFO property. */ diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java index 03342a497..2eeb14b1b 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamItemAdapter.java @@ -1,7 +1,8 @@ package org.schabi.newpipe.util; +import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; + import android.content.Context; -import android.util.SparseArray; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; @@ -10,19 +11,27 @@ import android.widget.ImageView; import android.widget.Spinner; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; +import androidx.collection.SparseArrayCompat; + import org.schabi.newpipe.DownloaderImpl; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.MediaFormat; +import org.schabi.newpipe.extractor.downloader.Response; import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.Stream; import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; +import org.schabi.newpipe.extractor.utils.Utils; import java.io.Serializable; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.concurrent.Callable; +import java.util.stream.Collectors; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.core.Single; @@ -37,10 +46,10 @@ import us.shandian.giga.util.Utility; * @param the secondary stream type's class extending {@link Stream} */ public class StreamItemAdapter extends BaseAdapter { - private final Context context; - - private final StreamSizeWrapper streamsWrapper; - private final SparseArray> secondaryStreams; + @NonNull + private final StreamInfoWrapper streamsWrapper; + @NonNull + private final SparseArrayCompat> secondaryStreams; /** * Indicates that at least one of the primary streams is an instance of {@link VideoStream}, @@ -49,9 +58,10 @@ public class StreamItemAdapter extends BaseA */ private final boolean hasAnyVideoOnlyStreamWithNoSecondaryStream; - public StreamItemAdapter(final Context context, final StreamSizeWrapper streamsWrapper, - final SparseArray> secondaryStreams) { - this.context = context; + public StreamItemAdapter( + @NonNull final StreamInfoWrapper streamsWrapper, + @NonNull final SparseArrayCompat> secondaryStreams + ) { this.streamsWrapper = streamsWrapper; this.secondaryStreams = secondaryStreams; @@ -59,15 +69,15 @@ public class StreamItemAdapter extends BaseA checkHasAnyVideoOnlyStreamWithNoSecondaryStream(); } - public StreamItemAdapter(final Context context, final StreamSizeWrapper streamsWrapper) { - this(context, streamsWrapper, null); + public StreamItemAdapter(final StreamInfoWrapper streamsWrapper) { + this(streamsWrapper, new SparseArrayCompat<>(0)); } public List getAll() { return streamsWrapper.getStreamsList(); } - public SparseArray> getAllSecondary() { + public SparseArrayCompat> getAllSecondary() { return secondaryStreams; } @@ -87,7 +97,8 @@ public class StreamItemAdapter extends BaseA } @Override - public View getDropDownView(final int position, final View convertView, + public View getDropDownView(final int position, + final View convertView, final ViewGroup parent) { return getCustomView(position, convertView, parent, true); } @@ -98,8 +109,12 @@ public class StreamItemAdapter extends BaseA convertView, parent, false); } - private View getCustomView(final int position, final View view, final ViewGroup parent, + @NonNull + private View getCustomView(final int position, + final View view, + final ViewGroup parent, final boolean isDropdownItem) { + final var context = parent.getContext(); View convertView = view; if (convertView == null) { convertView = LayoutInflater.from(context).inflate( @@ -112,6 +127,7 @@ public class StreamItemAdapter extends BaseA final TextView sizeView = convertView.findViewById(R.id.stream_size); final T stream = getItem(position); + final MediaFormat mediaFormat = streamsWrapper.getFormat(position); int woSoundIconVisibility = View.GONE; String qualityString; @@ -122,7 +138,7 @@ public class StreamItemAdapter extends BaseA if (hasAnyVideoOnlyStreamWithNoSecondaryStream) { if (videoStream.isVideoOnly()) { - woSoundIconVisibility = hasSecondaryStream(position) + woSoundIconVisibility = secondaryStreams.get(position) != null // It has a secondary stream associated with it, so check if it's a // dropdown view so it doesn't look out of place (missing margin) // compared to those that don't. @@ -135,24 +151,29 @@ public class StreamItemAdapter extends BaseA } } else if (stream instanceof AudioStream) { final AudioStream audioStream = ((AudioStream) stream); - qualityString = audioStream.getAverageBitrate() > 0 - ? audioStream.getAverageBitrate() + "kbps" - : audioStream.getFormat().getName(); + if (audioStream.getAverageBitrate() > 0) { + qualityString = audioStream.getAverageBitrate() + "kbps"; + } else { + qualityString = context.getString(R.string.unknown_quality); + } } else if (stream instanceof SubtitlesStream) { qualityString = ((SubtitlesStream) stream).getDisplayLanguageName(); if (((SubtitlesStream) stream).isAutoGenerated()) { qualityString += " (" + context.getString(R.string.caption_auto_generated) + ")"; } } else { - qualityString = stream.getFormat().getSuffix(); + if (mediaFormat == null) { + qualityString = context.getString(R.string.unknown_quality); + } else { + qualityString = mediaFormat.getSuffix(); + } } if (streamsWrapper.getSizeInBytes(position) > 0) { - final SecondaryStreamHelper secondary = secondaryStreams == null ? null - : secondaryStreams.get(position); + final var secondary = secondaryStreams.get(position); if (secondary != null) { - final long size - = secondary.getSizeInBytes() + streamsWrapper.getSizeInBytes(position); + final long size = secondary.getSizeInBytes() + + streamsWrapper.getSizeInBytes(position); sizeView.setText(Utility.formatBytes(size)); } else { sizeView.setText(streamsWrapper.getFormattedSize(position)); @@ -164,11 +185,15 @@ public class StreamItemAdapter extends BaseA if (stream instanceof SubtitlesStream) { formatNameView.setText(((SubtitlesStream) stream).getLanguageTag()); - } else if (stream.getFormat() == MediaFormat.WEBMA_OPUS) { - // noinspection AndroidLintSetTextI18n - formatNameView.setText("opus"); } else { - formatNameView.setText(stream.getFormat().getName()); + if (mediaFormat == null) { + formatNameView.setText(context.getString(R.string.unknown_format)); + } else if (mediaFormat == MediaFormat.WEBMA_OPUS) { + // noinspection AndroidLintSetTextI18n + formatNameView.setText("opus"); + } else { + formatNameView.setText(mediaFormat.getName()); + } } qualityView.setText(qualityString); @@ -177,14 +202,6 @@ public class StreamItemAdapter extends BaseA return convertView; } - /** - * @param position which primary stream to check. - * @return whether the primary stream at position has a secondary stream associated with it. - */ - private boolean hasSecondaryStream(final int position) { - return secondaryStreams != null && secondaryStreams.get(position) != null; - } - /** * @return if there are any video-only streams with no secondary stream associated with them. * @see #hasAnyVideoOnlyStreamWithNoSecondaryStream @@ -194,7 +211,7 @@ public class StreamItemAdapter extends BaseA final T stream = streamsWrapper.getStreamsList().get(i); if (stream instanceof VideoStream) { final boolean videoOnly = ((VideoStream) stream).isVideoOnly(); - if (videoOnly && !hasSecondaryStream(i)) { + if (videoOnly && secondaryStreams.get(i) == null) { return true; } } @@ -208,44 +225,58 @@ public class StreamItemAdapter extends BaseA * * @param the stream type's class extending {@link Stream} */ - public static class StreamSizeWrapper implements Serializable { - private static final StreamSizeWrapper EMPTY = new StreamSizeWrapper<>( - Collections.emptyList(), null); + public static class StreamInfoWrapper implements Serializable { + private static final StreamInfoWrapper EMPTY = + new StreamInfoWrapper<>(Collections.emptyList(), null); + private static final int SIZE_UNSET = -2; + private final List streamsList; private final long[] streamSizes; + private final MediaFormat[] streamFormats; private final String unknownSize; - public StreamSizeWrapper(final List sL, final Context context) { - this.streamsList = sL != null - ? sL - : Collections.emptyList(); + public StreamInfoWrapper(@NonNull final List streamList, + @Nullable final Context context) { + this.streamsList = streamList; this.streamSizes = new long[streamsList.size()]; this.unknownSize = context == null ? "--.-" : context.getString(R.string.unknown_content); - - Arrays.fill(streamSizes, -2); + this.streamFormats = new MediaFormat[streamsList.size()]; + resetInfo(); } /** - * Helper method to fetch the sizes of all the streams in a wrapper. + * Helper method to fetch the sizes and missing media formats + * of all the streams in a wrapper. * * @param the stream type's class extending {@link Stream} * @param streamsWrapper the wrapper * @return a {@link Single} that returns a boolean indicating if any elements were changed */ - public static Single fetchSizeForWrapper( - final StreamSizeWrapper streamsWrapper) { + @NonNull + public static Single fetchMoreInfoForWrapper( + final StreamInfoWrapper streamsWrapper) { final Callable fetchAndSet = () -> { boolean hasChanged = false; for (final X stream : streamsWrapper.getStreamsList()) { - if (streamsWrapper.getSizeInBytes(stream) > -2) { + final boolean changeSize = streamsWrapper.getSizeInBytes(stream) <= SIZE_UNSET; + final boolean changeFormat = stream.getFormat() == null; + if (!changeSize && !changeFormat) { continue; } - - final long contentLength = DownloaderImpl.getInstance().getContentLength( - stream.getUrl()); - streamsWrapper.setSize(stream, contentLength); - hasChanged = true; + final Response response = DownloaderImpl.getInstance() + .head(stream.getContent()); + if (changeSize) { + final String contentLength = response.getHeader("Content-Length"); + if (!isNullOrEmpty(contentLength)) { + streamsWrapper.setSize(stream, Long.parseLong(contentLength)); + hasChanged = true; + } + } + if (changeFormat) { + hasChanged = retrieveMediaFormat(stream, streamsWrapper, response) + || hasChanged; + } } return hasChanged; }; @@ -256,9 +287,149 @@ public class StreamItemAdapter extends BaseA .onErrorReturnItem(true); } - public static StreamSizeWrapper empty() { + /** + * Try to retrieve the {@link MediaFormat} for a stream from the request headers. + * + * @param the stream type to get the {@link MediaFormat} for + * @param stream the stream to find the {@link MediaFormat} for + * @param streamsWrapper the wrapper to store the found {@link MediaFormat} in + * @param response the response of the head request for the given stream + * @return {@code true} if the media format could be retrieved; {@code false} otherwise + */ + @VisibleForTesting + public static boolean retrieveMediaFormat( + @NonNull final X stream, + @NonNull final StreamInfoWrapper streamsWrapper, + @NonNull final Response response) { + return retrieveMediaFormatFromFileTypeHeaders(stream, streamsWrapper, response) + || retrieveMediaFormatFromContentDispositionHeader( + stream, streamsWrapper, response) + || retrieveMediaFormatFromContentTypeHeader(stream, streamsWrapper, response); + } + + @VisibleForTesting + public static boolean retrieveMediaFormatFromFileTypeHeaders( + @NonNull final X stream, + @NonNull final StreamInfoWrapper streamsWrapper, + @NonNull final Response response) { + // try to use additional headers from CDNs or servers, + // e.g. x-amz-meta-file-type (e.g. for SoundCloud) + final List keys = response.responseHeaders().keySet().stream() + .filter(k -> k.endsWith("file-type")).collect(Collectors.toList()); + if (!keys.isEmpty()) { + for (final String key : keys) { + final String suffix = response.getHeader(key); + final MediaFormat format = MediaFormat.getFromSuffix(suffix); + if (format != null) { + streamsWrapper.setFormat(stream, format); + return true; + } + } + } + return false; + } + + /** + *

Retrieve a {@link MediaFormat} from a HTTP Content-Disposition header + * for a stream and store the info in a wrapper.

+ * @see + * + * mdn Web Docs for the HTTP Content-Disposition Header + * @param stream the stream to get the {@link MediaFormat} for + * @param streamsWrapper the wrapper to store the {@link MediaFormat} in + * @param response the response to get the Content-Disposition header from + * @return {@code true} if the {@link MediaFormat} could be retrieved from the response; + * otherwise {@code false} + * @param + */ + @VisibleForTesting + public static boolean retrieveMediaFormatFromContentDispositionHeader( + @NonNull final X stream, + @NonNull final StreamInfoWrapper streamsWrapper, + @NonNull final Response response) { + // parse the Content-Disposition header, + // see + // there can be two filename directives + String contentDisposition = response.getHeader("Content-Disposition"); + if (contentDisposition == null) { + return false; + } + try { + contentDisposition = Utils.decodeUrlUtf8(contentDisposition); + final String[] parts = contentDisposition.split(";"); + for (String part : parts) { + final String fileName; + part = part.trim(); + + // extract the filename + if (part.startsWith("filename=")) { + // remove directive and decode + fileName = Utils.decodeUrlUtf8(part.substring(9)); + } else if (part.startsWith("filename*=")) { + fileName = Utils.decodeUrlUtf8(part.substring(10)); + } else { + continue; + } + + // extract the file extension / suffix + final String[] p = fileName.split("\\."); + String suffix = p[p.length - 1]; + if (suffix.endsWith("\"") || suffix.endsWith("'")) { + // remove trailing quotes if present, end index is exclusive + suffix = suffix.substring(0, suffix.length() - 1); + } + + // get the corresponding media format + final MediaFormat format = MediaFormat.getFromSuffix(suffix); + if (format != null) { + streamsWrapper.setFormat(stream, format); + return true; + } + } + } catch (final Exception ignored) { + // fail silently + } + return false; + } + + @VisibleForTesting + public static boolean retrieveMediaFormatFromContentTypeHeader( + @NonNull final X stream, + @NonNull final StreamInfoWrapper streamsWrapper, + @NonNull final Response response) { + // try to get the format by content type + // some mime types are not unique for every format, those are omitted + final String contentTypeHeader = response.getHeader("Content-Type"); + if (contentTypeHeader == null) { + return false; + } + + @Nullable MediaFormat foundFormat = null; + for (final MediaFormat format : MediaFormat.getAllFromMimeType(contentTypeHeader)) { + if (foundFormat == null) { + foundFormat = format; + } else if (foundFormat.id != format.id) { + return false; + } + } + if (foundFormat != null) { + streamsWrapper.setFormat(stream, foundFormat); + return true; + } + return false; + } + + public void resetInfo() { + Arrays.fill(streamSizes, SIZE_UNSET); + for (int i = 0; i < streamsList.size(); i++) { + streamFormats[i] = streamsList.get(i) == null // test for invalid streams + ? null : streamsList.get(i).getFormat(); + } + } + + public static StreamInfoWrapper empty() { //noinspection unchecked - return (StreamSizeWrapper) EMPTY; + return (StreamInfoWrapper) EMPTY; } public List getStreamsList() { @@ -277,10 +448,6 @@ public class StreamItemAdapter extends BaseA return formatSize(getSizeInBytes(streamIndex)); } - public String getFormattedSize(final T stream) { - return formatSize(getSizeInBytes(stream)); - } - private String formatSize(final long size) { if (size > -1) { return Utility.formatBytes(size); @@ -288,12 +455,16 @@ public class StreamItemAdapter extends BaseA return unknownSize; } - public void setSize(final int streamIndex, final long sizeInBytes) { - streamSizes[streamIndex] = sizeInBytes; - } - public void setSize(final T stream, final long sizeInBytes) { streamSizes[streamsList.indexOf(stream)] = sizeInBytes; } + + public MediaFormat getFormat(final int streamIndex) { + return streamFormats[streamIndex]; + } + + public void setFormat(final T stream, final MediaFormat format) { + streamFormats[streamsList.indexOf(stream)] = format; + } } } diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java b/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java index 87b3eed4f..0cc0ecf1f 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamTypeUtil.java @@ -3,7 +3,7 @@ package org.schabi.newpipe.util; import org.schabi.newpipe.extractor.stream.StreamType; /** - * Utility class for {@link org.schabi.newpipe.extractor.stream.StreamType}. + * Utility class for {@link StreamType}. */ public final class StreamTypeUtil { private StreamTypeUtil() { @@ -11,11 +11,37 @@ public final class StreamTypeUtil { } /** - * Checks if the streamType is a livestream. + * Check if the {@link StreamType} of a stream is a livestream. * - * @param streamType - * @return true when the streamType is a - * {@link StreamType#LIVE_STREAM} or {@link StreamType#AUDIO_LIVE_STREAM} + * @param streamType the stream type of the stream + * @return whether the stream type is {@link StreamType#AUDIO_STREAM}, + * {@link StreamType#AUDIO_LIVE_STREAM} or {@link StreamType#POST_LIVE_AUDIO_STREAM} + */ + public static boolean isAudio(final StreamType streamType) { + return streamType == StreamType.AUDIO_STREAM + || streamType == StreamType.AUDIO_LIVE_STREAM + || streamType == StreamType.POST_LIVE_AUDIO_STREAM; + } + + /** + * Check if the {@link StreamType} of a stream is a livestream. + * + * @param streamType the stream type of the stream + * @return whether the stream type is {@link StreamType#VIDEO_STREAM}, + * {@link StreamType#LIVE_STREAM} or {@link StreamType#POST_LIVE_STREAM} + */ + public static boolean isVideo(final StreamType streamType) { + return streamType == StreamType.VIDEO_STREAM + || streamType == StreamType.LIVE_STREAM + || streamType == StreamType.POST_LIVE_STREAM; + } + + /** + * Check if the {@link StreamType} of a stream is a livestream. + * + * @param streamType the stream type of the stream + * @return whether the stream type is {@link StreamType#LIVE_STREAM} or + * {@link StreamType#AUDIO_LIVE_STREAM} */ public static boolean isLiveStream(final StreamType streamType) { return streamType == StreamType.LIVE_STREAM diff --git a/app/src/main/java/org/schabi/newpipe/util/TLSSocketFactoryCompat.java b/app/src/main/java/org/schabi/newpipe/util/TLSSocketFactoryCompat.java deleted file mode 100644 index 05e69408a..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/TLSSocketFactoryCompat.java +++ /dev/null @@ -1,104 +0,0 @@ -package org.schabi.newpipe.util; - -import java.io.IOException; -import java.net.InetAddress; -import java.net.Socket; -import java.security.KeyManagementException; -import java.security.NoSuchAlgorithmException; - -import javax.net.ssl.HttpsURLConnection; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLSocket; -import javax.net.ssl.SSLSocketFactory; - -import android.util.Log; - - -/** - * This is an extension of the SSLSocketFactory which enables TLS 1.2 and 1.1. - * Created for usage on Android 4.1-4.4 devices, which haven't enabled those by default. - */ -public class TLSSocketFactoryCompat extends SSLSocketFactory { - - private static final String TAG = "TLSSocketFactoryCom"; - - private static TLSSocketFactoryCompat instance = null; - - private final SSLSocketFactory internalSSLSocketFactory; - - public TLSSocketFactoryCompat() throws KeyManagementException, NoSuchAlgorithmException { - final SSLContext context = SSLContext.getInstance("TLS"); - context.init(null, null, null); - internalSSLSocketFactory = context.getSocketFactory(); - } - - public static TLSSocketFactoryCompat getInstance() - throws NoSuchAlgorithmException, KeyManagementException { - if (instance != null) { - return instance; - } - instance = new TLSSocketFactoryCompat(); - return instance; - } - - public static void setAsDefault() { - try { - HttpsURLConnection.setDefaultSSLSocketFactory(getInstance()); - } catch (NoSuchAlgorithmException | KeyManagementException e) { - Log.e(TAG, "Unable to setAsDefault", e); - } - } - - @Override - public String[] getDefaultCipherSuites() { - return internalSSLSocketFactory.getDefaultCipherSuites(); - } - - @Override - public String[] getSupportedCipherSuites() { - return internalSSLSocketFactory.getSupportedCipherSuites(); - } - - @Override - public Socket createSocket() throws IOException { - return enableTLSOnSocket(internalSSLSocketFactory.createSocket()); - } - - @Override - public Socket createSocket(final Socket s, final String host, final int port, - final boolean autoClose) throws IOException { - return enableTLSOnSocket(internalSSLSocketFactory.createSocket(s, host, port, autoClose)); - } - - @Override - public Socket createSocket(final String host, final int port) throws IOException { - return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)); - } - - @Override - public Socket createSocket(final String host, final int port, final InetAddress localHost, - final int localPort) throws IOException { - return enableTLSOnSocket(internalSSLSocketFactory.createSocket( - host, port, localHost, localPort)); - } - - @Override - public Socket createSocket(final InetAddress host, final int port) throws IOException { - return enableTLSOnSocket(internalSSLSocketFactory.createSocket(host, port)); - } - - @Override - public Socket createSocket(final InetAddress address, final int port, - final InetAddress localAddress, final int localPort) - throws IOException { - return enableTLSOnSocket(internalSSLSocketFactory.createSocket( - address, port, localAddress, localPort)); - } - - private Socket enableTLSOnSocket(final Socket socket) { - if (socket instanceof SSLSocket) { - ((SSLSocket) socket).setEnabledProtocols(new String[]{"TLSv1.1", "TLSv1.2"}); - } - return socket; - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java index 7c47d387f..ab74e0305 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java @@ -23,14 +23,17 @@ import android.app.Activity; import android.content.Context; import android.content.res.Configuration; import android.content.res.Resources; +import android.graphics.drawable.Drawable; import android.util.TypedValue; import androidx.annotation.AttrRes; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StyleRes; import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatDelegate; +import androidx.appcompat.content.res.AppCompatResources; import androidx.core.content.ContextCompat; import androidx.preference.PreferenceManager; @@ -38,6 +41,7 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.info_list.ItemViewMode; public final class ThemeHelper { private ThemeHelper() { @@ -227,6 +231,36 @@ public final class ThemeHelper { return value.data; } + /** + * Resolves a {@link Drawable} by it's id. + * + * @param context Context + * @param attrResId Resource id + * @return the {@link Drawable} + */ + public static Drawable resolveDrawable(@NonNull final Context context, + @AttrRes final int attrResId) { + final TypedValue typedValue = new TypedValue(); + context.getTheme().resolveAttribute(attrResId, typedValue, true); + return AppCompatResources.getDrawable(context, typedValue.resourceId); + } + + /** + * Gets a runtime dimen from the {@code android} package. Should be used for dimens for which + * normal accessing with {@code R.dimen.} is not available. + * + * @param context context + * @param name dimen resource name (e.g. navigation_bar_height) + * @return the obtained dimension, in pixels, or 0 if the resource could not be resolved + */ + public static int getAndroidDimenPx(@NonNull final Context context, final String name) { + final int resId = context.getResources().getIdentifier(name, "dimen", "android"); + if (resId <= 0) { + return 0; + } + return context.getResources().getDimensionPixelSize(resId); + } + private static String getSelectedThemeKey(final Context context) { final String themeKey = context.getString(R.string.theme_key); final String defaultTheme = context.getResources().getString(R.string.default_theme_value); @@ -299,7 +333,6 @@ public final class ThemeHelper { } } - /** * Returns whether the grid layout or the list layout should be used. If the user set "auto" * mode in settings, decides based on screen orientation (landscape) and size. @@ -308,19 +341,8 @@ public final class ThemeHelper { * @return true:use grid layout, false:use list layout */ public static boolean shouldUseGridLayout(final Context context) { - final String listMode = PreferenceManager.getDefaultSharedPreferences(context) - .getString(context.getString(R.string.list_view_mode_key), - context.getString(R.string.list_view_mode_value)); - - if (listMode.equals(context.getString(R.string.list_view_mode_list_key))) { - return false; - } else if (listMode.equals(context.getString(R.string.list_view_mode_grid_key))) { - return true; - } else { - final Configuration configuration = context.getResources().getConfiguration(); - return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE - && configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE); - } + final ItemViewMode mode = getItemViewMode(context); + return mode == ItemViewMode.GRID; } /** @@ -334,6 +356,36 @@ public final class ThemeHelper { context.getResources().getDimensionPixelSize(R.dimen.channel_item_grid_min_width)); } + /** + * Returns item view mode. + * @param context to read preference and parse string + * @return Returns one of ItemViewMode + */ + public static ItemViewMode getItemViewMode(final Context context) { + final String listMode = PreferenceManager.getDefaultSharedPreferences(context) + .getString(context.getString(R.string.list_view_mode_key), + context.getString(R.string.list_view_mode_value)); + final ItemViewMode result; + if (listMode.equals(context.getString(R.string.list_view_mode_list_key))) { + result = ItemViewMode.LIST; + } else if (listMode.equals(context.getString(R.string.list_view_mode_grid_key))) { + result = ItemViewMode.GRID; + } else if (listMode.equals(context.getString(R.string.list_view_mode_card_key))) { + result = ItemViewMode.CARD; + } else { + // Auto mode - evaluate whether to use Grid based on screen real estate. + final Configuration configuration = context.getResources().getConfiguration(); + final boolean useGrid = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + && configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE); + if (useGrid) { + result = ItemViewMode.GRID; + } else { + result = ItemViewMode.LIST; + } + } + return result; + } + /** * Calculates the number of grid stream info items that can fit horizontally on the screen. The * width of a grid stream info item is obtained from the thumbnail width plus the right and left diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java index 0df579d88..6a605e982 100644 --- a/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java @@ -1,6 +1,11 @@ package org.schabi.newpipe.util.external_communication; +import static org.schabi.newpipe.util.external_communication.ShareUtils.installApp; +import static org.schabi.newpipe.util.external_communication.ShareUtils.tryOpenIntentInApp; + import android.content.Context; +import android.content.Intent; +import android.net.Uri; import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; @@ -8,7 +13,6 @@ import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.ServiceList; -import org.schabi.newpipe.util.NavigationHelper; /** * Util class that provides methods which are related to the Kodi Media Center and its Kore app. @@ -29,13 +33,40 @@ public final class KoreUtils { .getBoolean(context.getString(R.string.show_play_with_kodi_key), false); } - public static void showInstallKoreDialog(@NonNull final Context context) { - final AlertDialog.Builder builder = new AlertDialog.Builder(context); - builder.setMessage(R.string.kore_not_found) - .setPositiveButton(R.string.install, (dialog, which) -> - NavigationHelper.installKore(context)) - .setNegativeButton(R.string.cancel, (dialog, which) -> { - }); - builder.create().show(); + /** + * Start an activity to install Kore. + * + * @param context the context to use + */ + public static void installKore(final Context context) { + installApp(context, context.getString(R.string.kore_package)); + } + + /** + * Start Kore app to show a video on Kodi, and if the app is not installed ask the user to + * install it. + *

+ * For a list of supported urls see the + * + * Kore source code + * . + * + * @param context the context to use + * @param streamUrl the url to the stream to play + */ + public static void playWithKore(final Context context, final Uri streamUrl) { + final Intent intent = new Intent(Intent.ACTION_VIEW) + .setPackage(context.getString(R.string.kore_package)) + .setData(streamUrl) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + if (!tryOpenIntentInApp(context, intent)) { + new AlertDialog.Builder(context) + .setMessage(R.string.kore_not_found) + .setPositiveButton(R.string.install, (dialog, which) -> + installKore(context)) + .setNegativeButton(R.string.cancel, null) + .show(); + } } } diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java index c4f1675cf..7524e5413 100644 --- a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java @@ -1,5 +1,7 @@ package org.schabi.newpipe.util.external_communication; +import static org.schabi.newpipe.MainActivity.DEBUG; + import android.content.ActivityNotFoundException; import android.content.ClipData; import android.content.ClipboardManager; @@ -7,17 +9,31 @@ import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; import android.content.pm.ResolveInfo; +import android.graphics.Bitmap; import android.net.Uri; import android.os.Build; import android.text.TextUtils; +import android.util.Log; import android.widget.Toast; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; +import androidx.core.content.FileProvider; +import org.schabi.newpipe.BuildConfig; import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.Image; +import org.schabi.newpipe.util.image.ImageStrategy; +import org.schabi.newpipe.util.image.PicassoHelper; + +import java.io.File; +import java.io.FileOutputStream; +import java.util.List; public final class ShareUtils { + private static final String TAG = ShareUtils.class.getSimpleName(); + private ShareUtils() { } @@ -28,125 +44,128 @@ public final class ShareUtils { * second param (a system chooser will be opened if there are multiple markets and no default) * and falls back to Google Play Store web URL if no app to handle the market scheme was found. *

- * It uses {@link #openIntentInApp(Context, Intent, boolean)} to open market scheme - * and {@link #openUrlInBrowser(Context, String, boolean)} to open Google Play Store - * web URL with false for the boolean param. + * It uses {@link #openIntentInApp(Context, Intent)} to open market scheme and {@link + * #openUrlInBrowser(Context, String)} to open Google Play Store web URL. * * @param context the context to use * @param packageId the package id of the app to be installed */ public static void installApp(@NonNull final Context context, final String packageId) { // Try market scheme - final boolean marketSchemeResult = openIntentInApp(context, new Intent(Intent.ACTION_VIEW, + final Intent marketSchemeIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("market://details?id=" + packageId)) - .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK), false); - if (!marketSchemeResult) { - // Fall back to Google Play Store Web URL (F-Droid can handle it) - openUrlInBrowser(context, - "https://play.google.com/store/apps/details?id=" + packageId, false); - } - } - - /** - * Open the url with the system default browser. - *

- * If no browser is set as default, fallbacks to - * {@link #openAppChooser(Context, Intent, boolean)} - * - * @param context the context to use - * @param url the url to browse - * @param httpDefaultBrowserTest the boolean to set if the test for the default browser will be - * for HTTP protocol or for the created intent - * @return true if the URL can be opened or false if it cannot - */ - public static boolean openUrlInBrowser(@NonNull final Context context, - final String url, - final boolean httpDefaultBrowserTest) { - final String defaultPackageName; - final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)) .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - - if (httpDefaultBrowserTest) { - defaultPackageName = getDefaultAppPackageName(context, new Intent(Intent.ACTION_VIEW, - Uri.parse("http://")).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); - } else { - defaultPackageName = getDefaultAppPackageName(context, intent); + if (!tryOpenIntentInApp(context, marketSchemeIntent)) { + // Fall back to Google Play Store Web URL (F-Droid can handle it) + openUrlInApp(context, "https://play.google.com/store/apps/details?id=" + packageId); } - - if (defaultPackageName.equals("android")) { - // No browser set as default (doesn't work on some devices) - openAppChooser(context, intent, true); - } else { - if (defaultPackageName.isEmpty()) { - // No app installed to open a web url - Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show(); - return false; - } else { - try { - intent.setPackage(defaultPackageName); - context.startActivity(intent); - } catch (final ActivityNotFoundException e) { - // Not a browser but an app chooser because of OEMs changes - intent.setPackage(null); - openAppChooser(context, intent, true); - } - } - } - - return true; } /** - * Open the url with the system default browser. + * Open the url with the system default browser. If no browser is set as default, falls back to + * {@link #openAppChooser(Context, Intent, boolean)}. *

- * If no browser is set as default, fallbacks to - * {@link #openAppChooser(Context, Intent, boolean)} + * This function selects the package to open based on which apps respond to the {@code http://} + * schema alone, which should exclude special non-browser apps that are can handle the url (e.g. + * the official YouTube app). *

- * This calls {@link #openUrlInBrowser(Context, String, boolean)} with true - * for the boolean parameter + * Therefore please prefer {@link #openUrlInApp(Context, String)}, that handles package + * resolution in a standard way, unless this is the action of an explicit "Open in browser" + * button. * * @param context the context to use * @param url the url to browse - * @return true if the URL can be opened or false if it cannot be **/ - public static boolean openUrlInBrowser(@NonNull final Context context, final String url) { - return openUrlInBrowser(context, url, true); + public static void openUrlInBrowser(@NonNull final Context context, final String url) { + // Resolve using a generic http://, so we are sure to get a browser and not e.g. the yt app. + // Note that this requires the `http` schema to be added to `` in the manifest. + final ResolveInfo defaultBrowserInfo; + final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://")); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent, + PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY)); + } else { + defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent, + PackageManager.MATCH_DEFAULT_ONLY); + } + + final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url)) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + if (defaultBrowserInfo == null) { + // No app installed to open a web URL, but it may be handled by other apps so try + // opening a system chooser for the link in this case (it could be bypassed by the + // system if there is only one app which can open the link or a default app associated + // with the link domain on Android 12 and higher) + openAppChooser(context, intent, true); + return; + } + + final String defaultBrowserPackage = defaultBrowserInfo.activityInfo.packageName; + + if (defaultBrowserPackage.equals("android")) { + // No browser set as default (doesn't work on some devices) + openAppChooser(context, intent, true); + } else { + try { + intent.setPackage(defaultBrowserPackage); + context.startActivity(intent); + } catch (final ActivityNotFoundException e) { + // Not a browser but an app chooser because of OEMs changes + intent.setPackage(null); + openAppChooser(context, intent, true); + } + } + } + + /** + * Open a url with the system default app using {@link Intent#ACTION_VIEW}, showing a toast in + * case of failure. + * + * @param context the context to use + * @param url the url to open + */ + public static void openUrlInApp(@NonNull final Context context, final String url) { + openIntentInApp(context, new Intent(Intent.ACTION_VIEW, Uri.parse(url)) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)); } /** * Open an intent with the system default app. *

- * The intent can be of every type, excepted a web intent for which - * {@link #openUrlInBrowser(Context, String, boolean)} should be used. - *

- * If no app can open the intent, a toast with the message {@code No app on your device can - * open this} is shown. + * Use {@link #openIntentInApp(Context, Intent)} to show a toast in case of failure. * - * @param context the context to use - * @param intent the intent to open - * @param showToast a boolean to set if a toast is displayed to user when no app is installed - * to open the intent (true) or not (false) - * @return true if the intent can be opened or false if it cannot be + * @param context the context to use + * @param intent the intent to open + * @return true if the intent could be opened successfully, false otherwise */ - public static boolean openIntentInApp(@NonNull final Context context, - @NonNull final Intent intent, - final boolean showToast) { - final String defaultPackageName = getDefaultAppPackageName(context, intent); - - if (defaultPackageName.isEmpty()) { - // No app installed to open the intent - if (showToast) { - Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG) - .show(); - } - return false; - } else { + public static boolean tryOpenIntentInApp(@NonNull final Context context, + @NonNull final Intent intent) { + try { context.startActivity(intent); + } catch (final ActivityNotFoundException e) { + return false; } - return true; } + /** + * Open an intent with the system default app, showing a toast in case of failure. + *

+ * Use {@link #tryOpenIntentInApp(Context, Intent)} if you don't want the toast. Use {@link + * #openUrlInApp(Context, String)} as a shorthand for {@link Intent#ACTION_VIEW} with urls. + * + * @param context the context to use + * @param intent the intent to + */ + public static void openIntentInApp(@NonNull final Context context, + @NonNull final Intent intent) { + if (!tryOpenIntentInApp(context, intent)) { + Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG) + .show(); + } + } + /** * Open the system chooser to launch an intent. *

@@ -172,17 +191,10 @@ public final class ShareUtils { } // Migrate any clip data and flags from the original intent. - final int permFlags; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - permFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION - | Intent.FLAG_GRANT_WRITE_URI_PERMISSION - | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION - | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION); - } else { - permFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION - | Intent.FLAG_GRANT_WRITE_URI_PERMISSION - | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION); - } + final int permFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION + | Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | Intent.FLAG_GRANT_PREFIX_URI_PERMISSION); if (permFlags != 0) { ClipData targetClipData = intent.getClipData(); if (targetClipData == null && intent.getData() != null) { @@ -200,40 +212,22 @@ public final class ShareUtils { chooserIntent.addFlags(permFlags); } } - context.startActivity(chooserIntent); - } - /** - * Get the default app package name. - *

- * If no app is set as default, it will return "android" (not on some devices because some - * OEMs changed the app chooser). - *

- * If no app is installed on user's device to handle the intent, it will return an empty string. - * - * @param context the context to use - * @param intent the intent to get default app - * @return the package name of the default app, an empty string if there's no app installed to - * handle the intent or the app chooser if there's no default - */ - private static String getDefaultAppPackageName(@NonNull final Context context, - @NonNull final Intent intent) { - final ResolveInfo resolveInfo = context.getPackageManager().resolveActivity(intent, - PackageManager.MATCH_DEFAULT_ONLY); - - if (resolveInfo == null) { - return ""; - } else { - return resolveInfo.activityInfo.packageName; + try { + context.startActivity(chooserIntent); + } catch (final ActivityNotFoundException e) { + Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show(); } } /** * Open the android share sheet to share a content. * + *

* For Android 10+ users, a content preview is shown, which includes the title of the shared - * content. - * Support sharing the image of the content needs to done, if possible. + * content and an image preview the content, if its URL is not null or empty and its + * corresponding image is in the image cache. + *

* * @param context the context to use * @param title the title of the content @@ -252,13 +246,20 @@ public final class ShareUtils { shareIntent.putExtra(Intent.EXTRA_SUBJECT, title); } - /* TODO: add the image of the content to Android share sheet with setClipData after - generating a content URI of this image, then use ClipData.newUri(the content resolver, - null, the content URI) and set the ClipData to the share intent with - shareIntent.setClipData(generated ClipData). - if (!imagePreviewUrl.isEmpty()) { - //shareIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); - }*/ + // Content preview in the share sheet has been added in Android 10, so it's not needed to + // set a content preview which will be never displayed + // See https://developer.android.com/training/sharing/send#adding-rich-content-previews + // If loading of images has been disabled, don't try to generate a content preview + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + && !TextUtils.isEmpty(imagePreviewUrl) + && ImageStrategy.shouldLoadImages()) { + + final ClipData clipData = generateClipDataForImagePreview(context, imagePreviewUrl); + if (clipData != null) { + shareIntent.setClipData(clipData); + shareIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + } openAppChooser(context, shareIntent, false); } @@ -266,11 +267,34 @@ public final class ShareUtils { /** * Open the android share sheet to share a content. * + *

* For Android 10+ users, a content preview is shown, which includes the title of the shared - * content. + * content and an image preview the content, if the preferred image chosen by {@link + * ImageStrategy#choosePreferredImage(List)} is in the image cache. + *

+ * + * @param context the context to use + * @param title the title of the content + * @param content the content to share + * @param images a set of possible {@link Image}s of the subject, among which to choose with + * {@link ImageStrategy#choosePreferredImage(List)} since that's likely to + * provide an image that is in Picasso's cache + */ + public static void shareText(@NonNull final Context context, + @NonNull final String title, + final String content, + final List images) { + shareText(context, title, content, ImageStrategy.choosePreferredImage(images)); + } + + /** + * Open the android share sheet to share a content. + * *

* This calls {@link #shareText(Context, String, String, String)} with an empty string for the - * imagePreviewUrl parameter. + * {@code imagePreviewUrl} parameter. This method should be used when the shared content has no + * preview thumbnail. + *

* * @param context the context to use * @param title the title of the content @@ -298,7 +322,92 @@ public final class ShareUtils { return; } - clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)); - Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); + try { + clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)); + if (Build.VERSION.SDK_INT < 33) { + // Android 13 has its own "copied to clipboard" dialog + Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); + } + } catch (final Exception e) { + Log.e(TAG, "Error when trying to copy text to clipboard", e); + Toast.makeText(context, R.string.msg_failed_to_copy, Toast.LENGTH_SHORT).show(); + } + } + + /** + * Generate a {@link ClipData} with the image of the content shared, if it's in the app cache. + * + *

+ * In order not to worry about network issues (timeouts, DNS issues, low connection speed, ...) + * when sharing a content, only images in the {@link com.squareup.picasso.LruCache LruCache} + * used by the Picasso library inside {@link PicassoHelper} are used as preview images. If the + * thumbnail image is not in the cache, no {@link ClipData} will be generated and {@code null} + * will be returned. + *

+ * + *

+ * In order to display the image in the content preview of the Android share sheet, an URI of + * the content, accessible and readable by other apps has to be generated, so a new file inside + * the application cache will be generated, named {@code android_share_sheet_image_preview.jpg} + * (if a file under this name already exists, it will be overwritten). The thumbnail will be + * compressed in JPEG format, with a {@code 90} compression level. + *

+ * + *

+ * Note that if an exception occurs when generating the {@link ClipData}, {@code null} is + * returned. + *

+ * + *

+ * This method will call {@link PicassoHelper#getImageFromCacheIfPresent(String)} to get the + * thumbnail of the content in the {@link com.squareup.picasso.LruCache LruCache} used by + * the Picasso library inside {@link PicassoHelper}. + *

+ * + *

+ * Using the result of this method when sharing has only an effect on the system share sheet (if + * OEMs didn't change Android system standard behavior) on Android API 29 and higher. + *

+ * + * @param context the context to use + * @param thumbnailUrl the URL of the content thumbnail + * @return a {@link ClipData} of the content thumbnail, or {@code null} + */ + @Nullable + private static ClipData generateClipDataForImagePreview( + @NonNull final Context context, + @NonNull final String thumbnailUrl) { + try { + final Bitmap bitmap = PicassoHelper.getImageFromCacheIfPresent(thumbnailUrl); + if (bitmap == null) { + return null; + } + + // Save the image in memory to the application's cache because we need a URI to the + // image to generate a ClipData which will show the share sheet, and so an image file + final Context applicationContext = context.getApplicationContext(); + final String appFolder = applicationContext.getCacheDir().getAbsolutePath(); + final File thumbnailPreviewFile = new File(appFolder + + "/android_share_sheet_image_preview.jpg"); + + // Any existing file will be overwritten with FileOutputStream + final FileOutputStream fileOutputStream = new FileOutputStream(thumbnailPreviewFile); + bitmap.compress(Bitmap.CompressFormat.JPEG, 90, fileOutputStream); + fileOutputStream.close(); + + final ClipData clipData = ClipData.newUri(applicationContext.getContentResolver(), "", + FileProvider.getUriForFile(applicationContext, + BuildConfig.APPLICATION_ID + ".provider", + thumbnailPreviewFile)); + + if (DEBUG) { + Log.d(TAG, "ClipData successfully generated for Android share sheet: " + clipData); + } + return clipData; + + } catch (final Exception e) { + Log.w(TAG, "Error when setting preview image for share sheet", e); + return null; + } } } diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/TextLinkifier.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/TextLinkifier.java deleted file mode 100644 index 8b8eb265b..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/external_communication/TextLinkifier.java +++ /dev/null @@ -1,289 +0,0 @@ -package org.schabi.newpipe.util.external_communication; - -import android.content.Context; -import android.text.SpannableStringBuilder; -import android.text.method.LinkMovementMethod; -import android.text.style.ClickableSpan; -import android.text.style.URLSpan; -import android.text.util.Linkify; -import android.util.Log; -import android.view.View; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.core.text.HtmlCompat; - -import org.schabi.newpipe.extractor.Info; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.util.NavigationHelper; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import io.noties.markwon.Markwon; -import io.noties.markwon.linkify.LinkifyPlugin; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -import static org.schabi.newpipe.util.external_communication.InternalUrlsHandler.playOnPopup; - -public final class TextLinkifier { - public static final String TAG = TextLinkifier.class.getSimpleName(); - - // 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() { - } - - /** - * Create web links for contents with an HTML description. - *

- * This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence, - * Info, CompositeDisposable)} after having linked the URLs with - * {@link HtmlCompat#fromHtml(String, int)}. - * - * @param textView the TextView to set the htmlBlock linked - * @param htmlBlock the htmlBlock to be linked - * @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, int)} - * will be called - * @param relatedInfo if given, handle timestamps to open the stream in the popup player at - * the specific time, and hashtags to search for the term in the correct - * service - * @param disposables disposables created by the method are added here and their lifecycle - * should be handled by the calling class - */ - public static void createLinksFromHtmlBlock(@NonNull final TextView textView, - final String htmlBlock, - final int htmlCompatFlag, - @Nullable final Info relatedInfo, - final CompositeDisposable disposables) { - changeIntentsOfDescriptionLinks( - textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfo, disposables); - } - - /** - * Create web links for contents with a plain text description. - *

- * This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence, - * Info, CompositeDisposable)} after having linked the URLs with - * {@link TextView#setAutoLinkMask(int)} and - * {@link TextView#setText(CharSequence, TextView.BufferType)}. - * - * @param textView the TextView to set the plain text block linked - * @param plainTextBlock the block of plain text to be linked - * @param relatedInfo if given, handle timestamps to open the stream in the popup player at - * the specific time, and hashtags to search for the term in the correct - * service - * @param disposables disposables created by the method are added here and their lifecycle - * should be handled by the calling class - */ - public static void createLinksFromPlainText(@NonNull final TextView textView, - final String plainTextBlock, - @Nullable final Info relatedInfo, - final CompositeDisposable disposables) { - textView.setAutoLinkMask(Linkify.WEB_URLS); - textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE); - changeIntentsOfDescriptionLinks(textView, textView.getText(), relatedInfo, disposables); - } - - /** - * Create web links for contents with a markdown description. - *

- * This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence, - * Info, CompositeDisposable)} after creating an {@link Markwon} object and using - * {@link Markwon#setMarkdown(TextView, String)}. - * - * @param textView the TextView to set the plain text block linked - * @param markdownBlock the block of markdown text to be linked - * @param relatedInfo if given, handle timestamps to open the stream in the popup player at - * the specific time, and hashtags to search for the term in the correct - * @param disposables disposables created by the method are added here and their lifecycle - * should be handled by the calling class - */ - public static void createLinksFromMarkdownText(@NonNull final TextView textView, - final String markdownBlock, - @Nullable final Info relatedInfo, - final CompositeDisposable disposables) { - final Markwon markwon = Markwon.builder(textView.getContext()) - .usePlugin(LinkifyPlugin.create()).build(); - changeIntentsOfDescriptionLinks(textView, markwon.toMarkdown(markdownBlock), relatedInfo, - disposables); - } - - /** - * Add click listeners which opens a search on hashtags in a plain text. - *

- * This method finds all timestamps in the {@link SpannableStringBuilder} of the description - * using a regular expression, adds for each a {@link ClickableSpan} which opens - * {@link NavigationHelper#openSearch(Context, int, String)} and makes a search on the hashtag, - * in the service of the content. - * - * @param context the context to use - * @param spannableDescription the SpannableStringBuilder with the text of the - * content description - * @param relatedInfo used to search for the term in the correct service - */ - private static void addClickListenersOnHashtags(final Context context, - @NonNull final SpannableStringBuilder - spannableDescription, - final Info relatedInfo) { - final String descriptionText = spannableDescription.toString(); - final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText); - - while (hashtagsMatches.find()) { - final int hashtagStart = hashtagsMatches.start(1); - final int hashtagEnd = hashtagsMatches.end(1); - final String parsedHashtag = descriptionText.substring(hashtagStart, hashtagEnd); - - // don't add a ClickableSpan if there is already one, which should be a part of an URL, - // already parsed before - if (spannableDescription.getSpans(hashtagStart, hashtagEnd, - ClickableSpan.class).length == 0) { - spannableDescription.setSpan(new ClickableSpan() { - @Override - public void onClick(@NonNull final View view) { - NavigationHelper.openSearch(context, relatedInfo.getServiceId(), - parsedHashtag); - } - }, hashtagStart, hashtagEnd, 0); - } - } - } - - /** - * Add click listeners which opens the popup player on timestamps in a plain text. - *

- * This method finds all timestamps in the {@link SpannableStringBuilder} of the description - * using a regular expression, adds for each a {@link ClickableSpan} which opens the popup - * player at the time indicated in the timestamps. - * - * @param context the context to use - * @param spannableDescription the SpannableStringBuilder with the text of the - * content description - * @param relatedInfo what to open in the popup player when timestamps are clicked - * @param disposables disposables created by the method are added here and their - * lifecycle should be handled by the calling class - */ - private static void addClickListenersOnTimestamps(final Context context, - @NonNull final SpannableStringBuilder - spannableDescription, - final Info relatedInfo, - final CompositeDisposable disposables) { - final String descriptionText = spannableDescription.toString(); - final Matcher timestampsMatches = - TimestampExtractor.TIMESTAMPS_PATTERN.matcher(descriptionText); - - while (timestampsMatches.find()) { - final TimestampExtractor.TimestampMatchDTO timestampMatchDTO = - TimestampExtractor.getTimestampFromMatcher( - timestampsMatches, - descriptionText); - - if (timestampMatchDTO == null) { - continue; - } - - spannableDescription.setSpan( - new ClickableSpan() { - @Override - public void onClick(@NonNull final View view) { - playOnPopup( - context, - relatedInfo.getUrl(), - relatedInfo.getService(), - timestampMatchDTO.seconds(), - disposables); - } - }, - timestampMatchDTO.timestampStart(), - timestampMatchDTO.timestampEnd(), - 0); - } - } - - /** - * Change links generated by libraries in the description of a content to a custom link action - * and add click listeners on timestamps in this description. - *

- * Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of - * a content, this method will parse the {@link CharSequence} and replace all current web links - * with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}. - * This method will also add click listeners on timestamps in this description, which will play - * the content in the popup player at the time indicated in the timestamp, by using - * {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder, Info, - * CompositeDisposable)} method and click listeners on hashtags, by using - * {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder, Info)}, - * which will open a search on the current service with the hashtag. - *

- * This method is required in order to intercept links and e.g. show a confirmation dialog - * before opening a web link. - * - * @param textView the TextView in which the converted CharSequence will be applied - * @param chars the CharSequence to be parsed - * @param relatedInfo if given, handle timestamps to open the stream in the popup player at - * the specific time, and hashtags to search for the term in the correct - * service - * @param disposables disposables created by the method are added here and their lifecycle - * should be handled by the calling class - */ - private static void changeIntentsOfDescriptionLinks(final TextView textView, - final CharSequence chars, - @Nullable final Info relatedInfo, - final CompositeDisposable disposables) { - disposables.add(Single.fromCallable(() -> { - final Context context = textView.getContext(); - - // add custom click actions on web links - final SpannableStringBuilder textBlockLinked = new SpannableStringBuilder(chars); - final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), URLSpan.class); - - for (final URLSpan span : urls) { - final String url = span.getURL(); - final ClickableSpan clickableSpan = new ClickableSpan() { - public void onClick(@NonNull final View view) { - if (!InternalUrlsHandler.handleUrlDescriptionTimestamp( - new CompositeDisposable(), context, url)) { - ShareUtils.openUrlInBrowser(context, url, false); - } - } - }; - - textBlockLinked.setSpan(clickableSpan, textBlockLinked.getSpanStart(span), - textBlockLinked.getSpanEnd(span), textBlockLinked.getSpanFlags(span)); - textBlockLinked.removeSpan(span); - } - - // add click actions on plain text timestamps only for description of contents, - // unneeded for meta-info or other TextViews - if (relatedInfo != null) { - if (relatedInfo instanceof StreamInfo) { - addClickListenersOnTimestamps(context, textBlockLinked, relatedInfo, - disposables); - } - addClickListenersOnHashtags(context, textBlockLinked, relatedInfo); - } - - return textBlockLinked; - }).subscribeOn(Schedulers.computation()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - textBlockLinked -> setTextViewCharSequence(textView, textBlockLinked), - throwable -> { - Log.e(TAG, "Unable to linkify text", throwable); - // this should never happen, but if it does, just fallback to it - setTextViewCharSequence(textView, chars); - })); - } - - private static void setTextViewCharSequence(@NonNull final TextView textView, - final CharSequence charSequence) { - textView.setText(charSequence); - textView.setMovementMethod(LinkMovementMethod.getInstance()); - textView.setVisibility(View.VISIBLE); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.java b/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.java new file mode 100644 index 000000000..da97179b6 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/image/ImageStrategy.java @@ -0,0 +1,195 @@ +package org.schabi.newpipe.util.image; + +import static org.schabi.newpipe.extractor.Image.HEIGHT_UNKNOWN; +import static org.schabi.newpipe.extractor.Image.WIDTH_UNKNOWN; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import org.schabi.newpipe.extractor.Image; + +import java.util.Comparator; +import java.util.List; + +public final class ImageStrategy { + + // when preferredImageQuality is LOW or MEDIUM, images are sorted by how close their preferred + // image quality is to these values (H stands for "Height") + private static final int BEST_LOW_H = 75; + private static final int BEST_MEDIUM_H = 250; + + private static PreferredImageQuality preferredImageQuality = PreferredImageQuality.MEDIUM; + + private ImageStrategy() { + } + + public static void setPreferredImageQuality(final PreferredImageQuality preferredImageQuality) { + ImageStrategy.preferredImageQuality = preferredImageQuality; + } + + public static boolean shouldLoadImages() { + return preferredImageQuality != PreferredImageQuality.NONE; + } + + + static double estimatePixelCount(final Image image, final double widthOverHeight) { + if (image.getHeight() == HEIGHT_UNKNOWN) { + if (image.getWidth() == WIDTH_UNKNOWN) { + // images whose size is completely unknown will be in their own subgroups, so + // any one of them will do, hence returning the same value for all of them + return 0; + } else { + return image.getWidth() * image.getWidth() / widthOverHeight; + } + } else if (image.getWidth() == WIDTH_UNKNOWN) { + return image.getHeight() * image.getHeight() * widthOverHeight; + } else { + return image.getHeight() * image.getWidth(); + } + } + + /** + * {@link #choosePreferredImage(List)} contains the description for this function's logic. + * + * @param images the images from which to choose + * @param nonNoneQuality the preferred quality (must NOT be {@link PreferredImageQuality#NONE}) + * @return the chosen preferred image, or {@link null} if the list is empty + * @see #choosePreferredImage(List) + */ + @Nullable + static String choosePreferredImage(@NonNull final List images, + final PreferredImageQuality nonNoneQuality) { + // this will be used to estimate the pixel count for images where only one of height or + // width are known + final double widthOverHeight = images.stream() + .filter(image -> image.getHeight() != HEIGHT_UNKNOWN + && image.getWidth() != WIDTH_UNKNOWN) + .mapToDouble(image -> ((double) image.getWidth()) / image.getHeight()) + .findFirst() + .orElse(1.0); + + final Image.ResolutionLevel preferredLevel = nonNoneQuality.toResolutionLevel(); + final Comparator initialComparator = Comparator + // the first step splits the images into groups of resolution levels + .comparingInt(i -> { + if (i.getEstimatedResolutionLevel() == Image.ResolutionLevel.UNKNOWN) { + return 3; // avoid unknowns as much as possible + } else if (i.getEstimatedResolutionLevel() == preferredLevel) { + return 0; // prefer a matching resolution level + } else if (i.getEstimatedResolutionLevel() == Image.ResolutionLevel.MEDIUM) { + return 1; // the preferredLevel is only 1 "step" away (either HIGH or LOW) + } else { + return 2; // the preferredLevel is the furthest away possible (2 "steps") + } + }) + // then each level's group is further split into two subgroups, one with known image + // size (which is also the preferred subgroup) and the other without + .thenComparing(image -> + image.getHeight() == HEIGHT_UNKNOWN && image.getWidth() == WIDTH_UNKNOWN); + + // The third step chooses, within each subgroup with known image size, the best image based + // on how close its size is to BEST_LOW_H or BEST_MEDIUM_H (with proper units). Subgroups + // without known image size will be left untouched since estimatePixelCount always returns + // the same number for those. + final Comparator finalComparator = switch (nonNoneQuality) { + case NONE -> initialComparator; // unreachable + case LOW -> initialComparator.thenComparingDouble(image -> { + final double pixelCount = estimatePixelCount(image, widthOverHeight); + return Math.abs(pixelCount - BEST_LOW_H * BEST_LOW_H * widthOverHeight); + }); + case MEDIUM -> initialComparator.thenComparingDouble(image -> { + final double pixelCount = estimatePixelCount(image, widthOverHeight); + return Math.abs(pixelCount - BEST_MEDIUM_H * BEST_MEDIUM_H * widthOverHeight); + }); + case HIGH -> initialComparator.thenComparingDouble( + // this is reversed with a - so that the highest resolution is chosen + i -> -estimatePixelCount(i, widthOverHeight)); + }; + + return images.stream() + // using "min" basically means "take the first group, then take the first subgroup, + // then choose the best image, while ignoring all other groups and subgroups" + .min(finalComparator) + .map(Image::getUrl) + .orElse(null); + } + + /** + * Chooses an image amongst the provided list based on the user preference previously set with + * {@link #setPreferredImageQuality(PreferredImageQuality)}. {@code null} will be returned in + * case the list is empty or the user preference is to not show images. + *
+ * These properties will be preferred, from most to least important: + *

    + *
  1. The image's {@link Image#getEstimatedResolutionLevel()} is not unknown and is close + * to {@link #preferredImageQuality}
  2. + *
  3. At least one of the image's width or height are known
  4. + *
  5. The highest resolution image is finally chosen if the user's preference is {@link + * PreferredImageQuality#HIGH}, otherwise the chosen image is the one that has the height + * closest to {@link #BEST_LOW_H} or {@link #BEST_MEDIUM_H}
  6. + *
+ *
+ * Use {@link #imageListToDbUrl(List)} if the URL is going to be saved to the database, to avoid + * saving nothing in case at the moment of saving the user preference is to not show images. + * + * @param images the images from which to choose + * @return the chosen preferred image, or {@link null} if the list is empty or the user disabled + * images + * @see #imageListToDbUrl(List) + */ + @Nullable + public static String choosePreferredImage(@NonNull final List images) { + if (preferredImageQuality == PreferredImageQuality.NONE) { + return null; // do not load images + } + + return choosePreferredImage(images, preferredImageQuality); + } + + /** + * Like {@link #choosePreferredImage(List)}, except that if {@link #preferredImageQuality} is + * {@link PreferredImageQuality#NONE} an image will be chosen anyway (with preferred quality + * {@link PreferredImageQuality#MEDIUM}. + *
+ * To go back to a list of images (obviously with just the one chosen image) from a URL saved in + * the database use {@link #dbUrlToImageList(String)}. + * + * @param images the images from which to choose + * @return the chosen preferred image, or {@link null} if the list is empty + * @see #choosePreferredImage(List) + * @see #dbUrlToImageList(String) + */ + @Nullable + public static String imageListToDbUrl(@NonNull final List images) { + final PreferredImageQuality quality; + if (preferredImageQuality == PreferredImageQuality.NONE) { + quality = PreferredImageQuality.MEDIUM; + } else { + quality = preferredImageQuality; + } + + return choosePreferredImage(images, quality); + } + + /** + * Wraps the URL (coming from the database) in a {@code List} so that it is usable + * seamlessly in all of the places where the extractor would return a list of images, including + * allowing to build info objects based on database objects. + *
+ * To obtain a url to save to the database from a list of images use {@link + * #imageListToDbUrl(List)}. + * + * @param url the URL to wrap coming from the database, or {@code null} to get an empty list + * @return a list containing just one {@link Image} wrapping the provided URL, with unknown + * image size fields, or an empty list if the URL is {@code null} + * @see #imageListToDbUrl(List) + */ + @NonNull + public static List dbUrlToImageList(@Nullable final String url) { + if (url == null) { + return List.of(); + } else { + return List.of(new Image(url, -1, -1, Image.ResolutionLevel.UNKNOWN)); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/PicassoHelper.java b/app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.java similarity index 51% rename from app/src/main/java/org/schabi/newpipe/util/PicassoHelper.java rename to app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.java index da86ab1a4..4b116bdf9 100644 --- a/app/src/main/java/org/schabi/newpipe/util/PicassoHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/image/PicassoHelper.java @@ -1,33 +1,40 @@ -package org.schabi.newpipe.util; +package org.schabi.newpipe.util.image; -import static org.schabi.newpipe.extractor.utils.Utils.isBlank; +import static org.schabi.newpipe.MainActivity.DEBUG; +import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; +import static org.schabi.newpipe.util.image.ImageStrategy.choosePreferredImage; import android.annotation.SuppressLint; import android.content.Context; import android.graphics.Bitmap; -import android.graphics.drawable.Drawable; +import android.util.Log; + +import androidx.annotation.DrawableRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.graphics.BitmapCompat; import com.squareup.picasso.Cache; import com.squareup.picasso.LruCache; import com.squareup.picasso.OkHttp3Downloader; import com.squareup.picasso.Picasso; import com.squareup.picasso.RequestCreator; -import com.squareup.picasso.Target; import com.squareup.picasso.Transformation; import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.Image; import java.io.File; import java.io.IOException; +import java.util.List; import java.util.concurrent.TimeUnit; -import java.util.function.Consumer; import okhttp3.OkHttpClient; public final class PicassoHelper { - public static final String PLAYER_THUMBNAIL_TAG = "PICASSO_PLAYER_THUMBNAIL_TAG"; - private static final String PLAYER_THUMBNAIL_TRANSFORMATION_KEY - = "PICASSO_PLAYER_THUMBNAIL_TRANSFORMATION_KEY"; + private static final String TAG = PicassoHelper.class.getSimpleName(); + private static final String PLAYER_THUMBNAIL_TRANSFORMATION_KEY = + "PICASSO_PLAYER_THUMBNAIL_TRANSFORMATION_KEY"; private PicassoHelper() { } @@ -39,13 +46,12 @@ public final class PicassoHelper { @SuppressLint("StaticFieldLeak") private static Picasso picassoInstance; - private static boolean shouldLoadImages; public static void init(final Context context) { picassoCache = new LruCache(10 * 1024 * 1024); picassoDownloaderClient = new OkHttpClient.Builder() .cache(new okhttp3.Cache(new File(context.getExternalCacheDir(), "picasso"), - 50 * 1024 * 1024)) + 50L * 1024L * 1024L)) // this should already be the default timeout in OkHttp3, but just to be sure... .callTimeout(15, TimeUnit.SECONDS) .build(); @@ -85,63 +91,82 @@ public final class PicassoHelper { picassoInstance.setIndicatorsEnabled(enabled); // useful for debugging } - public static void setShouldLoadImages(final boolean shouldLoadImages) { - PicassoHelper.shouldLoadImages = shouldLoadImages; + + public static RequestCreator loadAvatar(@NonNull final List images) { + return loadImageDefault(images, R.drawable.placeholder_person); } - public static boolean getShouldLoadImages() { - return shouldLoadImages; + public static RequestCreator loadAvatar(@Nullable final String url) { + return loadImageDefault(url, R.drawable.placeholder_person); } - - public static RequestCreator loadAvatar(final String url) { - return loadImageDefault(url, R.drawable.buddy); + public static RequestCreator loadThumbnail(@NonNull final List images) { + return loadImageDefault(images, R.drawable.placeholder_thumbnail_video); } - public static RequestCreator loadThumbnail(final String url) { - return loadImageDefault(url, R.drawable.dummy_thumbnail); + public static RequestCreator loadThumbnail(@Nullable final String url) { + return loadImageDefault(url, R.drawable.placeholder_thumbnail_video); } - public static RequestCreator loadBanner(final String url) { - return loadImageDefault(url, R.drawable.channel_banner); + public static RequestCreator loadDetailsThumbnail(@NonNull final List images) { + return loadImageDefault(choosePreferredImage(images), + R.drawable.placeholder_thumbnail_video, false); } - public static RequestCreator loadPlaylistThumbnail(final String url) { - return loadImageDefault(url, R.drawable.dummy_thumbnail_playlist); + public static RequestCreator loadBanner(@NonNull final List images) { + return loadImageDefault(images, R.drawable.placeholder_channel_banner); } - public static RequestCreator loadSeekbarThumbnailPreview(final String url) { + public static RequestCreator loadPlaylistThumbnail(@NonNull final List images) { + return loadImageDefault(images, R.drawable.placeholder_thumbnail_playlist); + } + + public static RequestCreator loadPlaylistThumbnail(@Nullable final String url) { + return loadImageDefault(url, R.drawable.placeholder_thumbnail_playlist); + } + + public static RequestCreator loadSeekbarThumbnailPreview(@Nullable final String url) { return picassoInstance.load(url); } + public static RequestCreator loadNotificationIcon(@Nullable final String url) { + return loadImageDefault(url, R.drawable.ic_newpipe_triangle_white); + } - public static RequestCreator loadScaledDownThumbnail(final Context context, final String url) { + + public static RequestCreator loadScaledDownThumbnail(final Context context, + @NonNull final List images) { // scale down the notification thumbnail for performance - return PicassoHelper.loadThumbnail(url) - .tag(PLAYER_THUMBNAIL_TAG) + return PicassoHelper.loadThumbnail(images) .transform(new Transformation() { @Override public Bitmap transform(final Bitmap source) { + if (DEBUG) { + Log.d(TAG, "Thumbnail - transform() called"); + } + final float notificationThumbnailWidth = Math.min( context.getResources() .getDimension(R.dimen.player_notification_thumbnail_width), source.getWidth()); - final Bitmap result = Bitmap.createScaledBitmap( + final Bitmap result = BitmapCompat.createScaledBitmap( source, (int) notificationThumbnailWidth, (int) (source.getHeight() / (source.getWidth() / notificationThumbnailWidth)), + null, true); - if (result == source) { + if (result == source || !result.isMutable()) { // create a new mutable bitmap to prevent strange crashes on some // devices (see #4638) - final Bitmap copied = Bitmap.createScaledBitmap( + final Bitmap copied = BitmapCompat.createScaledBitmap( source, (int) notificationThumbnailWidth - 1, (int) (source.getHeight() / (source.getWidth() / (notificationThumbnailWidth - 1))), + null, true); source.recycle(); return copied; @@ -158,39 +183,42 @@ public final class PicassoHelper { }); } - - public static void loadNotificationIcon(final String url, - final Consumer bitmapConsumer) { - loadImageDefault(url, R.drawable.ic_newpipe_triangle_white) - .into(new Target() { - @Override - public void onBitmapLoaded(final Bitmap bitmap, final Picasso.LoadedFrom from) { - bitmapConsumer.accept(bitmap); - } - - @Override - public void onBitmapFailed(final Exception e, final Drawable errorDrawable) { - bitmapConsumer.accept(null); - } - - @Override - public void onPrepareLoad(final Drawable placeHolderDrawable) { - // Nothing to do - } - }); + @Nullable + public static Bitmap getImageFromCacheIfPresent(@NonNull final String imageUrl) { + // URLs in the internal cache finish with \n so we need to add \n to image URLs + return picassoCache.get(imageUrl + "\n"); } - private static RequestCreator loadImageDefault(final String url, final int placeholderResId) { - if (!shouldLoadImages || isBlank(url)) { + private static RequestCreator loadImageDefault(@NonNull final List images, + @DrawableRes final int placeholderResId) { + return loadImageDefault(choosePreferredImage(images), placeholderResId); + } + + private static RequestCreator loadImageDefault(@Nullable final String url, + @DrawableRes final int placeholderResId) { + return loadImageDefault(url, placeholderResId, true); + } + + private static RequestCreator loadImageDefault(@Nullable final String url, + @DrawableRes final int placeholderResId, + final boolean showPlaceholderWhileLoading) { + // if the URL was chosen with `choosePreferredImage` it will be null, but check again + // `shouldLoadImages` in case the URL was chosen with `imageListToDbUrl` (which is the case + // for URLs stored in the database) + if (isNullOrEmpty(url) || !ImageStrategy.shouldLoadImages()) { return picassoInstance .load((String) null) .placeholder(placeholderResId) // show placeholder when no image should load .error(placeholderResId); } else { - return picassoInstance + final RequestCreator requestCreator = picassoInstance .load(url) - .error(placeholderResId); // don't show placeholder while loading, only on error + .error(placeholderResId); + if (showPlaceholderWhileLoading) { + requestCreator.placeholder(placeholderResId); + } + return requestCreator; } } } diff --git a/app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.java b/app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.java new file mode 100644 index 000000000..7106359b3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/image/PreferredImageQuality.java @@ -0,0 +1,39 @@ +package org.schabi.newpipe.util.image; + +import android.content.Context; + +import org.schabi.newpipe.R; +import org.schabi.newpipe.extractor.Image; + +public enum PreferredImageQuality { + NONE, + LOW, + MEDIUM, + HIGH; + + public static PreferredImageQuality fromPreferenceKey(final Context context, final String key) { + if (context.getString(R.string.image_quality_none_key).equals(key)) { + return NONE; + } else if (context.getString(R.string.image_quality_low_key).equals(key)) { + return LOW; + } else if (context.getString(R.string.image_quality_high_key).equals(key)) { + return HIGH; + } else { + return MEDIUM; // default to medium + } + } + + public Image.ResolutionLevel toResolutionLevel() { + switch (this) { + case LOW: + return Image.ResolutionLevel.LOW; + case MEDIUM: + return Image.ResolutionLevel.MEDIUM; + case HIGH: + return Image.ResolutionLevel.HIGH; + default: + case NONE: + return Image.ResolutionLevel.UNKNOWN; + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/CommentTextOnTouchListener.java b/app/src/main/java/org/schabi/newpipe/util/text/CommentTextOnTouchListener.java new file mode 100644 index 000000000..5018a6120 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/CommentTextOnTouchListener.java @@ -0,0 +1,42 @@ +package org.schabi.newpipe.util.text; + +import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine; + +import android.annotation.SuppressLint; +import android.text.Spanned; +import android.text.style.ClickableSpan; +import android.view.MotionEvent; +import android.view.View; +import android.widget.TextView; + +public class CommentTextOnTouchListener implements View.OnTouchListener { + public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener(); + + @SuppressLint("ClickableViewAccessibility") + @Override + public boolean onTouch(final View v, final MotionEvent event) { + if (!(v instanceof TextView)) { + return false; + } + final TextView widget = (TextView) v; + final CharSequence text = widget.getText(); + if (text instanceof Spanned) { + final Spanned buffer = (Spanned) text; + final int action = event.getAction(); + + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { + final int offset = getOffsetForHorizontalLine(widget, event); + final ClickableSpan[] links = buffer.getSpans(offset, offset, ClickableSpan.class); + + if (links.length != 0) { + if (action == MotionEvent.ACTION_UP) { + links[0].onClick(widget); + } + // we handle events that intersect links, so return true + return true; + } + } + } + return false; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.java new file mode 100644 index 000000000..8a0363ecb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/HashtagLongPressClickableSpan.java @@ -0,0 +1,36 @@ +package org.schabi.newpipe.util.text; + +import android.content.Context; +import android.view.View; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.external_communication.ShareUtils; + +final class HashtagLongPressClickableSpan extends LongPressClickableSpan { + + @NonNull + private final Context context; + @NonNull + private final String parsedHashtag; + private final int relatedInfoServiceId; + + HashtagLongPressClickableSpan(@NonNull final Context context, + @NonNull final String parsedHashtag, + final int relatedInfoServiceId) { + this.context = context; + this.parsedHashtag = parsedHashtag; + this.relatedInfoServiceId = relatedInfoServiceId; + } + + @Override + public void onClick(@NonNull final View view) { + NavigationHelper.openSearch(context, relatedInfoServiceId, parsedHashtag); + } + + @Override + public void onLongClick(@NonNull final View view) { + ShareUtils.copyToClipboard(context, parsedHashtag); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/InternalUrlsHandler.java b/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java similarity index 95% rename from app/src/main/java/org/schabi/newpipe/util/external_communication/InternalUrlsHandler.java rename to app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java index 240341ab0..066515d6b 100644 --- a/app/src/main/java/org/schabi/newpipe/util/external_communication/InternalUrlsHandler.java +++ b/app/src/main/java/org/schabi/newpipe/util/text/InternalUrlsHandler.java @@ -1,4 +1,4 @@ -package org.schabi.newpipe.util.external_communication; +package org.schabi.newpipe.util.text; import android.content.Context; import android.util.Log; @@ -153,13 +153,13 @@ public final class InternalUrlsHandler { return false; } - final Single single - = ExtractorHelper.getStreamInfo(service.getServiceId(), cleanUrl, false); + final Single single = + ExtractorHelper.getStreamInfo(service.getServiceId(), cleanUrl, false); disposables.add(single.subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(info -> { - final PlayQueue playQueue - = new SinglePlayQueue(info, seconds * 1000L); + final PlayQueue playQueue = + new SinglePlayQueue(info, seconds * 1000L); NavigationHelper.playOnPopupPlayer(context, playQueue, false); }, throwable -> { if (DEBUG) { @@ -169,7 +169,7 @@ public final class InternalUrlsHandler { .setTitle(R.string.player_stream_failure) .setMessage( ErrorPanelHelper.Companion.getExceptionDescription(throwable)) - .setPositiveButton(R.string.ok, (v, b) -> { }) + .setPositiveButton(R.string.ok, null) .show(); })); return true; diff --git a/app/src/main/java/org/schabi/newpipe/util/text/LongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/LongPressClickableSpan.java new file mode 100644 index 000000000..5c94a5850 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/LongPressClickableSpan.java @@ -0,0 +1,12 @@ +package org.schabi.newpipe.util.text; + +import android.text.style.ClickableSpan; +import android.view.View; + +import androidx.annotation.NonNull; + +public abstract class LongPressClickableSpan extends ClickableSpan { + + public abstract void onLongClick(@NonNull View view); + +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/LongPressLinkMovementMethod.java b/app/src/main/java/org/schabi/newpipe/util/text/LongPressLinkMovementMethod.java new file mode 100644 index 000000000..bd57621cb --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/LongPressLinkMovementMethod.java @@ -0,0 +1,77 @@ +package org.schabi.newpipe.util.text; + +import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine; + +import android.os.Handler; +import android.os.Looper; +import android.text.Selection; +import android.text.Spannable; +import android.text.method.LinkMovementMethod; +import android.text.method.MovementMethod; +import android.view.MotionEvent; +import android.view.ViewConfiguration; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +// Class adapted from https://stackoverflow.com/a/31786969 + +public class LongPressLinkMovementMethod extends LinkMovementMethod { + + private static final int LONG_PRESS_TIME = ViewConfiguration.getLongPressTimeout(); + + private static LongPressLinkMovementMethod instance; + + private Handler longClickHandler; + private boolean isLongPressed = false; + + @Override + public boolean onTouchEvent(@NonNull final TextView widget, + @NonNull final Spannable buffer, + @NonNull final MotionEvent event) { + final int action = event.getAction(); + + if (action == MotionEvent.ACTION_CANCEL && longClickHandler != null) { + longClickHandler.removeCallbacksAndMessages(null); + } + + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) { + final int offset = getOffsetForHorizontalLine(widget, event); + final LongPressClickableSpan[] link = buffer.getSpans(offset, offset, + LongPressClickableSpan.class); + + if (link.length != 0) { + if (action == MotionEvent.ACTION_UP) { + if (longClickHandler != null) { + longClickHandler.removeCallbacksAndMessages(null); + } + if (!isLongPressed) { + link[0].onClick(widget); + } + isLongPressed = false; + } else { + Selection.setSelection(buffer, buffer.getSpanStart(link[0]), + buffer.getSpanEnd(link[0])); + if (longClickHandler != null) { + longClickHandler.postDelayed(() -> { + link[0].onLongClick(widget); + isLongPressed = true; + }, LONG_PRESS_TIME); + } + } + return true; + } + } + + return super.onTouchEvent(widget, buffer, event); + } + + public static MovementMethod getInstance() { + if (instance == null) { + instance = new LongPressLinkMovementMethod(); + instance.longClickHandler = new Handler(Looper.myLooper()); + } + + return instance; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java b/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java new file mode 100644 index 000000000..184b73304 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/TextEllipsizer.java @@ -0,0 +1,193 @@ +package org.schabi.newpipe.util.text; + +import android.graphics.Paint; +import android.text.Layout; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.text.HtmlCompat; + +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.stream.Description; + +import java.util.function.Consumer; + + +import io.reactivex.rxjava3.disposables.CompositeDisposable; + +/** + *

Class to ellipsize text inside a {@link TextView}.

+ * This class provides all utils to automatically ellipsize and expand a text + */ +public final class TextEllipsizer { + private static final int EXPANDED_LINES = Integer.MAX_VALUE; + private static final String ELLIPSIS = "…"; + + @NonNull private final CompositeDisposable disposable = new CompositeDisposable(); + + @NonNull private final TextView view; + private final int maxLines; + @NonNull private Description content; + @Nullable private StreamingService streamingService; + @Nullable private String streamUrl; + private boolean isEllipsized = false; + @Nullable private Boolean canBeEllipsized = null; + + @NonNull private final Paint paintAtContentSize = new Paint(); + private final float ellipsisWidthPx; + @Nullable private Consumer stateChangeListener = null; + @Nullable private Consumer onContentChanged; + + public TextEllipsizer(@NonNull final TextView view, + final int maxLines, + @Nullable final StreamingService streamingService) { + this.view = view; + this.maxLines = maxLines; + this.content = Description.EMPTY_DESCRIPTION; + this.streamingService = streamingService; + + paintAtContentSize.setTextSize(view.getTextSize()); + ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS); + } + + public void setOnContentChanged(@Nullable final Consumer onContentChanged) { + this.onContentChanged = onContentChanged; + } + + public void setContent(@NonNull final Description content) { + this.content = content; + canBeEllipsized = null; + linkifyContentView(v -> { + final int currentMaxLines = view.getMaxLines(); + view.setMaxLines(EXPANDED_LINES); + canBeEllipsized = view.getLineCount() > maxLines; + view.setMaxLines(currentMaxLines); + if (onContentChanged != null) { + onContentChanged.accept(canBeEllipsized); + } + }); + } + + public void setStreamUrl(@Nullable final String streamUrl) { + this.streamUrl = streamUrl; + } + + public void setStreamingService(@NonNull final StreamingService streamingService) { + this.streamingService = streamingService; + } + + /** + * Expand the {@link TextEllipsizer#content} to its full length. + */ + public void expand() { + view.setMaxLines(EXPANDED_LINES); + linkifyContentView(v -> isEllipsized = false); + } + + /** + * Shorten the {@link TextEllipsizer#content} to the given number of + * {@link TextEllipsizer#maxLines maximum lines} and add trailing '{@code …}' + * if the text was shorted. + */ + public void ellipsize() { + // expand text to see whether it is necessary to ellipsize the text + view.setMaxLines(EXPANDED_LINES); + linkifyContentView(v -> { + final CharSequence charSeqText = view.getText(); + if (charSeqText != null && view.getLineCount() > maxLines) { + // Note that converting to String removes spans (i.e. links), but that's something + // we actually want since when the text is ellipsized we want all clicks on the + // comment to expand the comment, not to open links. + final String text = charSeqText.toString(); + + final Layout layout = view.getLayout(); + final float lineWidth = layout.getLineWidth(maxLines - 1); + final float layoutWidth = layout.getWidth(); + final int lineStart = layout.getLineStart(maxLines - 1); + final int lineEnd = layout.getLineEnd(maxLines - 1); + + // remove characters up until there is enough space for the ellipsis + // (also summing 2 more pixels, just to be sure to avoid float rounding errors) + int end = lineEnd; + float removedCharactersWidth = 0.0f; + while (lineWidth - removedCharactersWidth + ellipsisWidthPx + 2.0f > layoutWidth + && end >= lineStart) { + end -= 1; + // recalculate each time to account for ligatures or other similar things + removedCharactersWidth = paintAtContentSize.measureText( + text.substring(end, lineEnd)); + } + + // remove trailing spaces and newlines + while (end > 0 && Character.isWhitespace(text.charAt(end - 1))) { + end -= 1; + } + + final String newVal = text.substring(0, end) + ELLIPSIS; + view.setText(newVal); + isEllipsized = true; + } else { + isEllipsized = false; + } + view.setMaxLines(maxLines); + }); + } + + /** + * Toggle the view between the ellipsized and expanded state. + */ + public void toggle() { + if (isEllipsized) { + expand(); + } else { + ellipsize(); + } + } + + /** + * Whether the {@link #view} can be ellipsized. + * This is only the case when the {@link #content} has more lines + * than allowed via {@link #maxLines}. + * @return {@code true} if the {@link #content} has more lines than allowed via + * {@link #maxLines} and thus can be shortened, {@code false} if the {@code content} fits into + * the {@link #view} without being shortened and {@code null} if the initialization is not + * completed yet. + */ + @Nullable + public Boolean canBeEllipsized() { + return canBeEllipsized; + } + + private void linkifyContentView(final Consumer consumer) { + final boolean oldState = isEllipsized; + disposable.clear(); + TextLinkifier.fromDescription(view, content, + HtmlCompat.FROM_HTML_MODE_LEGACY, streamingService, streamUrl, disposable, + v -> { + consumer.accept(v); + notifyStateChangeListener(oldState); + }); + + } + + /** + * Add a listener which is called when the given content is changed, + * either from ellipsized to full or vice versa. + * @param listener The listener to be called, or {@code null} to remove it. + * The Boolean parameter is the new state. + * Ellipsized content is represented as {@code true}, + * normal or full content by {@code false}. + */ + public void setStateChangeListener(@Nullable final Consumer listener) { + this.stateChangeListener = listener; + } + + private void notifyStateChangeListener(final boolean oldState) { + if (oldState != isEllipsized && stateChangeListener != null) { + stateChangeListener.accept(isEllipsized); + } + } + +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java b/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java new file mode 100644 index 000000000..1419ac85a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/TextLinkifier.java @@ -0,0 +1,369 @@ +package org.schabi.newpipe.util.text; + +import android.content.Context; +import android.text.SpannableStringBuilder; +import android.text.style.URLSpan; +import android.text.util.Linkify; +import android.util.Log; +import android.view.View; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.core.text.HtmlCompat; + +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.stream.Description; +import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.external_communication.ShareUtils; + +import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.noties.markwon.Markwon; +import io.noties.markwon.linkify.LinkifyPlugin; +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Single; +import io.reactivex.rxjava3.disposables.CompositeDisposable; +import io.reactivex.rxjava3.schedulers.Schedulers; + +public final class TextLinkifier { + public static final String TAG = TextLinkifier.class.getSimpleName(); + + // 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_]+)"); + + public static final Consumer SET_LINK_MOVEMENT_METHOD = + v -> v.setMovementMethod(LongPressLinkMovementMethod.getInstance()); + + private TextLinkifier() { + } + + /** + * Create links for contents with an {@link Description} in the various possible formats. + *

+ * This will call one of these three functions based on the format: {@link #fromHtml}, + * {@link #fromMarkdown} or {@link #fromPlainText}. + * + * @param textView the TextView to set the htmlBlock linked + * @param description the htmlBlock to be linked + * @param htmlCompatFlag the int flag to be set if {@link HtmlCompat#fromHtml(String, int)} + * will be called (not used for formats different than HTML) + * @param relatedInfoService if given, handle hashtags to search for the term in the correct + * service + * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle + * timestamps to open the stream in the popup player at the specific + * time + * @param disposables disposables created by the method are added here and their + * lifecycle should be handled by the calling class + * @param onCompletion will be run when setting text to the textView completes; use {@link + * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable + */ + public static void fromDescription(@NonNull final TextView textView, + @NonNull final Description description, + final int htmlCompatFlag, + @Nullable final StreamingService relatedInfoService, + @Nullable final String relatedStreamUrl, + @NonNull final CompositeDisposable disposables, + @Nullable final Consumer onCompletion) { + switch (description.getType()) { + case Description.HTML: + TextLinkifier.fromHtml(textView, description.getContent(), htmlCompatFlag, + relatedInfoService, relatedStreamUrl, disposables, onCompletion); + break; + case Description.MARKDOWN: + TextLinkifier.fromMarkdown(textView, description.getContent(), + relatedInfoService, relatedStreamUrl, disposables, onCompletion); + break; + case Description.PLAIN_TEXT: default: + TextLinkifier.fromPlainText(textView, description.getContent(), + relatedInfoService, relatedStreamUrl, disposables, onCompletion); + break; + } + } + + /** + * Create links for contents with an HTML description. + * + *

+ * This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService, + * String, CompositeDisposable, Consumer)} after having linked the URLs with + * {@link HtmlCompat#fromHtml(String, int)}. + *

+ * + * @param textView the {@link TextView} to set the HTML string block linked + * @param htmlBlock the HTML string block to be linked + * @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, + * int)} will be called + * @param relatedInfoService if given, handle hashtags to search for the term in the correct + * service + * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle + * timestamps to open the stream in the popup player at the specific + * time + * @param disposables disposables created by the method are added here and their + * lifecycle should be handled by the calling class + * @param onCompletion will be run when setting text to the textView completes; use {@link + * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable + */ + public static void fromHtml(@NonNull final TextView textView, + @NonNull final String htmlBlock, + final int htmlCompatFlag, + @Nullable final StreamingService relatedInfoService, + @Nullable final String relatedStreamUrl, + @NonNull final CompositeDisposable disposables, + @Nullable final Consumer onCompletion) { + changeLinkIntents( + textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfoService, + relatedStreamUrl, disposables, onCompletion); + } + + /** + * Create links for contents with a plain text description. + * + *

+ * This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService, + * String, CompositeDisposable, Consumer)} after having linked the URLs with + * {@link TextView#setAutoLinkMask(int)} and + * {@link TextView#setText(CharSequence, TextView.BufferType)}. + *

+ * + * @param textView the {@link TextView} to set the plain text block linked + * @param plainTextBlock the block of plain text to be linked + * @param relatedInfoService if given, handle hashtags to search for the term in the correct + * service + * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle + * timestamps to open the stream in the popup player at the specific + * time + * @param disposables disposables created by the method are added here and their + * lifecycle should be handled by the calling class + * @param onCompletion will be run when setting text to the textView completes; use {@link + * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable + */ + public static void fromPlainText(@NonNull final TextView textView, + @NonNull final String plainTextBlock, + @Nullable final StreamingService relatedInfoService, + @Nullable final String relatedStreamUrl, + @NonNull final CompositeDisposable disposables, + @Nullable final Consumer onCompletion) { + textView.setAutoLinkMask(Linkify.WEB_URLS); + textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE); + changeLinkIntents(textView, textView.getText(), relatedInfoService, + relatedStreamUrl, disposables, onCompletion); + } + + /** + * Create links for contents with a markdown description. + * + *

+ * This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService, + * String, CompositeDisposable, Consumer)} after creating a {@link Markwon} object and using + * {@link Markwon#setMarkdown(TextView, String)}. + *

+ * + * @param textView the {@link TextView} to set the plain text block linked + * @param markdownBlock the block of markdown text to be linked + * @param relatedInfoService if given, handle hashtags to search for the term in the correct + * service + * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle + * timestamps to open the stream in the popup player at the specific + * time + * @param disposables disposables created by the method are added here and their + * lifecycle should be handled by the calling class + * @param onCompletion will be run when setting text to the textView completes; use {@link + * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable + */ + public static void fromMarkdown(@NonNull final TextView textView, + @NonNull final String markdownBlock, + @Nullable final StreamingService relatedInfoService, + @Nullable final String relatedStreamUrl, + @NonNull final CompositeDisposable disposables, + @Nullable final Consumer onCompletion) { + final Markwon markwon = Markwon.builder(textView.getContext()) + .usePlugin(LinkifyPlugin.create()).build(); + changeLinkIntents(textView, markwon.toMarkdown(markdownBlock), + relatedInfoService, relatedStreamUrl, disposables, onCompletion); + } + + /** + * Change links generated by libraries in the description of a content to a custom link action + * and add click listeners on timestamps in this description. + * + *

+ * Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of + * a content, this method will parse the {@link CharSequence} and replace all current web links + * with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}. + *

+ * + *

+ * This method will also add click listeners on timestamps in this description, which will play + * the content in the popup player at the time indicated in the timestamp, by using + * {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder, + * StreamingService, String, CompositeDisposable)} method and click listeners on hashtags, by + * using {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder, + * StreamingService)}, which will open a search on the current service with the hashtag. + *

+ * + *

+ * This method is required in order to intercept links and e.g. show a confirmation dialog + * before opening a web link. + *

+ * + * @param textView the {@link TextView} to which the converted {@link CharSequence} + * will be applied + * @param chars the {@link CharSequence} to be parsed + * @param relatedInfoService if given, handle hashtags to search for the term in the correct + * service + * @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle + * timestamps to open the stream in the popup player at the specific + * time + * @param disposables disposables created by the method are added here and their + * lifecycle should be handled by the calling class + * @param onCompletion will be run when setting text to the textView completes; use {@link + * #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable + */ + private static void changeLinkIntents(@NonNull final TextView textView, + @NonNull final CharSequence chars, + @Nullable final StreamingService relatedInfoService, + @Nullable final String relatedStreamUrl, + @NonNull final CompositeDisposable disposables, + @Nullable final Consumer onCompletion) { + disposables.add(Single.fromCallable(() -> { + final Context context = textView.getContext(); + + // add custom click actions on web links + final SpannableStringBuilder textBlockLinked = + new SpannableStringBuilder(chars); + final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), + URLSpan.class); + + for (final URLSpan span : urls) { + final String url = span.getURL(); + final LongPressClickableSpan longPressClickableSpan = + new UrlLongPressClickableSpan(context, disposables, url); + + textBlockLinked.setSpan(longPressClickableSpan, + textBlockLinked.getSpanStart(span), + textBlockLinked.getSpanEnd(span), + textBlockLinked.getSpanFlags(span)); + textBlockLinked.removeSpan(span); + } + + // add click actions on plain text timestamps only for description of contents, + // unneeded for meta-info or other TextViews + if (relatedInfoService != null) { + if (relatedStreamUrl != null) { + addClickListenersOnTimestamps(context, textBlockLinked, + relatedInfoService, relatedStreamUrl, disposables); + } + addClickListenersOnHashtags(context, textBlockLinked, relatedInfoService); + } + + return textBlockLinked; + }).subscribeOn(Schedulers.computation()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe( + textBlockLinked -> + setTextViewCharSequence(textView, textBlockLinked, onCompletion), + throwable -> { + Log.e(TAG, "Unable to linkify text", throwable); + // this should never happen, but if it does, just fallback to it + setTextViewCharSequence(textView, chars, onCompletion); + })); + } + + /** + * Add click listeners which opens a search on hashtags in a plain text. + * + *

+ * This method finds all timestamps in the {@link SpannableStringBuilder} of the description + * using a regular expression, adds for each a {@link LongPressClickableSpan} which opens + * {@link NavigationHelper#openSearch(Context, int, String)} and makes a search on the hashtag, + * in the service of the content when pressed, and copy the hashtag to clipboard when + * long-pressed, if allowed by the caller method (parameter {@code addLongClickCopyListener}). + *

+ * + * @param context the {@link Context} to use + * @param spannableDescription the {@link SpannableStringBuilder} with the text of the + * content description + * @param relatedInfoService used to search for the term in the correct service + */ + private static void addClickListenersOnHashtags( + @NonNull final Context context, + @NonNull final SpannableStringBuilder spannableDescription, + @NonNull final StreamingService relatedInfoService) { + final String descriptionText = spannableDescription.toString(); + final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText); + + while (hashtagsMatches.find()) { + final int hashtagStart = hashtagsMatches.start(1); + final int hashtagEnd = hashtagsMatches.end(1); + final String parsedHashtag = descriptionText.substring(hashtagStart, hashtagEnd); + + // Don't add a LongPressClickableSpan if there is already one, which should be a part + // of an URL, already parsed before + if (spannableDescription.getSpans(hashtagStart, hashtagEnd, + LongPressClickableSpan.class).length == 0) { + final int serviceId = relatedInfoService.getServiceId(); + spannableDescription.setSpan( + new HashtagLongPressClickableSpan(context, parsedHashtag, serviceId), + hashtagStart, hashtagEnd, 0); + } + } + } + + /** + * Add click listeners which opens the popup player on timestamps in a plain text. + * + *

+ * This method finds all timestamps in the {@link SpannableStringBuilder} of the description + * using a regular expression, adds for each a {@link LongPressClickableSpan} which opens the + * popup player at the time indicated in the timestamps and copy the timestamp in clipboard + * when long-pressed. + *

+ * + * @param context the {@link Context} to use + * @param spannableDescription the {@link SpannableStringBuilder} with the text of the + * content description + * @param relatedInfoService the service of the {@code relatedStreamUrl} + * @param relatedStreamUrl what to open in the popup player when timestamps are clicked + * @param disposables disposables created by the method are added here and their + * lifecycle should be handled by the calling class + */ + private static void addClickListenersOnTimestamps( + @NonNull final Context context, + @NonNull final SpannableStringBuilder spannableDescription, + @NonNull final StreamingService relatedInfoService, + @NonNull final String relatedStreamUrl, + @NonNull final CompositeDisposable disposables) { + final String descriptionText = spannableDescription.toString(); + final Matcher timestampsMatches = TimestampExtractor.TIMESTAMPS_PATTERN.matcher( + descriptionText); + + while (timestampsMatches.find()) { + final TimestampExtractor.TimestampMatchDTO timestampMatchDTO = + TimestampExtractor.getTimestampFromMatcher(timestampsMatches, descriptionText); + + if (timestampMatchDTO == null) { + continue; + } + + spannableDescription.setSpan( + new TimestampLongPressClickableSpan(context, descriptionText, disposables, + relatedInfoService, relatedStreamUrl, timestampMatchDTO), + timestampMatchDTO.timestampStart(), + timestampMatchDTO.timestampEnd(), + 0); + } + } + + private static void setTextViewCharSequence(@NonNull final TextView textView, + @Nullable final CharSequence charSequence, + @Nullable final Consumer onCompletion) { + textView.setText(charSequence); + textView.setVisibility(View.VISIBLE); + if (onCompletion != null) { + onCompletion.accept(textView); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/TimestampExtractor.java b/app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.java similarity index 78% rename from app/src/main/java/org/schabi/newpipe/util/external_communication/TimestampExtractor.java rename to app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.java index a13c66402..be603f41a 100644 --- a/app/src/main/java/org/schabi/newpipe/util/external_communication/TimestampExtractor.java +++ b/app/src/main/java/org/schabi/newpipe/util/text/TimestampExtractor.java @@ -1,4 +1,7 @@ -package org.schabi.newpipe.util.external_communication; +package org.schabi.newpipe.util.text; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -15,17 +18,18 @@ public final class TimestampExtractor { } /** - * Get's a single timestamp from a matcher. + * Gets a single timestamp from a matcher. * - * @param timestampMatches The matcher which was created using {@link #TIMESTAMPS_PATTERN} - * @param baseText The text where the pattern was applied to / - * where the matcher is based upon - * @return If a match occurred: a {@link TimestampMatchDTO} filled with information.
- * If not null. + * @param timestampMatches the matcher which was created using {@link #TIMESTAMPS_PATTERN} + * @param baseText the text where the pattern was applied to / where the matcher is + * based upon + * @return if a match occurred, a {@link TimestampMatchDTO} filled with information, otherwise + * {@code null}. */ + @Nullable public static TimestampMatchDTO getTimestampFromMatcher( - final Matcher timestampMatches, - final String baseText) { + @NonNull final Matcher timestampMatches, + @NonNull final String baseText) { int timestampStart = timestampMatches.start(1); if (timestampStart == -1) { timestampStart = timestampMatches.start(2); diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java new file mode 100644 index 000000000..f5864794a --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/TimestampLongPressClickableSpan.java @@ -0,0 +1,78 @@ +package org.schabi.newpipe.util.text; + +import static org.schabi.newpipe.util.text.InternalUrlsHandler.playOnPopup; + +import android.content.Context; +import android.view.View; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.extractor.ServiceList; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.util.external_communication.ShareUtils; + +import io.reactivex.rxjava3.disposables.CompositeDisposable; + +final class TimestampLongPressClickableSpan extends LongPressClickableSpan { + + @NonNull + private final Context context; + @NonNull + private final String descriptionText; + @NonNull + private final CompositeDisposable disposables; + @NonNull + private final StreamingService relatedInfoService; + @NonNull + private final String relatedStreamUrl; + @NonNull + private final TimestampExtractor.TimestampMatchDTO timestampMatchDTO; + + TimestampLongPressClickableSpan( + @NonNull final Context context, + @NonNull final String descriptionText, + @NonNull final CompositeDisposable disposables, + @NonNull final StreamingService relatedInfoService, + @NonNull final String relatedStreamUrl, + @NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) { + this.context = context; + this.descriptionText = descriptionText; + this.disposables = disposables; + this.relatedInfoService = relatedInfoService; + this.relatedStreamUrl = relatedStreamUrl; + this.timestampMatchDTO = timestampMatchDTO; + } + + @Override + public void onClick(@NonNull final View view) { + playOnPopup(context, relatedStreamUrl, relatedInfoService, + timestampMatchDTO.seconds(), disposables); + } + + @Override + public void onLongClick(@NonNull final View view) { + ShareUtils.copyToClipboard(context, getTimestampTextToCopy( + relatedInfoService, relatedStreamUrl, descriptionText, timestampMatchDTO)); + } + + @NonNull + private static String getTimestampTextToCopy( + @NonNull final StreamingService relatedInfoService, + @NonNull final String relatedStreamUrl, + @NonNull final String descriptionText, + @NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) { + // TODO: use extractor methods to get timestamps when this feature will be implemented in it + if (relatedInfoService == ServiceList.YouTube) { + return relatedStreamUrl + "&t=" + timestampMatchDTO.seconds(); + } else if (relatedInfoService == ServiceList.SoundCloud + || relatedInfoService == ServiceList.MediaCCC) { + return relatedStreamUrl + "#t=" + timestampMatchDTO.seconds(); + } else if (relatedInfoService == ServiceList.PeerTube) { + return relatedStreamUrl + "?start=" + timestampMatchDTO.seconds(); + } + + // Return timestamp text for other services + return descriptionText.subSequence(timestampMatchDTO.timestampStart(), + timestampMatchDTO.timestampEnd()).toString(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/TouchUtils.java b/app/src/main/java/org/schabi/newpipe/util/text/TouchUtils.java new file mode 100644 index 000000000..5c0db20a3 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/TouchUtils.java @@ -0,0 +1,38 @@ +package org.schabi.newpipe.util.text; + +import android.text.Layout; +import android.view.MotionEvent; +import android.widget.TextView; + +import androidx.annotation.NonNull; + +public final class TouchUtils { + + private TouchUtils() { + } + + /** + * Get the character offset on the closest line to the position pressed by the user of a + * {@link TextView} from a {@link MotionEvent} which was fired on this {@link TextView}. + * + * @param textView the {@link TextView} on which the {@link MotionEvent} was fired + * @param event the {@link MotionEvent} which was fired + * @return the character offset on the closest line to the position pressed by the user + */ + public static int getOffsetForHorizontalLine(@NonNull final TextView textView, + @NonNull final MotionEvent event) { + + int x = (int) event.getX(); + int y = (int) event.getY(); + + x -= textView.getTotalPaddingLeft(); + y -= textView.getTotalPaddingTop(); + + x += textView.getScrollX(); + y += textView.getScrollY(); + + final Layout layout = textView.getLayout(); + final int line = layout.getLineForVertical(y); + return layout.getOffsetForHorizontal(line, x); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java b/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java new file mode 100644 index 000000000..61c1a546d --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/text/UrlLongPressClickableSpan.java @@ -0,0 +1,41 @@ +package org.schabi.newpipe.util.text; + +import android.content.Context; +import android.view.View; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.util.external_communication.ShareUtils; + +import io.reactivex.rxjava3.disposables.CompositeDisposable; + +final class UrlLongPressClickableSpan extends LongPressClickableSpan { + + @NonNull + private final Context context; + @NonNull + private final CompositeDisposable disposables; + @NonNull + private final String url; + + UrlLongPressClickableSpan(@NonNull final Context context, + @NonNull final CompositeDisposable disposables, + @NonNull final String url) { + this.context = context; + this.disposables = disposables; + this.url = url; + } + + @Override + public void onClick(@NonNull final View view) { + if (!InternalUrlsHandler.handleUrlDescriptionTimestamp( + disposables, context, url)) { + ShareUtils.openUrlInApp(context, url); + } + } + + @Override + public void onLongClick(@NonNull final View view) { + ShareUtils.copyToClipboard(context, url); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.java b/app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.java index 5a0dbb003..49be86ae0 100644 --- a/app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.java +++ b/app/src/main/java/org/schabi/newpipe/util/urlfinder/PatternsCompat.java @@ -18,159 +18,17 @@ package org.schabi.newpipe.util.urlfinder; -import androidx.annotation.RestrictTo; - import java.util.regex.Pattern; -import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX; - /** * Commonly used regular expression patterns. */ public final class PatternsCompat { - /** - * Regular expression to match all IANA top-level domains. - * - * List accurate as of 2015/11/24. List taken from: - * http://data.iana.org/TLD/tlds-alpha-by-domain.txt - * This pattern is auto-generated by frameworks/ex/common/tools/make-iana-tld-pattern.py - */ - static final String IANA_TOP_LEVEL_DOMAINS = "(?:" - + "(?:aaa|aarp|abb|abbott|abogado|academy|accenture|accountant|accountants|aco|active" - + "|actor|ads|adult|aeg|aero|afl|agency|aig|airforce|airtel|allfinanz|alsace|amica" - + "|amsterdam|android|apartments|app|apple|aquarelle|aramco|archi|army|arpa|arte|asia" - + "|associates|attorney|auction|audio|auto|autos|axa|azure|a[cdefgilmoqrstuwxz])" - + "|(?:band|bank|bar|barcelona|barclaycard|barclays|bargains|bauhaus|bayern|bbc|bbva" - + "|bcn|beats|beer|bentley|berlin|best|bet|bharti|bible|bid|bike|bing|bingo|bio|biz" - + "|black|blackfriday|bloomberg|blue|bms|bmw|bnl|bnpparibas|boats|bom|bond|boo|boots" - + "|boutique|bradesco|bridgestone|broadway|broker|brother|brussels|budapest|build" - + "|builders|business|buzz|bzh|b[abdefghijmnorstvwyz])" - + "|(?:cab|cafe|cal|camera|camp|cancerresearch|canon|capetown|capital|car|caravan|cards" - + "|care|career|careers|cars|cartier|casa|cash|casino|cat|catering|cba|cbn|ceb|center" - + "|ceo|cern|cfa|cfd|chanel|channel|chat|cheap|chloe|christmas|chrome|church|cipriani" - + "|cisco|citic|city|cityeats|claims|cleaning|click|clinic|clothing|cloud|club|clubmed" - + "|coach|codes|coffee|college|cologne|com|commbank|community|company|computer|comsec" - + "|condos|construction|consulting|contractors|cooking|cool|coop|corsica|country" - + "|coupons|courses|credit|creditcard|creditunion|cricket|crown|crs|cruises|csc" - + "|cuisinella|cymru|cyou|c[acdfghiklmnoruvwxyz])" - + "|(?:dabur|dad|dance|date|dating|datsun|day|dclk|deals|degree|delivery|dell|delta" - + "|democrat|dental|dentist|desi|design|dev|diamonds|diet|digital|direct|directory" - + "|discount|dnp|docs|dog|doha|domains|doosan|download|drive|durban|dvag|d[ejkmoz])" - + "|(?:earth|eat|edu|education|email|emerck|energy|engineer|engineering|enterprises" - + "|epson|equipment|erni|esq|estate|eurovision|eus|events|everbank|exchange|expert" - + "|exposed|express|e[cegrstu])" - + "|(?:fage|fail|fairwinds|faith|family|fan|fans|farm" - + "|fashion|feedback|ferrero|film|final|finance|financial|firmdale|fish|fishing|fit" - + "|fitness|flights|florist|flowers|flsmidth|fly|foo|football|forex|forsale|forum" - + "|foundation|frl|frogans|fund|furniture|futbol|fyi|f[ijkmor])" - + "|(?:gal|gallery|game|garden|gbiz|gdn|gea|gent|genting|ggee|gift|gifts|gives|giving" - + "|glass|gle|global|globo|gmail|gmo|gmx|gold|goldpoint|golf|goo|goog|google|gop|gov" - + "|grainger|graphics|gratis|green|gripe|group|gucci|guge|guide|guitars|guru" - + "|g[abdefghilmnpqrstuwy])" - + "|(?:hamburg|hangout|haus|healthcare|help|here|hermes|hiphop|hitachi|hiv|hockey" - + "|holdings|holiday|homedepot|homes|honda|horse|host|hosting|hoteles|hotmail|house" - + "|how|hsbc|hyundai|h[kmnrtu])" - + "|(?:ibm|icbc|ice|icu|ifm|iinet|immo|immobilien|industries|infiniti|info|ing|ink" - + "|institute|insure|int|international|investments|ipiranga|irish|ist|istanbul|itau" - + "|iwc|i[delmnoqrst])" - + "|(?:jaguar|java|jcb|jetzt|jewelry|jlc|jll|jobs|joburg|jprs|juegos|j[emop])" - + "|(?:kaufen|kddi|kia|kim|kinder|kitchen|kiwi|koeln|komatsu|krd|kred|kyoto" - + "|k[eghimnprwyz])" - + "|(?:lacaixa|lancaster|land|landrover|lasalle|lat|latrobe|law|lawyer|lds|lease" - + "|leclerc|legal|lexus|lgbt|liaison|lidl|life|lifestyle|lighting|limited|limo|linde" - + "|link|live|lixil|loan|loans|lol|london|lotte|lotto|love|ltd|ltda|lupin|luxe|luxury" - + "|l[abcikrstuvy])" - + "|(?:madrid|maif|maison|man|management|mango|market|marketing|markets|marriott|mba" - + "|media|meet|melbourne|meme|memorial|men|menu|meo|miami|microsoft|mil|mini|mma|mobi" - + "|moda|moe|moi|mom|monash|money|montblanc|mormon|mortgage|moscow|motorcycles|mov" - + "|movie|movistar|mtn|mtpc|mtr|museum|mutuelle|m[acdeghklmnopqrstuvwxyz])" - + "|(?:nadex|nagoya|name|navy|nec|net|netbank|network|neustar|new|news|nexus|ngo|nhk" - + "|nico|ninja|nissan|nokia|nra|nrw|ntt|nyc|n[acefgilopruz])" - + "|(?:obi|office|okinawa|omega|one|ong|onl|online|ooo|oracle|orange|org|organic|osaka" - + "|otsuka|ovh|om)" - + "|(?:page|panerai|paris|partners|parts|party|pet|pharmacy|philips|photo|photography" - + "|photos|physio|piaget|pics|pictet|pictures|ping|pink|pizza|place|play|playstation" - + "|plumbing|plus|pohl|poker|porn|post|praxi|press|pro|prod|productions|prof|properties" - + "|property|protection|pub|p[aefghklmnrstwy])" - + "|(?:qpon|quebec|qa)" - + "|(?:racing|realtor|realty|recipes|red|redstone|rehab|reise|reisen|reit|ren|rent" - + "|rentals|repair|report|republican|rest|restaurant|review|reviews|rich|ricoh|rio|rip" - + "|rocher|rocks|rodeo|rsvp|ruhr|run|rwe|ryukyu|r[eosuw])" - + "|(?:saarland|sakura|sale|samsung|sandvik|sandvikcoromant|sanofi|sap|sapo|sarl|saxo" - + "|sbs|sca|scb|schmidt|scholarships|school|schule|schwarz|science|scor|scot|seat" - + "|security|seek|sener|services|seven|sew|sex|sexy|shiksha|shoes|show|shriram|singles" - + "|site|ski|sky|skype|sncf|soccer|social|software|sohu|solar|solutions|sony|soy|space" - + "|spiegel|spreadbetting|srl|stada|starhub|statoil|stc|stcgroup|stockholm|studio|study" - + "|style|sucks|supplies|supply|support|surf|surgery|suzuki|swatch|swiss|sydney|systems" - + "|s[abcdeghijklmnortuvxyz])" - + "|(?:tab|taipei|tatamotors|tatar|tattoo|tax|taxi|team|tech|technology|tel|telefonica" - + "|temasek|tennis|thd|theater|theatre|tickets|tienda|tips|tires|tirol|today|tokyo" - + "|tools|top|toray|toshiba|tours|town|toyota|toys|trade|trading|training|travel|trust" - + "|tui|t[cdfghjklmnortvwz])" - + "|(?:ubs|university|uno|uol|u[agksyz])" - + "|(?:vacations|vana|vegas|ventures|versicherung|vet|viajes|video|villas|vin|virgin" - + "|vision|vista|vistaprint|viva|vlaanderen|vodka|vote|voting|voto|voyage|v[aceginu])" - + "|(?:wales|walter|wang|watch|webcam|website|wed|wedding|weir|whoswho|wien|wiki" - + "|williamhill|win|windows|wine|wme|work|works|world|wtc|wtf|w[fs])" - + "|(?:\u03b5\u03bb|\u0431\u0435\u043b|\u0434\u0435\u0442\u0438|\u043a\u043e\u043c" - + "|\u043c\u043a\u0434|\u043c\u043e\u043d|\u043c\u043e\u0441\u043a\u0432\u0430" - + "|\u043e\u043d\u043b\u0430\u0439\u043d|\u043e\u0440\u0433|\u0440\u0443\u0441" - + "|\u0440\u0444|\u0441\u0430\u0439\u0442|\u0441\u0440\u0431|\u0443\u043a\u0440" - + "|\u049b\u0430\u0437|\u0570\u0561\u0575|\u05e7\u05d5\u05dd" - + "|\u0627\u0631\u0627\u0645\u0643\u0648|\u0627\u0644\u0627\u0631\u062f\u0646" - + "|\u0627\u0644\u062c\u0632\u0627\u0626\u0631" - + "|\u0627\u0644\u0633\u0639\u0648\u062f\u064a\u0629" - + "|\u0627\u0644\u0645\u063a\u0631\u0628|\u0627\u0645\u0627\u0631\u0627\u062a" - + "|\u0627\u06cc\u0631\u0627\u0646|\u0628\u0627\u0632\u0627\u0631" - + "|\u0628\u06be\u0627\u0631\u062a|\u062a\u0648\u0646\u0633" - + "|\u0633\u0648\u062f\u0627\u0646|\u0633\u0648\u0631\u064a\u0629" - + "|\u0634\u0628\u0643\u0629|\u0639\u0631\u0627\u0642|\u0639\u0645\u0627\u0646" - + "|\u0641\u0644\u0633\u0637\u064a\u0646|\u0642\u0637\u0631|\u0643\u0648\u0645" - + "|\u0645\u0635\u0631|\u0645\u0644\u064a\u0633\u064a\u0627|\u0645\u0648\u0642\u0639" - + "|\u0915\u0949\u092e|\u0928\u0947\u091f|\u092d\u093e\u0930\u0924" - + "|\u0938\u0902\u0917\u0920\u0928|\u09ad\u09be\u09b0\u09a4|\u0a2d\u0a3e\u0a30\u0a24" - + "|\u0aad\u0abe\u0ab0\u0aa4|\u0b87\u0ba8\u0bcd\u0ba4\u0bbf\u0baf\u0bbe" - + "|\u0b87\u0bb2\u0b99\u0bcd\u0b95\u0bc8" - + "|\u0b9a\u0bbf\u0b99\u0bcd\u0b95\u0baa\u0bcd\u0baa\u0bc2\u0bb0\u0bcd" - + "|\u0c2d\u0c3e\u0c30\u0c24\u0c4d|\u0dbd\u0d82\u0d9a\u0dcf|\u0e04\u0e2d\u0e21" - + "|\u0e44\u0e17\u0e22|\u10d2\u10d4|\u307f\u3093\u306a|\u30b0\u30fc\u30b0\u30eb" - + "|\u30b3\u30e0|\u4e16\u754c|\u4e2d\u4fe1|\u4e2d\u56fd|\u4e2d\u570b|\u4e2d\u6587\u7f51" - + "|\u4f01\u4e1a|\u4f5b\u5c71|\u4fe1\u606f|\u5065\u5eb7|\u516b\u5366|\u516c\u53f8" - + "|\u516c\u76ca|\u53f0\u6e7e|\u53f0\u7063|\u5546\u57ce|\u5546\u5e97|\u5546\u6807" - + "|\u5728\u7ebf|\u5927\u62ff|\u5a31\u4e50|\u5de5\u884c|\u5e7f\u4e1c|\u6148\u5584" - + "|\u6211\u7231\u4f60|\u624b\u673a|\u653f\u52a1|\u653f\u5e9c|\u65b0\u52a0\u5761" - + "|\u65b0\u95fb|\u65f6\u5c1a|\u673a\u6784|\u6de1\u9a6c\u9521|\u6e38\u620f|\u70b9\u770b" - + "|\u79fb\u52a8|\u7ec4\u7ec7\u673a\u6784|\u7f51\u5740|\u7f51\u5e97|\u7f51\u7edc" - + "|\u8c37\u6b4c|\u96c6\u56e2|\u98de\u5229\u6d66|\u9910\u5385|\u9999\u6e2f|\ub2f7\ub137" - + "|\ub2f7\ucef4|\uc0bc\uc131|\ud55c\uad6d|xbox|xerox|xin|xn\\-\\-11b4c3d" - + "|xn\\-\\-1qqw23a|xn\\-\\-30rr7y|xn\\-\\-3bst00m|xn\\-\\-3ds443g|xn\\-\\-3e0b707e" - + "|xn\\-\\-3pxu8k|xn\\-\\-42c2d9a|xn\\-\\-45brj9c|xn\\-\\-45q11c|xn\\-\\-4gbrim" - + "|xn\\-\\-55qw42g|xn\\-\\-55qx5d|xn\\-\\-6frz82g|xn\\-\\-6qq986b3xl|xn\\-\\-80adxhks" - + "|xn\\-\\-80ao21a|xn\\-\\-80asehdb|xn\\-\\-80aswg|xn\\-\\-90a3ac|xn\\-\\-90ais" - + "|xn\\-\\-9dbq2a|xn\\-\\-9et52u|xn\\-\\-b4w605ferd|xn\\-\\-c1avg|xn\\-\\-c2br7g" - + "|xn\\-\\-cg4bki|xn\\-\\-clchc0ea0b2g2a9gcd|xn\\-\\-czr694b|xn\\-\\-czrs0t" - + "|xn\\-\\-czru2d|xn\\-\\-d1acj3b|xn\\-\\-d1alf|xn\\-\\-efvy88h|xn\\-\\-estv75g" - + "|xn\\-\\-fhbei|xn\\-\\-fiq228c5hs|xn\\-\\-fiq64b|xn\\-\\-fiqs8s|xn\\-\\-fiqz9s" - + "|xn\\-\\-fjq720a|xn\\-\\-flw351e|xn\\-\\-fpcrj9c3d|xn\\-\\-fzc2c9e2c|xn\\-\\-gecrj9c" - + "|xn\\-\\-h2brj9c|xn\\-\\-hxt814e|xn\\-\\-i1b6b1a6a2e|xn\\-\\-imr513n|xn\\-\\-io0a7i" - + "|xn\\-\\-j1aef|xn\\-\\-j1amh|xn\\-\\-j6w193g|xn\\-\\-kcrx77d1x4a|xn\\-\\-kprw13d" - + "|xn\\-\\-kpry57d|xn\\-\\-kput3i|xn\\-\\-l1acc|xn\\-\\-lgbbat1ad8j|xn\\-\\-mgb9awbf" - + "|xn\\-\\-mgba3a3ejt|xn\\-\\-mgba3a4f16a|xn\\-\\-mgbaam7a8h|xn\\-\\-mgbab2bd" - + "|xn\\-\\-mgbayh7gpa|xn\\-\\-mgbbh1a71e|xn\\-\\-mgbc0a9azcg|xn\\-\\-mgberp4a5d4ar" - + "|xn\\-\\-mgbpl2fh|xn\\-\\-mgbtx2b|xn\\-\\-mgbx4cd0ab|xn\\-\\-mk1bu44c|xn\\-\\-mxtq1m" - + "|xn\\-\\-ngbc5azd|xn\\-\\-node|xn\\-\\-nqv7f|xn\\-\\-nqv7fs00ema|xn\\-\\-nyqy26a" - + "|xn\\-\\-o3cw4h|xn\\-\\-ogbpf8fl|xn\\-\\-p1acf|xn\\-\\-p1ai|xn\\-\\-pgbs0dh" - + "|xn\\-\\-pssy2u|xn\\-\\-q9jyb4c|xn\\-\\-qcka1pmc|xn\\-\\-qxam|xn\\-\\-rhqv96g" - + "|xn\\-\\-s9brj9c|xn\\-\\-ses554g|xn\\-\\-t60b56a|xn\\-\\-tckwe|xn\\-\\-unup4y" - + "|xn\\-\\-vermgensberater\\-ctb|xn\\-\\-vermgensberatung\\-pwb|xn\\-\\-vhquv" - + "|xn\\-\\-vuq861b|xn\\-\\-wgbh1c|xn\\-\\-wgbl6a|xn\\-\\-xhq521b|xn\\-\\-xkc2al3hye2a" - + "|xn\\-\\-xkc2dl3a5ee0h|xn\\-\\-y9a3aq|xn\\-\\-yfro4i67o|xn\\-\\-ygbi2ammx" - + "|xn\\-\\-zfr164b|xperia|xxx|xyz)" - + "|(?:yachts|yamaxun|yandex|yodobashi|yoga|yokohama|youtube|y[et])" - + "|(?:zara|zip|zone|zuerich|z[amw]))"; + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + // CHANGED: Removed unused code // + //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - public static final Pattern IP_ADDRESS - = Pattern.compile( + public static final Pattern IP_ADDRESS = Pattern.compile( "((25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9])\\.(25[0-5]|2[0-4]" + "[0-9]|[0-1][0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1]" + "[0-9]{2}|[1-9][0-9]|[1-9]|0)\\.(25[0-5]|2[0-4][0-9]|[0-1][0-9]{2}" @@ -204,28 +62,11 @@ public final class PatternsCompat { */ private static final String LABEL_CHAR = "a-zA-Z0-9" + UCS_CHAR; - /** - * Valid characters for IRI TLD defined in RFC 3987. - */ - private static final String TLD_CHAR = "a-zA-Z" + UCS_CHAR; - /** * RFC 1035 Section 2.3.4 limits the labels to a maximum 63 octets. */ - private static final String IRI_LABEL - = "[" + LABEL_CHAR + "](?:[" + LABEL_CHAR + "_\\-]{0,61}[" + LABEL_CHAR + "]){0,1}"; - - /** - * RFC 3492 references RFC 1034 and limits Punycode algorithm output to 63 characters. - */ - private static final String PUNYCODE_TLD = "xn\\-\\-[\\w\\-]{0,58}\\w"; - - private static final String TLD = "(" + PUNYCODE_TLD + "|" + "[" + TLD_CHAR + "]{2,63}" + ")"; - - private static final String HOST_NAME = "(" + IRI_LABEL + "\\.)+" + TLD; - - public static final Pattern DOMAIN_NAME - = Pattern.compile("(" + HOST_NAME + "|" + IP_ADDRESS + ")"); + private static final String IRI_LABEL = + "[" + LABEL_CHAR + "](?:[" + LABEL_CHAR + "_\\-]{0,61}[" + LABEL_CHAR + "]){0,1}"; //!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! // CHANGED: Removed rtsp from supported protocols // @@ -245,59 +86,11 @@ public final class PatternsCompat { + ";/\\?:@&=#~" // plus optional query params + "\\-\\.\\+!\\*'\\(\\),_\\$])|(?:%[a-fA-F0-9]{2}))*"; - /** - * Regular expression pattern to match most part of RFC 3987 - * Internationalized URLs, aka IRIs. - */ - public static final Pattern WEB_URL = Pattern.compile("(" - + "(" - + "(?:" + PROTOCOL + "(?:" + USER_INFO + ")?" + ")?" - + "(?:" + DOMAIN_NAME + ")" - + "(?:" + PORT_NUMBER + ")?" - + ")" - + "(" + PATH_AND_QUERY + ")?" - + WORD_BOUNDARY - + ")"); - - /** - * Regular expression that matches known TLDs and punycode TLDs. - */ - private static final String STRICT_TLD = "(?:" - + IANA_TOP_LEVEL_DOMAINS + "|" + PUNYCODE_TLD + ")"; - - /** - * Regular expression that matches host names using {@link #STRICT_TLD}. - */ - private static final String STRICT_HOST_NAME = "(?:(?:" + IRI_LABEL + "\\.)+" - + STRICT_TLD + ")"; - - /** - * Regular expression that matches domain names using either {@link #STRICT_HOST_NAME} or - * {@link #IP_ADDRESS}. - */ - private static final Pattern STRICT_DOMAIN_NAME - = Pattern.compile("(?:" + STRICT_HOST_NAME + "|" + IP_ADDRESS + ")"); - /** * Regular expression that matches domain names without a TLD. */ - private static final String RELAXED_DOMAIN_NAME - = "(?:" + "(?:" + IRI_LABEL + "(?:\\.(?=\\S))" + "?)+" + "|" + IP_ADDRESS + ")"; - - /** - * Regular expression to match strings that do not start with a supported protocol. The TLDs - * are expected to be one of the known TLDs. - */ - private static final String WEB_URL_WITHOUT_PROTOCOL = "(" - + WORD_BOUNDARY - + "(? 0) { height = (int) (width / videoAspectRatio); } else { diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.java b/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.java index 798d08c72..d4fafc31a 100644 --- a/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.java +++ b/app/src/main/java/org/schabi/newpipe/views/FocusAwareCoordinator.java @@ -17,10 +17,8 @@ */ package org.schabi.newpipe.views; -import android.annotation.TargetApi; import android.content.Context; import android.graphics.Rect; -import android.os.Build; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; @@ -29,6 +27,7 @@ import android.view.WindowInsets; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.view.WindowInsetsCompat; import org.schabi.newpipe.R; @@ -74,7 +73,6 @@ public final class FocusAwareCoordinator extends CoordinatorLayout { * Makes possible for multiple fragments to co-exist. Without this code * the first ViewGroup who consumes will be the last who receive the insets */ - @TargetApi(Build.VERSION_CODES.LOLLIPOP) @Override public WindowInsets dispatchApplyWindowInsets(final WindowInsets insets) { boolean consumed = false; @@ -86,23 +84,22 @@ public final class FocusAwareCoordinator extends CoordinatorLayout { } } - if (consumed) { - insets.consumeSystemWindowInsets(); - } - return insets; + return consumed ? WindowInsetsCompat.CONSUMED.toWindowInsets() : insets; } /** - * Adjusts player's controls manually because fitsSystemWindows doesn't work when multiple + * Adjusts player's controls manually because onApplyWindowInsets doesn't work when multiple * receivers adjust its bounds. So when two listeners are present (like in profile page) * the player's controls will not receive insets. This method fixes it */ @Override - protected boolean fitSystemWindows(final Rect insets) { + public WindowInsets onApplyWindowInsets(final WindowInsets windowInsets) { + final var windowInsetsCompat = WindowInsetsCompat.toWindowInsetsCompat(windowInsets, this); + final var insets = windowInsetsCompat.getInsets(WindowInsetsCompat.Type.systemBars()); final ViewGroup controls = findViewById(R.id.playbackControlRoot); if (controls != null) { controls.setPadding(insets.left, insets.top, insets.right, insets.bottom); } - return super.fitSystemWindows(insets); + return super.onApplyWindowInsets(windowInsets); } } diff --git a/app/src/main/java/org/schabi/newpipe/views/FocusAwareDrawerLayout.java b/app/src/main/java/org/schabi/newpipe/views/FocusAwareDrawerLayout.java index 500562668..5c694c3a9 100644 --- a/app/src/main/java/org/schabi/newpipe/views/FocusAwareDrawerLayout.java +++ b/app/src/main/java/org/schabi/newpipe/views/FocusAwareDrawerLayout.java @@ -57,8 +57,8 @@ public final class FocusAwareDrawerLayout extends DrawerLayout { for (int i = 0; i < getChildCount(); ++i) { final View child = getChildAt(i); - final DrawerLayout.LayoutParams lp - = (DrawerLayout.LayoutParams) child.getLayoutParams(); + final DrawerLayout.LayoutParams lp = + (DrawerLayout.LayoutParams) child.getLayoutParams(); if (lp.gravity != 0 && isDrawerVisible(child)) { hasOpenPanels = true; @@ -85,8 +85,8 @@ public final class FocusAwareDrawerLayout extends DrawerLayout { for (int i = 0; i < getChildCount(); ++i) { final View child = getChildAt(i); - final DrawerLayout.LayoutParams lp - = (DrawerLayout.LayoutParams) child.getLayoutParams(); + final DrawerLayout.LayoutParams lp = + (DrawerLayout.LayoutParams) child.getLayoutParams(); if (lp.gravity == 0) { content = child; diff --git a/app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.java b/app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.java index 2adc28d0e..f0993055e 100644 --- a/app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.java +++ b/app/src/main/java/org/schabi/newpipe/views/NewPipeEditText.java @@ -13,9 +13,10 @@ import org.schabi.newpipe.util.external_communication.ShareUtils; /** * An {@link AppCompatEditText} which uses {@link ShareUtils#shareText(Context, String, String)} * when sharing selected text by using the {@code Share} command of the floating actions. + * *

- * This allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing text - * from {@link AppCompatEditText} on EMUI devices. + * This class allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing + * text from {@link AppCompatEditText} on EMUI devices. *

*/ public class NewPipeEditText extends AppCompatEditText { diff --git a/app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.java b/app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.java index 8fdac32db..dd3f20f40 100644 --- a/app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.java +++ b/app/src/main/java/org/schabi/newpipe/views/NewPipeTextView.java @@ -1,6 +1,7 @@ package org.schabi.newpipe.views; import android.content.Context; +import android.text.method.MovementMethod; import android.util.AttributeSet; import androidx.annotation.NonNull; @@ -13,9 +14,11 @@ import org.schabi.newpipe.util.external_communication.ShareUtils; /** * An {@link AppCompatTextView} which uses {@link ShareUtils#shareText(Context, String, String)} * when sharing selected text by using the {@code Share} command of the floating actions. + * *

- * This allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing text - * from {@link AppCompatTextView} on EMUI devices. + * This class allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing + * text from {@link AppCompatTextView} on EMUI devices and also to keep movement method set when a + * text change occurs, if the text cannot be selected and text links are clickable. *

*/ public class NewPipeTextView extends AppCompatTextView { @@ -34,6 +37,16 @@ public class NewPipeTextView extends AppCompatTextView { super(context, attrs, defStyleAttr); } + @Override + public void setText(final CharSequence text, final BufferType type) { + // We need to set again the movement method after a text change because Android resets the + // movement method to the default one in the case where the text cannot be selected and + // text links are clickable (which is the default case in NewPipe). + final MovementMethod movementMethod = this.getMovementMethod(); + super.setText(text, type); + setMovementMethod(movementMethod); + } + @Override public boolean onTextContextMenuItem(final int id) { if (id == android.R.id.shareText) { diff --git a/app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt b/app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt index e3d142916..8554e7194 100644 --- a/app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt +++ b/app/src/main/java/org/schabi/newpipe/views/player/CircleClipTapView.kt @@ -80,10 +80,10 @@ class CircleClipTapView(context: Context?, attrs: AttributeSet) : View(context, updatePathShape() } - override fun onDraw(canvas: Canvas?) { + override fun onDraw(canvas: Canvas) { super.onDraw(canvas) - canvas?.clipPath(shapePath) - canvas?.drawPath(shapePath, backgroundPaint) + canvas.clipPath(shapePath) + canvas.drawPath(shapePath, backgroundPaint) } } diff --git a/app/src/main/java/org/schabi/newpipe/views/player/PlayerFastSeekOverlay.kt b/app/src/main/java/org/schabi/newpipe/views/player/PlayerFastSeekOverlay.kt index 649b60494..877070a91 100644 --- a/app/src/main/java/org/schabi/newpipe/views/player/PlayerFastSeekOverlay.kt +++ b/app/src/main/java/org/schabi/newpipe/views/player/PlayerFastSeekOverlay.kt @@ -4,7 +4,6 @@ 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 @@ -12,8 +11,8 @@ 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 +import org.schabi.newpipe.player.gesture.DisplayPortion +import org.schabi.newpipe.player.gesture.DoubleTapListener class PlayerFastSeekOverlay(context: Context, attrs: AttributeSet?) : ConstraintLayout(context, attrs), DoubleTapListener { @@ -38,14 +37,14 @@ class PlayerFastSeekOverlay(context: Context, attrs: AttributeSet?) : private var performListener: PerformListener? = null - fun performListener(listener: PerformListener) = apply { + fun performListener(listener: PerformListener?) = apply { performListener = listener } private var seekSecondsSupplier: () -> Int = { 0 } - fun seekSecondsSupplier(supplier: () -> Int) = apply { - seekSecondsSupplier = supplier + fun seekSecondsSupplier(supplier: (() -> Int)?) = apply { + seekSecondsSupplier = supplier ?: { 0 } } // Indicates whether this (double) tap is the first of a series @@ -127,7 +126,6 @@ class PlayerFastSeekOverlay(context: Context, attrs: AttributeSet?) : /** * Determines if the playback should forward/rewind or do nothing. */ - @NonNull fun getFastSeekDirection(portion: DisplayPortion): FastSeekDirection fun seek(forward: Boolean) diff --git a/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java index 7c0fa9012..84e968b43 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadInitializer.java @@ -18,10 +18,10 @@ import static org.schabi.newpipe.BuildConfig.DEBUG; import static us.shandian.giga.get.DownloadMission.ERROR_HTTP_FORBIDDEN; public class DownloadInitializer extends Thread { - private final static String TAG = "DownloadInitializer"; - final static int mId = 0; - private final static int RESERVE_SPACE_DEFAULT = 5 * 1024 * 1024;// 5 MiB - private final static int RESERVE_SPACE_MAXIMUM = 150 * 1024 * 1024;// 150 MiB + private static final String TAG = "DownloadInitializer"; + static final int mId = 0; + private static final int RESERVE_SPACE_DEFAULT = 5 * 1024 * 1024;// 5 MiB + private static final int RESERVE_SPACE_MAXIMUM = 150 * 1024 * 1024;// 150 MiB private final DownloadMission mMission; private HttpURLConnection mConn; @@ -54,12 +54,12 @@ public class DownloadInitializer extends Thread { long lowestSize = Long.MAX_VALUE; for (int i = 0; i < mMission.urls.length && mMission.running; i++) { - mConn = mMission.openConnection(mMission.urls[i], true, -1, -1); + mConn = mMission.openConnection(mMission.urls[i], true, 0, 0); mMission.establishConnection(mId, mConn); dispose(); if (Thread.interrupted()) return; - long length = Utility.getContentLength(mConn); + long length = Utility.getTotalContentLength(mConn); if (i == 0) { httpCode = mConn.getResponseCode(); @@ -84,14 +84,14 @@ public class DownloadInitializer extends Thread { } } else { // ask for the current resource length - mConn = mMission.openConnection(true, -1, -1); + mConn = mMission.openConnection(true, 0, 0); mMission.establishConnection(mId, mConn); dispose(); if (!mMission.running || Thread.interrupted()) return; httpCode = mConn.getResponseCode(); - mMission.length = Utility.getContentLength(mConn); + mMission.length = Utility.getTotalContentLength(mConn); } if (mMission.length == 0 || httpCode == 204) { diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMission.java b/app/src/main/java/us/shandian/giga/get/DownloadMission.java index 9d8eaf9a5..04930b002 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -1,6 +1,5 @@ package us.shandian.giga.get; -import android.os.Build; import android.os.Handler; import android.system.ErrnoException; import android.system.OsConstants; @@ -316,16 +315,14 @@ public class DownloadMission extends Mission { public synchronized void notifyError(int code, Exception err) { Log.e(TAG, "notifyError() code = " + code, err); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - if (err != null && err.getCause() instanceof ErrnoException) { - int errno = ((ErrnoException) err.getCause()).errno; - if (errno == OsConstants.ENOSPC) { - code = ERROR_INSUFFICIENT_STORAGE; - err = null; - } else if (errno == OsConstants.EACCES) { - code = ERROR_PERMISSION_DENIED; - err = null; - } + if (err != null && err.getCause() instanceof ErrnoException) { + int errno = ((ErrnoException) err.getCause()).errno; + if (errno == OsConstants.ENOSPC) { + code = ERROR_INSUFFICIENT_STORAGE; + err = null; + } else if (errno == OsConstants.EACCES) { + code = ERROR_PERMISSION_DENIED; + err = null; } } diff --git a/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java b/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java index 90886b63c..e001c6f3f 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMissionRecover.java @@ -6,6 +6,7 @@ import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.stream.AudioStream; +import org.schabi.newpipe.extractor.stream.DeliveryMethod; import org.schabi.newpipe.extractor.stream.StreamExtractor; import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; @@ -131,31 +132,38 @@ public class DownloadMissionRecover extends Thread { switch (mRecovery.getKind()) { case 'a': - for (AudioStream audio : mExtractor.getAudioStreams()) { - if (audio.getAverageBitrate() == mRecovery.getDesiredBitrate() && audio.getFormat() == mRecovery.getFormat()) { - url = audio.getUrl(); + for (final AudioStream audio : mExtractor.getAudioStreams()) { + if (audio.getAverageBitrate() == mRecovery.getDesiredBitrate() + && audio.getFormat() == mRecovery.getFormat() + && audio.getDeliveryMethod() == DeliveryMethod.PROGRESSIVE_HTTP) { + url = audio.getContent(); break; } } break; case 'v': - List videoStreams; + final List videoStreams; if (mRecovery.isDesired2()) videoStreams = mExtractor.getVideoOnlyStreams(); else videoStreams = mExtractor.getVideoStreams(); - for (VideoStream video : videoStreams) { - if (video.resolution.equals(mRecovery.getDesired()) && video.getFormat() == mRecovery.getFormat()) { - url = video.getUrl(); + for (final VideoStream video : videoStreams) { + if (video.getResolution().equals(mRecovery.getDesired()) + && video.getFormat() == mRecovery.getFormat() + && video.getDeliveryMethod() == DeliveryMethod.PROGRESSIVE_HTTP) { + url = video.getContent(); break; } } break; case 's': - for (SubtitlesStream subtitles : mExtractor.getSubtitles(mRecovery.getFormat())) { + for (final SubtitlesStream subtitles : mExtractor.getSubtitles(mRecovery + .getFormat())) { String tag = subtitles.getLanguageTag(); - if (tag.equals(mRecovery.getDesired()) && subtitles.isAutoGenerated() == mRecovery.isDesired2()) { - url = subtitles.getUrl(); + if (tag.equals(mRecovery.getDesired()) + && subtitles.isAutoGenerated() == mRecovery.isDesired2() + && subtitles.getDeliveryMethod() == DeliveryMethod.PROGRESSIVE_HTTP) { + url = subtitles.getContent(); break; } } diff --git a/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.kt b/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.kt index 11293a610..c2f9dc9b2 100644 --- a/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.kt +++ b/app/src/main/java/us/shandian/giga/get/MissionRecoveryInfo.kt @@ -11,23 +11,23 @@ import java.io.Serializable @Parcelize class MissionRecoveryInfo( - var format: MediaFormat, + var format: MediaFormat?, var desired: String? = null, var isDesired2: Boolean = false, var desiredBitrate: Int = 0, var kind: Char = Char.MIN_VALUE, var validateCondition: String? = null ) : Serializable, Parcelable { - constructor(stream: Stream) : this(format = stream.getFormat()!!) { + constructor(stream: Stream) : this(format = stream.format) { when (stream) { is AudioStream -> { - desiredBitrate = stream.averageBitrate + desiredBitrate = stream.getAverageBitrate() isDesired2 = false kind = 'a' } is VideoStream -> { - desired = stream.resolution - isDesired2 = stream.isVideoOnly + desired = stream.getResolution() + isDesired2 = stream.isVideoOnly() kind = 'v' } is SubtitlesStream -> { @@ -62,7 +62,7 @@ class MissionRecoveryInfo( } } str.append(" format=") - .append(format.getName()) + .append(format?.getName()) .append(' ') .append(info) .append('}') diff --git a/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java b/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java index dbceeb091..4473fa7f9 100644 --- a/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java +++ b/app/src/main/java/us/shandian/giga/io/CircularFileWriter.java @@ -11,10 +11,10 @@ import java.util.Objects; public class CircularFileWriter extends SharpStream { - private final static int QUEUE_BUFFER_SIZE = 8 * 1024;// 8 KiB - private final static int COPY_BUFFER_SIZE = 128 * 1024; // 128 KiB - private final static int NOTIFY_BYTES_INTERVAL = 64 * 1024;// 64 KiB - private final static int THRESHOLD_AUX_LENGTH = 15 * 1024 * 1024;// 15 MiB + private static final int QUEUE_BUFFER_SIZE = 8 * 1024;// 8 KiB + private static final int COPY_BUFFER_SIZE = 128 * 1024; // 128 KiB + private static final int NOTIFY_BYTES_INTERVAL = 64 * 1024;// 64 KiB + private static final int THRESHOLD_AUX_LENGTH = 15 * 1024 * 1024;// 15 MiB private final OffsetChecker callback; diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManager.java b/app/src/main/java/us/shandian/giga/service/DownloadManager.java index 7c248c2b6..9b90fa14b 100644 --- a/app/src/main/java/us/shandian/giga/service/DownloadManager.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManager.java @@ -30,9 +30,9 @@ public class DownloadManager { enum NetworkState {Unavailable, Operating, MeteredOperating} - public final static int SPECIAL_NOTHING = 0; - public final static int SPECIAL_PENDING = 1; - public final static int SPECIAL_FINISHED = 2; + public static final int SPECIAL_NOTHING = 0; + public static final int SPECIAL_PENDING = 1; + public static final int SPECIAL_FINISHED = 2; public static final String TAG_AUDIO = "audio"; public static final String TAG_VIDEO = "video"; diff --git a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java index d96b4fc5b..45211211f 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -1,13 +1,14 @@ package us.shandian.giga.service; +import static org.schabi.newpipe.BuildConfig.APPLICATION_ID; +import static org.schabi.newpipe.BuildConfig.DEBUG; + import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.app.Service; -import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; -import android.content.IntentFilter; import android.content.SharedPreferences; import android.content.SharedPreferences.OnSharedPreferenceChangeListener; import android.graphics.Bitmap; @@ -18,45 +19,43 @@ import android.net.NetworkInfo; import android.net.NetworkRequest; import android.net.Uri; import android.os.Binder; -import android.os.Build; import android.os.Handler; import android.os.Handler.Callback; import android.os.IBinder; import android.os.Message; -import android.os.Parcelable; import android.util.Log; -import android.util.SparseArray; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; +import androidx.collection.SparseArrayCompat; import androidx.core.app.NotificationCompat; import androidx.core.app.NotificationCompat.Builder; +import androidx.core.app.PendingIntentCompat; import androidx.core.app.ServiceCompat; import androidx.core.content.ContextCompat; +import androidx.core.content.IntentCompat; import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; import org.schabi.newpipe.download.DownloadActivity; import org.schabi.newpipe.player.helper.LockManager; - -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; - -import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.get.MissionRecoveryInfo; import org.schabi.newpipe.streams.io.StoredDirectoryHelper; import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.Localization; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.get.MissionRecoveryInfo; import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.service.DownloadManager.NetworkState; -import static org.schabi.newpipe.BuildConfig.APPLICATION_ID; -import static org.schabi.newpipe.BuildConfig.DEBUG; - public class DownloadManagerService extends Service { private static final String TAG = "DownloadManagerService"; @@ -97,10 +96,9 @@ public class DownloadManagerService extends Service { private Builder downloadDoneNotification = null; private StringBuilder downloadDoneList = null; - private final ArrayList mEchoObservers = new ArrayList<>(1); + private final List mEchoObservers = new ArrayList<>(1); private ConnectivityManager mConnectivityManager; - private BroadcastReceiver mNetworkStateListener = null; private ConnectivityManager.NetworkCallback mNetworkStateListenerL = null; private SharedPreferences mPrefs = null; @@ -111,7 +109,8 @@ public class DownloadManagerService extends Service { private int downloadFailedNotificationID = DOWNLOADS_NOTIFICATION_ID + 1; private Builder downloadFailedNotification = null; - private final SparseArray mFailedDownloads = new SparseArray<>(5); + private final SparseArrayCompat mFailedDownloads = + new SparseArrayCompat<>(5); private Bitmap icLauncher; private Bitmap icDownloadDone; @@ -146,9 +145,9 @@ public class DownloadManagerService extends Service { Intent openDownloadListIntent = new Intent(this, DownloadActivity.class) .setAction(Intent.ACTION_MAIN); - mOpenDownloadList = PendingIntent.getActivity(this, 0, + mOpenDownloadList = PendingIntentCompat.getActivity(this, 0, openDownloadListIntent, - PendingIntent.FLAG_UPDATE_CURRENT); + PendingIntent.FLAG_UPDATE_CURRENT, false); icLauncher = BitmapFactory.decodeResource(this.getResources(), R.mipmap.ic_launcher); @@ -166,28 +165,18 @@ public class DownloadManagerService extends Service { mConnectivityManager = ContextCompat.getSystemService(this, ConnectivityManager.class); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - mNetworkStateListenerL = new ConnectivityManager.NetworkCallback() { - @Override - public void onAvailable(Network network) { - handleConnectivityState(false); - } + mNetworkStateListenerL = new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(Network network) { + handleConnectivityState(false); + } - @Override - public void onLost(Network network) { - handleConnectivityState(false); - } - }; - mConnectivityManager.registerNetworkCallback(new NetworkRequest.Builder().build(), mNetworkStateListenerL); - } else { - mNetworkStateListener = new BroadcastReceiver() { - @Override - public void onReceive(Context context, Intent intent) { - handleConnectivityState(false); - } - }; - registerReceiver(mNetworkStateListener, new IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)); - } + @Override + public void onLost(Network network) { + handleConnectivityState(false); + } + }; + mConnectivityManager.registerNetworkCallback(new NetworkRequest.Builder().build(), mNetworkStateListenerL); mPrefs.registerOnSharedPreferenceChangeListener(mPrefChangeListener); @@ -246,10 +235,7 @@ public class DownloadManagerService extends Service { manageLock(false); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) - mConnectivityManager.unregisterNetworkCallback(mNetworkStateListenerL); - else - unregisterReceiver(mNetworkStateListener); + mConnectivityManager.unregisterNetworkCallback(mNetworkStateListenerL); mPrefs.unregisterOnSharedPreferenceChangeListener(mPrefChangeListener); @@ -263,21 +249,6 @@ public class DownloadManagerService extends Service { @Override public IBinder onBind(Intent intent) { - /* - int permissionCheck; - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN) { - permissionCheck = PermissionChecker.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE); - if (permissionCheck == PermissionChecker.PERMISSION_DENIED) { - Toast.makeText(this, "Permission denied (read)", Toast.LENGTH_SHORT).show(); - } - } - - permissionCheck = PermissionChecker.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE); - if (permissionCheck == PermissionChecker.PERMISSION_DENIED) { - Toast.makeText(this, "Permission denied (write)", Toast.LENGTH_SHORT).show(); - } - */ - return mBinder; } @@ -308,7 +279,7 @@ public class DownloadManagerService extends Service { } if (msg.what != MESSAGE_ERROR) - mFailedDownloads.delete(mFailedDownloads.indexOfValue(mission)); + mFailedDownloads.remove(mFailedDownloads.indexOfValue(mission)); for (Callback observer : mEchoObservers) observer.handleMessage(msg); @@ -340,7 +311,7 @@ public class DownloadManagerService extends Service { } private void handlePreferenceChange(SharedPreferences prefs, @NonNull String key) { - if (key.equals(getString(R.string.downloads_maximum_retry))) { + if (getString(R.string.downloads_maximum_retry).equals(key)) { try { String value = prefs.getString(key, getString(R.string.downloads_maximum_retry_default)); mManager.mPrefMaxRetry = value == null ? 0 : Integer.parseInt(value); @@ -348,13 +319,13 @@ public class DownloadManagerService extends Service { mManager.mPrefMaxRetry = 0; } mManager.updateMaximumAttempts(); - } else if (key.equals(getString(R.string.downloads_cross_network))) { + } else if (getString(R.string.downloads_cross_network).equals(key)) { mManager.mPrefMeteredDownloads = prefs.getBoolean(key, false); - } else if (key.equals(getString(R.string.downloads_queue_limit))) { + } else if (getString(R.string.downloads_queue_limit).equals(key)) { mManager.mPrefQueueLimit = prefs.getBoolean(key, true); - } else if (key.equals(getString(R.string.download_path_video_key))) { + } else if (getString(R.string.download_path_video_key).equals(key)) { mManager.mMainStorageVideo = loadMainVideoStorage(); - } else if (key.equals(getString(R.string.download_path_audio_key))) { + } else if (getString(R.string.download_path_audio_key).equals(key)) { mManager.mMainStorageAudio = loadMainAudioStorage(); } } @@ -389,29 +360,29 @@ public class DownloadManagerService extends Service { */ public static void startMission(Context context, String[] urls, StoredFileHelper storage, char kind, int threads, String source, String psName, - String[] psArgs, long nearLength, MissionRecoveryInfo[] recoveryInfo) { - Intent intent = new Intent(context, DownloadManagerService.class); - intent.setAction(Intent.ACTION_RUN); - intent.putExtra(EXTRA_URLS, urls); - intent.putExtra(EXTRA_KIND, kind); - intent.putExtra(EXTRA_THREADS, threads); - intent.putExtra(EXTRA_SOURCE, source); - intent.putExtra(EXTRA_POSTPROCESSING_NAME, psName); - intent.putExtra(EXTRA_POSTPROCESSING_ARGS, psArgs); - intent.putExtra(EXTRA_NEAR_LENGTH, nearLength); - intent.putExtra(EXTRA_RECOVERY_INFO, recoveryInfo); - - intent.putExtra(EXTRA_PARENT_PATH, storage.getParentUri()); - intent.putExtra(EXTRA_PATH, storage.getUri()); - intent.putExtra(EXTRA_STORAGE_TAG, storage.getTag()); + String[] psArgs, long nearLength, + ArrayList recoveryInfo) { + final Intent intent = new Intent(context, DownloadManagerService.class) + .setAction(Intent.ACTION_RUN) + .putExtra(EXTRA_URLS, urls) + .putExtra(EXTRA_KIND, kind) + .putExtra(EXTRA_THREADS, threads) + .putExtra(EXTRA_SOURCE, source) + .putExtra(EXTRA_POSTPROCESSING_NAME, psName) + .putExtra(EXTRA_POSTPROCESSING_ARGS, psArgs) + .putExtra(EXTRA_NEAR_LENGTH, nearLength) + .putExtra(EXTRA_RECOVERY_INFO, recoveryInfo) + .putExtra(EXTRA_PARENT_PATH, storage.getParentUri()) + .putExtra(EXTRA_PATH, storage.getUri()) + .putExtra(EXTRA_STORAGE_TAG, storage.getTag()); context.startService(intent); } private void startMission(Intent intent) { String[] urls = intent.getStringArrayExtra(EXTRA_URLS); - Uri path = intent.getParcelableExtra(EXTRA_PATH); - Uri parentPath = intent.getParcelableExtra(EXTRA_PARENT_PATH); + Uri path = IntentCompat.getParcelableExtra(intent, EXTRA_PATH, Uri.class); + Uri parentPath = IntentCompat.getParcelableExtra(intent, EXTRA_PARENT_PATH, Uri.class); int threads = intent.getIntExtra(EXTRA_THREADS, 1); char kind = intent.getCharExtra(EXTRA_KIND, '?'); String psName = intent.getStringExtra(EXTRA_POSTPROCESSING_NAME); @@ -419,7 +390,9 @@ public class DownloadManagerService extends Service { String source = intent.getStringExtra(EXTRA_SOURCE); long nearLength = intent.getLongExtra(EXTRA_NEAR_LENGTH, 0); String tag = intent.getStringExtra(EXTRA_STORAGE_TAG); - Parcelable[] parcelRecovery = intent.getParcelableArrayExtra(EXTRA_RECOVERY_INFO); + final var recovery = IntentCompat.getParcelableArrayListExtra(intent, EXTRA_RECOVERY_INFO, + MissionRecoveryInfo.class); + Objects.requireNonNull(recovery); StoredFileHelper storage; try { @@ -434,15 +407,11 @@ public class DownloadManagerService extends Service { else ps = Postprocessing.getAlgorithm(psName, psArgs); - MissionRecoveryInfo[] recovery = new MissionRecoveryInfo[parcelRecovery.length]; - for (int i = 0; i < parcelRecovery.length; i++) - recovery[i] = (MissionRecoveryInfo) parcelRecovery[i]; - final DownloadMission mission = new DownloadMission(urls, storage, kind, ps); mission.threadCount = threads; mission.source = source; mission.nearLength = nearLength; - mission.recoveryInfo = recovery; + mission.recoveryInfo = recovery.toArray(new MissionRecoveryInfo[0]); if (ps != null) ps.setTemporalDir(DownloadManager.pickAvailableTemporalDir(this)); @@ -473,12 +442,7 @@ public class DownloadManagerService extends Service { if (downloadDoneCount == 1) { downloadDoneList.append(name); - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - downloadDoneNotification.setContentTitle(getString(R.string.app_name)); - } else { - downloadDoneNotification.setContentTitle(null); - } - + downloadDoneNotification.setContentTitle(null); downloadDoneNotification.setContentText(Localization.downloadCount(this, downloadDoneCount)); downloadDoneNotification.setStyle(new NotificationCompat.BigTextStyle() .setBigContentTitle(Localization.downloadCount(this, downloadDoneCount)) @@ -497,7 +461,7 @@ public class DownloadManagerService extends Service { } public void notifyFailedDownload(DownloadMission mission) { - if (!mDownloadNotificationEnable || mFailedDownloads.indexOfValue(mission) >= 0) return; + if (!mDownloadNotificationEnable || mFailedDownloads.containsValue(mission)) return; int id = downloadFailedNotificationID++; mFailedDownloads.put(id, mission); @@ -511,23 +475,18 @@ public class DownloadManagerService extends Service { .setContentIntent(mOpenDownloadList); } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - downloadFailedNotification.setContentTitle(getString(R.string.app_name)); - downloadFailedNotification.setStyle(new NotificationCompat.BigTextStyle() - .bigText(getString(R.string.download_failed).concat(": ").concat(mission.storage.getName()))); - } else { - downloadFailedNotification.setContentTitle(getString(R.string.download_failed)); - downloadFailedNotification.setContentText(mission.storage.getName()); - downloadFailedNotification.setStyle(new NotificationCompat.BigTextStyle() - .bigText(mission.storage.getName())); - } + downloadFailedNotification.setContentTitle(getString(R.string.download_failed)); + downloadFailedNotification.setContentText(mission.storage.getName()); + downloadFailedNotification.setStyle(new NotificationCompat.BigTextStyle() + .bigText(mission.storage.getName())); mNotificationManager.notify(id, downloadFailedNotification.build()); } private PendingIntent makePendingIntent(String action) { Intent intent = new Intent(this, DownloadManagerService.class).setAction(action); - return PendingIntent.getService(this, intent.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT); + return PendingIntentCompat.getService(this, intent.hashCode(), intent, + PendingIntent.FLAG_UPDATE_CURRENT, false); } private void manageLock(boolean acquire) { @@ -556,12 +515,7 @@ public class DownloadManagerService extends Service { if (path.charAt(0) == File.separatorChar) { Log.i(TAG, "Old save path style present: " + path); - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) - path = Uri.fromFile(new File(path)).toString(); - else - path = ""; - + path = ""; mPrefs.edit().putString(getString(prefKey), "").apply(); } diff --git a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java index 39bdefbe0..31e7f663d 100644 --- a/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java +++ b/app/src/main/java/us/shandian/giga/ui/adapter/MissionAdapter.java @@ -1,74 +1,5 @@ package us.shandian.giga.ui.adapter; -import android.annotation.SuppressLint; -import android.app.NotificationManager; -import android.content.Context; -import android.content.Intent; -import android.graphics.Color; -import android.net.Uri; -import android.os.Build; -import android.os.Handler; -import android.os.Message; -import android.util.Log; -import android.util.SparseArray; -import android.view.HapticFeedbackConstants; -import android.view.LayoutInflater; -import android.view.Menu; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; -import android.webkit.MimeTypeMap; -import android.widget.ImageView; -import android.widget.PopupMenu; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.StringRes; -import androidx.appcompat.app.AlertDialog; -import androidx.core.app.NotificationCompat; -import androidx.core.content.ContextCompat; -import androidx.core.content.FileProvider; -import androidx.core.view.ViewCompat; -import androidx.recyclerview.widget.DiffUtil; -import androidx.recyclerview.widget.RecyclerView; -import androidx.recyclerview.widget.RecyclerView.Adapter; -import androidx.recyclerview.widget.RecyclerView.ViewHolder; - -import com.google.android.material.snackbar.Snackbar; - -import org.schabi.newpipe.BuildConfig; -import org.schabi.newpipe.R; -import org.schabi.newpipe.error.ErrorUtil; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.error.ErrorInfo; -import org.schabi.newpipe.error.UserAction; -import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.external_communication.ShareUtils; - -import java.io.File; -import java.net.URI; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Iterator; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Observable; -import io.reactivex.rxjava3.disposables.CompositeDisposable; -import io.reactivex.rxjava3.schedulers.Schedulers; -import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.get.FinishedMission; -import us.shandian.giga.get.Mission; -import us.shandian.giga.get.MissionRecoveryInfo; -import org.schabi.newpipe.streams.io.StoredFileHelper; -import us.shandian.giga.service.DownloadManager; -import us.shandian.giga.service.DownloadManagerService; -import us.shandian.giga.ui.common.Deleter; -import us.shandian.giga.ui.common.ProgressDrawable; -import us.shandian.giga.util.Utility; - -import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; import static android.content.Intent.FLAG_GRANT_PREFIX_URI_PERMISSION; import static android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION; import static us.shandian.giga.get.DownloadMission.ERROR_CONNECT_HOST; @@ -88,19 +19,83 @@ import static us.shandian.giga.get.DownloadMission.ERROR_TIMEOUT; import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_EXCEPTION; import static us.shandian.giga.get.DownloadMission.ERROR_UNKNOWN_HOST; +import android.annotation.SuppressLint; +import android.app.NotificationManager; +import android.content.Context; +import android.content.Intent; +import android.graphics.Color; +import android.net.Uri; +import android.os.Build; +import android.os.Handler; +import android.os.Message; +import android.util.Log; +import android.view.HapticFeedbackConstants; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.webkit.MimeTypeMap; +import android.widget.ImageView; +import android.widget.PopupMenu; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; +import androidx.core.app.NotificationCompat; +import androidx.core.content.ContextCompat; +import androidx.core.content.FileProvider; +import androidx.core.os.HandlerCompat; +import androidx.recyclerview.widget.DiffUtil; +import androidx.recyclerview.widget.RecyclerView; +import androidx.recyclerview.widget.RecyclerView.Adapter; +import androidx.recyclerview.widget.RecyclerView.ViewHolder; + +import com.google.android.material.snackbar.Snackbar; + +import org.schabi.newpipe.BuildConfig; +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.NewPipe; +import org.schabi.newpipe.streams.io.StoredFileHelper; +import org.schabi.newpipe.util.Localization; +import org.schabi.newpipe.util.NavigationHelper; +import org.schabi.newpipe.util.external_communication.ShareUtils; + +import java.io.File; +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; + +import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; +import io.reactivex.rxjava3.core.Observable; +import io.reactivex.rxjava3.disposables.CompositeDisposable; +import io.reactivex.rxjava3.schedulers.Schedulers; +import us.shandian.giga.get.DownloadMission; +import us.shandian.giga.get.FinishedMission; +import us.shandian.giga.get.Mission; +import us.shandian.giga.get.MissionRecoveryInfo; +import us.shandian.giga.service.DownloadManager; +import us.shandian.giga.service.DownloadManagerService; +import us.shandian.giga.ui.common.Deleter; +import us.shandian.giga.ui.common.ProgressDrawable; +import us.shandian.giga.util.Utility; + public class MissionAdapter extends Adapter implements Handler.Callback { - private static final SparseArray ALGORITHMS = new SparseArray<>(); private static final String TAG = "MissionAdapter"; private static final String UNDEFINED_PROGRESS = "--.-%"; private static final String DEFAULT_MIME_TYPE = "*/*"; private static final String UNDEFINED_ETA = "--:--"; - private static final int HASH_NOTIFICATION_ID = 123790; + private static final String UPDATER = "updater"; + private static final String DELETE = "deleteFinishedDownloads"; - static { - ALGORITHMS.put(R.id.md5, "MD5"); - ALGORITHMS.put(R.id.sha1, "SHA1"); - } + private static final int HASH_NOTIFICATION_ID = 123790; private final Context mContext; private final LayoutInflater mInflater; @@ -119,9 +114,6 @@ public class MissionAdapter extends Adapter implements Handler.Callb private final ArrayList mHidden; private Snackbar mSnackbar; - private final Runnable rUpdater = this::updater; - private final Runnable rDelete = this::deleteFinishedDownloads; - private final CompositeDisposable compositeDisposable = new CompositeDisposable(); public MissionAdapter(Context context, @NonNull DownloadManager downloadManager, View emptyMessage, View root) { @@ -351,19 +343,8 @@ public class MissionAdapter extends Adapter implements Handler.Callb Intent intent = new Intent(Intent.ACTION_VIEW); intent.setDataAndType(resolveShareableUri(mission), mimeType); intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - intent.addFlags(FLAG_GRANT_PREFIX_URI_PERMISSION); - } - if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) { - intent.addFlags(FLAG_ACTIVITY_NEW_TASK); - } - - if (intent.resolveActivity(mContext.getPackageManager()) != null) { - ShareUtils.openIntentInApp(mContext, intent, false); - } else { - Toast.makeText(mContext, R.string.toast_no_player, Toast.LENGTH_LONG).show(); - } + intent.addFlags(FLAG_GRANT_PREFIX_URI_PERMISSION); + ShareUtils.openIntentInApp(mContext, intent); } private void shareFile(Mission mission) { @@ -509,7 +490,7 @@ public class MissionAdapter extends Adapter implements Handler.Callb showError(mission, UserAction.DOWNLOAD_POSTPROCESSING, R.string.error_postprocessing_failed); return; case ERROR_INSUFFICIENT_STORAGE: - msg = R.string.error_insufficient_storage; + msg = R.string.error_insufficient_storage_left; break; case ERROR_UNKNOWN_EXCEPTION: if (mission.errObject != null) { @@ -557,7 +538,6 @@ public class MissionAdapter extends Adapter implements Handler.Callb builder.setNegativeButton(R.string.ok, (dialog, which) -> dialog.cancel()) .setTitle(mission.storage.getName()) - .create() .show(); } @@ -606,12 +586,12 @@ public class MissionAdapter extends Adapter implements Handler.Callb i.remove(); } applyChanges(); - mHandler.removeCallbacks(rDelete); + mHandler.removeCallbacksAndMessages(DELETE); }); mSnackbar.setActionTextColor(Color.YELLOW); mSnackbar.show(); - mHandler.postDelayed(rDelete, 5000); + HandlerCompat.postDelayed(mHandler, this::deleteFinishedDownloads, DELETE, 5000); } else if (!delete) { mDownloadManager.forgetFinishedDownloads(); applyChanges(); @@ -700,11 +680,11 @@ public class MissionAdapter extends Adapter implements Handler.Callb .build()); final StoredFileHelper storage = h.item.mission.storage; compositeDisposable.add( - Observable.fromCallable(() -> Utility.checksum(storage, ALGORITHMS.get(id))) + Observable.fromCallable(() -> Utility.checksum(storage, id)) .subscribeOn(Schedulers.computation()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(result -> { - Utility.copyToClipboard(mContext, result); + ShareUtils.copyToClipboard(mContext, result); notificationManager.cancel(HASH_NOTIFICATION_ID); }) ); @@ -797,15 +777,14 @@ public class MissionAdapter extends Adapter implements Handler.Callb public void onResume() { mDeleter.resume(); - mHandler.post(rUpdater); + HandlerCompat.postDelayed(mHandler, this::updater, UPDATER, 0); } public void onPaused() { mDeleter.pause(); - mHandler.removeCallbacks(rUpdater); + mHandler.removeCallbacksAndMessages(UPDATER); } - public void recoverMission(DownloadMission mission) { ViewHolderItem h = getViewHolder(mission); if (h == null) return; @@ -828,7 +807,7 @@ public class MissionAdapter extends Adapter implements Handler.Callb updateProgress(h); } - mHandler.postDelayed(rUpdater, 1000); + HandlerCompat.postDelayed(mHandler, this::updater, UPDATER, 1000); } private boolean isNotFinite(double value) { @@ -871,7 +850,7 @@ public class MissionAdapter extends Adapter implements Handler.Callb super(view); progress = new ProgressDrawable(); - ViewCompat.setBackground(itemView.findViewById(R.id.item_bkg), progress); + itemView.findViewById(R.id.item_bkg).setBackground(progress); status = itemView.findViewById(R.id.item_status); name = itemView.findViewById(R.id.item_name); diff --git a/app/src/main/java/us/shandian/giga/ui/common/Deleter.java b/app/src/main/java/us/shandian/giga/ui/common/Deleter.java index c554766ff..1902076d6 100644 --- a/app/src/main/java/us/shandian/giga/ui/common/Deleter.java +++ b/app/src/main/java/us/shandian/giga/ui/common/Deleter.java @@ -6,6 +6,8 @@ import android.graphics.Color; import android.os.Handler; import android.view.View; +import androidx.core.os.HandlerCompat; + import com.google.android.material.snackbar.Snackbar; import org.schabi.newpipe.R; @@ -19,6 +21,10 @@ import us.shandian.giga.service.DownloadManager.MissionIterator; import us.shandian.giga.ui.adapter.MissionAdapter; public class Deleter { + private static final String COMMIT = "commit"; + private static final String NEXT = "next"; + private static final String SHOW = "show"; + private static final int TIMEOUT = 5000;// ms private static final int DELAY = 350;// ms private static final int DELAY_RESUME = 400;// ms @@ -34,10 +40,6 @@ public class Deleter { private final Handler mHandler; private final View mView; - private final Runnable rShow; - private final Runnable rNext; - private final Runnable rCommit; - public Deleter(View v, Context c, MissionAdapter a, DownloadManager d, MissionIterator i, Handler h) { mView = v; mContext = c; @@ -46,21 +48,15 @@ public class Deleter { mIterator = i; mHandler = h; - // use variables to know the reference of the lambdas - rShow = this::show; - rNext = this::next; - rCommit = this::commit; - items = new ArrayList<>(2); } public void append(Mission item) { - /* If a mission is removed from the list while the Snackbar for a previously * removed item is still showing, commit the action for the previous item * immediately. This prevents Snackbars from stacking up in reverse order. */ - mHandler.removeCallbacks(rCommit); + mHandler.removeCallbacksAndMessages(COMMIT); commit(); mIterator.hide(item); @@ -82,7 +78,7 @@ public class Deleter { pause(); running = true; - mHandler.postDelayed(rNext, DELAY); + HandlerCompat.postDelayed(mHandler, this::next, NEXT, DELAY); } private void next() { @@ -95,7 +91,7 @@ public class Deleter { snackbar.setActionTextColor(Color.YELLOW); snackbar.show(); - mHandler.postDelayed(rCommit, TIMEOUT); + HandlerCompat.postDelayed(mHandler, this::commit, COMMIT, TIMEOUT); } private void commit() { @@ -124,15 +120,16 @@ public class Deleter { public void pause() { running = false; - mHandler.removeCallbacks(rNext); - mHandler.removeCallbacks(rShow); - mHandler.removeCallbacks(rCommit); + mHandler.removeCallbacksAndMessages(NEXT); + mHandler.removeCallbacksAndMessages(SHOW); + mHandler.removeCallbacksAndMessages(COMMIT); if (snackbar != null) snackbar.dismiss(); } public void resume() { - if (running) return; - mHandler.postDelayed(rShow, DELAY_RESUME); + if (!running) { + HandlerCompat.postDelayed(mHandler, this::show, SHOW, DELAY_RESUME); + } } public void dispose() { diff --git a/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java b/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java index b5fc0297c..690ed4a97 100644 --- a/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java +++ b/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java @@ -211,12 +211,11 @@ public class MissionsFragment extends Fragment { .setTitle(R.string.clear_download_history) .setMessage(R.string.confirm_prompt) // Intentionally misusing buttons' purpose in order to achieve good order - .setNegativeButton(R.string.clear_download_history, - (dialog, which) -> mAdapter.clearFinishedDownloads(false)) + .setNegativeButton(R.string.clear_download_history, (dialog, which) -> + mAdapter.clearFinishedDownloads(false)) .setNeutralButton(R.string.cancel, null) - .setPositiveButton(R.string.delete_downloaded_files, - (dialog, which) -> showDeleteDownloadedFilesConfirmationPrompt()) - .create() + .setPositiveButton(R.string.delete_downloaded_files, (dialog, which) -> + showDeleteDownloadedFilesConfirmationPrompt()) .show(); } @@ -225,9 +224,8 @@ public class MissionsFragment extends Fragment { new AlertDialog.Builder(mContext) .setTitle(R.string.delete_downloaded_files_confirm) .setNegativeButton(R.string.cancel, null) - .setPositiveButton(R.string.ok, - (dialog, which) -> mAdapter.clearFinishedDownloads(true)) - .create() + .setPositiveButton(R.string.ok, (dialog, which) -> + mAdapter.clearFinishedDownloads(true)) .show(); } diff --git a/app/src/main/java/us/shandian/giga/util/Utility.java b/app/src/main/java/us/shandian/giga/util/Utility.java index 9e6787d5d..c75269757 100644 --- a/app/src/main/java/us/shandian/giga/util/Utility.java +++ b/app/src/main/java/us/shandian/giga/util/Utility.java @@ -1,11 +1,10 @@ package us.shandian.giga.util; -import android.content.ClipData; -import android.content.ClipboardManager; import android.content.Context; import android.os.Build; +import android.os.Environment; +import android.os.StatFs; import android.util.Log; -import android.widget.Toast; import androidx.annotation.ColorInt; import androidx.annotation.DrawableRes; @@ -13,8 +12,11 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; +import com.google.android.exoplayer2.util.Util; + import org.schabi.newpipe.R; -import org.schabi.newpipe.streams.io.SharpStream; +import org.schabi.newpipe.streams.io.SharpInputStream; +import org.schabi.newpipe.streams.io.StoredFileHelper; import java.io.BufferedOutputStream; import java.io.File; @@ -25,11 +27,9 @@ import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.io.Serializable; import java.net.HttpURLConnection; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; import java.util.Locale; -import org.schabi.newpipe.streams.io.StoredFileHelper; +import okio.ByteString; public class Utility { @@ -40,6 +40,20 @@ public class Utility { UNKNOWN } + /** + * Get amount of free system's memory. + * @return free memory (bytes) + */ + public static long getSystemFreeMemory() { + try { + final StatFs statFs = new StatFs(Environment.getExternalStorageDirectory().getPath()); + return statFs.getAvailableBlocksLong() * statFs.getBlockSizeLong(); + } catch (final Exception e) { + // do nothing + } + return -1; + } + public static String formatBytes(long bytes) { Locale locale = Locale.getDefault(); if (bytes < 1024) { @@ -191,56 +205,18 @@ public class Utility { } } - public static void copyToClipboard(Context context, String str) { - ClipboardManager cm = ContextCompat.getSystemService(context, ClipboardManager.class); - - if (cm == null) { - Toast.makeText(context, R.string.permission_denied, Toast.LENGTH_LONG).show(); - return; + public static String checksum(final StoredFileHelper source, final int algorithmId) + throws IOException { + ByteString byteString; + try (var inputStream = new SharpInputStream(source.getStream())) { + byteString = ByteString.of(Util.toByteArray(inputStream)); } - - cm.setPrimaryClip(ClipData.newPlainText("text", str)); - Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); - } - - public static String checksum(StoredFileHelper source, String algorithm) { - MessageDigest md; - - try { - md = MessageDigest.getInstance(algorithm); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException(e); + if (algorithmId == R.id.md5) { + byteString = byteString.md5(); + } else if (algorithmId == R.id.sha1) { + byteString = byteString.sha1(); } - - SharpStream i; - - try { - i = source.getStream(); - } catch (Exception e) { - throw new RuntimeException(e); - } - - byte[] buf = new byte[1024]; - int len; - - try { - while ((len = i.read(buf)) != -1) { - md.update(buf, 0, len); - } - } catch (IOException e) { - // nothing to do - } - - byte[] digest = md.digest(); - - // HEX - StringBuilder sb = new StringBuilder(); - for (byte b : digest) { - sb.append(Integer.toString((b & 0xff) + 0x100, 16).substring(1)); - } - - return sb.toString(); - + return byteString.hex(); } @SuppressWarnings("ResultOfMethodCallIgnored") @@ -269,14 +245,36 @@ public class Utility { return -1; } + /** + * Get the content length of the entire file even if the HTTP response is partial + * (response code 206). + * @param connection http connection + * @return content length + */ + public static long getTotalContentLength(final HttpURLConnection connection) { + try { + if (connection.getResponseCode() == 206) { + final String rangeStr = connection.getHeaderField("Content-Range"); + final String bytesStr = rangeStr.split("/", 2)[1]; + return Long.parseLong(bytesStr); + } else { + return getContentLength(connection); + } + } catch (Exception err) { + // nothing to do + } + + return -1; + } + private static String pad(int number) { return number < 10 ? ("0" + number) : String.valueOf(number); } - public static String stringifySeconds(double seconds) { - int h = (int) Math.floor(seconds / 3600); - int m = (int) Math.floor((seconds - (h * 3600)) / 60); - int s = (int) (seconds - (h * 3600) - (m * 60)); + public static String stringifySeconds(final long seconds) { + final int h = (int) Math.floorDiv(seconds, 3600); + final int m = (int) Math.floorDiv(seconds - (h * 3600L), 60); + final int s = (int) (seconds - (h * 3600) - (m * 60)); String str = ""; diff --git a/app/src/main/res/drawable-hdpi/ic_close_white.png b/app/src/main/res/drawable-hdpi/ic_close_white.png deleted file mode 100644 index 5546fb0ff..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_close_white.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_hourglass_top_white.png b/app/src/main/res/drawable-hdpi/ic_hourglass_top_white.png deleted file mode 100644 index 1f1f9046c..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_hourglass_top_white.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/ic_newpipe_triangle_white.png b/app/src/main/res/drawable-hdpi/ic_newpipe_triangle_white.png index cd3b6d182..dd3638579 100644 Binary files a/app/src/main/res/drawable-hdpi/ic_newpipe_triangle_white.png and b/app/src/main/res/drawable-hdpi/ic_newpipe_triangle_white.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_replay_white.png b/app/src/main/res/drawable-hdpi/ic_replay_white.png deleted file mode 100644 index c706f8097..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_replay_white.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_close_white.png b/app/src/main/res/drawable-mdpi/ic_close_white.png deleted file mode 100644 index 1037ea613..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_close_white.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_hourglass_top_white.png b/app/src/main/res/drawable-mdpi/ic_hourglass_top_white.png deleted file mode 100644 index 734e8eca3..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_hourglass_top_white.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/ic_newpipe_triangle_white.png b/app/src/main/res/drawable-mdpi/ic_newpipe_triangle_white.png index f967011b0..e5d102eda 100644 Binary files a/app/src/main/res/drawable-mdpi/ic_newpipe_triangle_white.png and b/app/src/main/res/drawable-mdpi/ic_newpipe_triangle_white.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_replay_white.png b/app/src/main/res/drawable-mdpi/ic_replay_white.png deleted file mode 100644 index 24558a423..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_replay_white.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/buddy.png b/app/src/main/res/drawable-nodpi/buddy.png deleted file mode 100644 index 8713ee02b..000000000 Binary files a/app/src/main/res/drawable-nodpi/buddy.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/buddy_channel_item.png b/app/src/main/res/drawable-nodpi/buddy_channel_item.png deleted file mode 100644 index 64d4cb1a0..000000000 Binary files a/app/src/main/res/drawable-nodpi/buddy_channel_item.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/channel_banner.png b/app/src/main/res/drawable-nodpi/channel_banner.png deleted file mode 100644 index 12e70bb6d..000000000 Binary files a/app/src/main/res/drawable-nodpi/channel_banner.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/dummy_thumbnail.png b/app/src/main/res/drawable-nodpi/dummy_thumbnail.png deleted file mode 100644 index 86f454186..000000000 Binary files a/app/src/main/res/drawable-nodpi/dummy_thumbnail.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/dummy_thumbnail_dark.png b/app/src/main/res/drawable-nodpi/dummy_thumbnail_dark.png deleted file mode 100644 index 02f698918..000000000 Binary files a/app/src/main/res/drawable-nodpi/dummy_thumbnail_dark.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/dummy_thumbnail_playlist.png b/app/src/main/res/drawable-nodpi/dummy_thumbnail_playlist.png deleted file mode 100644 index 9ba84fdb4..000000000 Binary files a/app/src/main/res/drawable-nodpi/dummy_thumbnail_playlist.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/newpipe_logo_nude_shadow.png b/app/src/main/res/drawable-nodpi/newpipe_logo_nude_shadow.png deleted file mode 100644 index 49c12af83..000000000 Binary files a/app/src/main/res/drawable-nodpi/newpipe_logo_nude_shadow.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/place_holder_bandcamp.png b/app/src/main/res/drawable-nodpi/place_holder_bandcamp.png deleted file mode 100644 index 13c44b649..000000000 Binary files a/app/src/main/res/drawable-nodpi/place_holder_bandcamp.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/place_holder_circle.png b/app/src/main/res/drawable-nodpi/place_holder_circle.png deleted file mode 100644 index 630d0454e..000000000 Binary files a/app/src/main/res/drawable-nodpi/place_holder_circle.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/place_holder_cloud.png b/app/src/main/res/drawable-nodpi/place_holder_cloud.png deleted file mode 100644 index c4ba2a6f4..000000000 Binary files a/app/src/main/res/drawable-nodpi/place_holder_cloud.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/place_holder_gadse.png b/app/src/main/res/drawable-nodpi/place_holder_gadse.png deleted file mode 100644 index 9b479ed4f..000000000 Binary files a/app/src/main/res/drawable-nodpi/place_holder_gadse.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/place_holder_peertube.png b/app/src/main/res/drawable-nodpi/place_holder_peertube.png deleted file mode 100644 index 81dfdb8cc..000000000 Binary files a/app/src/main/res/drawable-nodpi/place_holder_peertube.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/place_holder_youtube.png b/app/src/main/res/drawable-nodpi/place_holder_youtube.png deleted file mode 100644 index d147c6643..000000000 Binary files a/app/src/main/res/drawable-nodpi/place_holder_youtube.png and /dev/null differ diff --git a/app/src/main/res/drawable-nodpi/placeholder_channel_banner.png b/app/src/main/res/drawable-nodpi/placeholder_channel_banner.png new file mode 100644 index 000000000..cad11fa50 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/placeholder_channel_banner.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_close_white.png b/app/src/main/res/drawable-xhdpi/ic_close_white.png deleted file mode 100644 index 568663ed0..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_close_white.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_hourglass_top_white.png b/app/src/main/res/drawable-xhdpi/ic_hourglass_top_white.png deleted file mode 100644 index e53c699db..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_hourglass_top_white.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/ic_newpipe_triangle_white.png b/app/src/main/res/drawable-xhdpi/ic_newpipe_triangle_white.png index 5fe229a96..a875fac86 100644 Binary files a/app/src/main/res/drawable-xhdpi/ic_newpipe_triangle_white.png and b/app/src/main/res/drawable-xhdpi/ic_newpipe_triangle_white.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_replay_white.png b/app/src/main/res/drawable-xhdpi/ic_replay_white.png deleted file mode 100644 index 47b75ceb9..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_replay_white.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_close_white.png b/app/src/main/res/drawable-xxhdpi/ic_close_white.png deleted file mode 100644 index 990895143..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_close_white.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_hourglass_top_white.png b/app/src/main/res/drawable-xxhdpi/ic_hourglass_top_white.png deleted file mode 100644 index b8b98737f..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_hourglass_top_white.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_newpipe_triangle_white.png b/app/src/main/res/drawable-xxhdpi/ic_newpipe_triangle_white.png index 595d5ab11..e6e661b41 100644 Binary files a/app/src/main/res/drawable-xxhdpi/ic_newpipe_triangle_white.png and b/app/src/main/res/drawable-xxhdpi/ic_newpipe_triangle_white.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_replay_white.png b/app/src/main/res/drawable-xxhdpi/ic_replay_white.png deleted file mode 100644 index 9a8e1507d..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_replay_white.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_close_white.png b/app/src/main/res/drawable-xxxhdpi/ic_close_white.png deleted file mode 100644 index 06854ca49..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_close_white.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_hourglass_top_white.png b/app/src/main/res/drawable-xxxhdpi/ic_hourglass_top_white.png deleted file mode 100644 index a0d5ba81e..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_hourglass_top_white.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_newpipe_triangle_white.png b/app/src/main/res/drawable-xxxhdpi/ic_newpipe_triangle_white.png index 699e0c158..218594399 100644 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_newpipe_triangle_white.png and b/app/src/main/res/drawable-xxxhdpi/ic_newpipe_triangle_white.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_replay_white.png b/app/src/main/res/drawable-xxxhdpi/ic_replay_white.png deleted file mode 100644 index 6a9092761..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_replay_white.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_fast_forward.xml b/app/src/main/res/drawable/ic_checklist.xml similarity index 58% rename from app/src/main/res/drawable/ic_fast_forward.xml rename to app/src/main/res/drawable/ic_checklist.xml index 4edc96a9b..27bed183f 100644 --- a/app/src/main/res/drawable/ic_fast_forward.xml +++ b/app/src/main/res/drawable/ic_checklist.xml @@ -6,5 +6,5 @@ android:viewportHeight="24"> + android:pathData="M22,7h-9v2h9V7zM22,15h-9v2h9V15zM5.54,11L2,7.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,11zM5.54,19L2,15.46l1.41,-1.41l2.12,2.12l4.24,-4.24l1.41,1.41L5.54,19z" /> diff --git a/app/src/main/res/drawable/ic_circle.xml b/app/src/main/res/drawable/ic_circle.xml new file mode 100644 index 000000000..dc0a218b8 --- /dev/null +++ b/app/src/main/res/drawable/ic_circle.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_cloud.xml b/app/src/main/res/drawable/ic_cloud.xml new file mode 100644 index 000000000..15a682b76 --- /dev/null +++ b/app/src/main/res/drawable/ic_cloud.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_fast_rewind.xml b/app/src/main/res/drawable/ic_fast_rewind.xml deleted file mode 100644 index 33d9f56ef..000000000 --- a/app/src/main/res/drawable/ic_fast_rewind.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_format_list_numbered.xml b/app/src/main/res/drawable/ic_format_list_numbered.xml deleted file mode 100644 index b11666c56..000000000 --- a/app/src/main/res/drawable/ic_format_list_numbered.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_heart.xml b/app/src/main/res/drawable/ic_heart.xml index 86d1f0527..248f9788b 100644 --- a/app/src/main/res/drawable/ic_heart.xml +++ b/app/src/main/res/drawable/ic_heart.xml @@ -2,9 +2,9 @@ android:width="24dp" android:height="24dp" android:tint="#E53935" - android:viewportWidth="24.0" - android:viewportHeight="24.0"> + android:viewportWidth="24" + android:viewportHeight="24"> + android:pathData="M12,21.35l-1.45,-1.32C5.4,15.36 2,12.28 2,8.5 2,5.42 4.42,3 7.5,3c1.74,0 3.41,0.81 4.5,2.09C13.09,3.81 14.76,3 16.5,3 19.58,3 22,5.42 22,8.5c0,3.78 -3.4,6.86 -8.55,11.54L12,21.35z" /> diff --git a/app/src/main/res/drawable/ic_list_check.xml b/app/src/main/res/drawable/ic_list_check.xml deleted file mode 100644 index 37d806044..000000000 --- a/app/src/main/res/drawable/ic_list_check.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_menu_book.xml b/app/src/main/res/drawable/ic_menu_book.xml new file mode 100644 index 000000000..4cd4fb3a4 --- /dev/null +++ b/app/src/main/res/drawable/ic_menu_book.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_placeholder_bandcamp.xml b/app/src/main/res/drawable/ic_placeholder_bandcamp.xml new file mode 100644 index 000000000..411e69854 --- /dev/null +++ b/app/src/main/res/drawable/ic_placeholder_bandcamp.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_placeholder_media_ccc.xml b/app/src/main/res/drawable/ic_placeholder_media_ccc.xml new file mode 100644 index 000000000..cdc743cb2 --- /dev/null +++ b/app/src/main/res/drawable/ic_placeholder_media_ccc.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_placeholder_peertube.xml b/app/src/main/res/drawable/ic_placeholder_peertube.xml new file mode 100644 index 000000000..263d92d70 --- /dev/null +++ b/app/src/main/res/drawable/ic_placeholder_peertube.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_play_seek_triangle.xml b/app/src/main/res/drawable/ic_play_seek_triangle.xml index 1aee026db..9c257c423 100644 --- a/app/src/main/res/drawable/ic_play_seek_triangle.xml +++ b/app/src/main/res/drawable/ic_play_seek_triangle.xml @@ -1,11 +1,9 @@ - + android:width="16dp" + android:height="20dp" + android:viewportWidth="24" + android:viewportHeight="24"> - + android:fillColor="#FFFFFF" + android:pathData="M3,2 L22,12 L3,22 Z" /> diff --git a/app/src/main/res/drawable/ic_smart_display.xml b/app/src/main/res/drawable/ic_smart_display.xml new file mode 100644 index 000000000..d666a3b37 --- /dev/null +++ b/app/src/main/res/drawable/ic_smart_display.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_subscriptions.xml b/app/src/main/res/drawable/ic_subscriptions.xml new file mode 100644 index 000000000..f2ac7bec2 --- /dev/null +++ b/app/src/main/res/drawable/ic_subscriptions.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_visibility_off.xml b/app/src/main/res/drawable/ic_visibility_off.xml deleted file mode 100644 index f833d5e06..000000000 --- a/app/src/main/res/drawable/ic_visibility_off.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/placeholder_person.xml b/app/src/main/res/drawable/placeholder_person.xml new file mode 100644 index 000000000..2b3229e8f --- /dev/null +++ b/app/src/main/res/drawable/placeholder_person.xml @@ -0,0 +1,20 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/placeholder_thumbnail_playlist.xml b/app/src/main/res/drawable/placeholder_thumbnail_playlist.xml new file mode 100644 index 000000000..de286d860 --- /dev/null +++ b/app/src/main/res/drawable/placeholder_thumbnail_playlist.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/placeholder_thumbnail_video.xml b/app/src/main/res/drawable/placeholder_thumbnail_video.xml new file mode 100644 index 000000000..0b262f923 --- /dev/null +++ b/app/src/main/res/drawable/placeholder_thumbnail_video.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout-land/activity_player_queue_control.xml b/app/src/main/res/layout-land/activity_player_queue_control.xml index c2359552e..a5df5e566 100644 --- a/app/src/main/res/layout-land/activity_player_queue_control.xml +++ b/app/src/main/res/layout-land/activity_player_queue_control.xml @@ -123,7 +123,7 @@ android:scaleType="fitCenter" android:src="@drawable/exo_controls_rewind" android:tint="?attr/colorAccent" - tools:ignore="ContentDescription" /> + android:contentDescription="@string/rewind" /> + android:contentDescription="@string/pause" /> + android:contentDescription="@string/forward" /> + android:contentDescription="@string/previous_stream" /> + android:contentDescription="@string/notification_action_repeat" /> + android:contentDescription="@string/notification_action_shuffle" /> + android:contentDescription="@string/next_stream" /> diff --git a/app/src/main/res/layout-land/list_stream_card_item.xml b/app/src/main/res/layout-land/list_stream_card_item.xml new file mode 120000 index 000000000..70228ee1d --- /dev/null +++ b/app/src/main/res/layout-land/list_stream_card_item.xml @@ -0,0 +1 @@ +../layout/list_stream_item.xml \ No newline at end of file diff --git a/app/src/main/res/layout-large-land/fragment_video_detail.xml b/app/src/main/res/layout-large-land/fragment_video_detail.xml index 851085b5b..d18681056 100644 --- a/app/src/main/res/layout-large-land/fragment_video_detail.xml +++ b/app/src/main/res/layout-large-land/fragment_video_detail.xml @@ -57,7 +57,7 @@ android:scaleType="fitCenter" tools:ignore="RtlHardcoded" tools:layout_height="200dp" - tools:src="@drawable/dummy_thumbnail" /> + tools:src="@drawable/placeholder_thumbnail_video" /> @@ -199,7 +199,7 @@ android:layout_gravity="top|end" android:layout_marginTop="11dp" android:layout_marginEnd="10dp" - app:srcCompat="@drawable/ic_expand_more" + android:src="@drawable/ic_expand_more" tools:ignore="ContentDescription" /> @@ -267,23 +267,21 @@ android:layout_height="wrap_content"> + android:contentDescription="@string/detail_uploader_thumbnail_view_description" + android:src="@drawable/placeholder_person" + app:shapeAppearance="@style/CircularImageView" /> @@ -319,25 +317,11 @@ android:marqueeRepeatLimit="marquee_forever" android:scrollHorizontally="true" android:singleLine="true" - android:textAppearance="?android:attr/textAppearanceLarge" android:textSize="@dimen/video_item_detail_uploader_text_size" - android:textStyle="bold" tools:ignore="RtlHardcoded" tools:text="Uploader" /> - @@ -369,7 +353,7 @@ android:layout_height="@dimen/video_item_detail_like_image_height" android:layout_below="@id/detail_view_count_view" android:contentDescription="@string/detail_likes_img_view_description" - app:srcCompat="@drawable/ic_thumb_up" /> + android:src="@drawable/ic_thumb_up" /> + android:textSize="@dimen/detail_control_text_size" /> + android:textSize="@dimen/detail_control_text_size" /> + android:textSize="@dimen/detail_control_text_size" /> + android:textSize="@dimen/detail_control_text_size" /> @@ -526,12 +510,12 @@ android:background="?attr/selectableItemBackgroundBorderless" android:clickable="true" android:contentDescription="@string/share" + android:drawableTop="@drawable/ic_share" android:focusable="true" android:gravity="center" android:paddingVertical="@dimen/detail_control_padding" android:text="@string/share" - android:textSize="@dimen/detail_control_text_size" - app:drawableTopCompat="@drawable/ic_share" /> + android:textSize="@dimen/detail_control_text_size" /> + android:textSize="@dimen/detail_control_text_size" /> + android:textSize="@dimen/detail_control_text_size" /> - + android:textSize="@dimen/detail_control_text_size" /> @@ -615,19 +599,19 @@ android:id="@+id/view_pager" android:layout_width="match_parent" android:layout_height="match_parent" - app:layout_behavior="@string/appbar_scrolling_view_behavior" - android:paddingBottom="48dp"/> + android:paddingBottom="48dp" + app:layout_behavior="@string/appbar_scrolling_view_behavior" /> + app:tabIconTint="?attr/colorAccent" + app:tabIndicatorGravity="top" /> @@ -639,6 +623,7 @@ android:layout_weight="3" /> + + android:src="@drawable/placeholder_thumbnail_video" /> + + + android:scaleType="center" + android:src="@drawable/ic_play_arrow" /> + android:src="@drawable/ic_close" + tools:ignore="RtlSymmetry" /> diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 97ccd199e..01d842812 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -25,7 +25,7 @@ android:layout_gravity="center_horizontal" app:behavior_hideable="true" app:behavior_peekHeight="0dp" - app:layout_behavior="org.schabi.newpipe.player.event.CustomBottomSheetBehavior" /> + app:layout_behavior="org.schabi.newpipe.player.gesture.CustomBottomSheetBehavior" /> diff --git a/app/src/main/res/layout/activity_player_queue_control.xml b/app/src/main/res/layout/activity_player_queue_control.xml index 24e062932..29efa36f9 100644 --- a/app/src/main/res/layout/activity_player_queue_control.xml +++ b/app/src/main/res/layout/activity_player_queue_control.xml @@ -174,8 +174,8 @@ android:clickable="true" android:focusable="true" android:scaleType="fitXY" + android:src="@drawable/ic_repeat" android:tint="?attr/colorAccent" - app:srcCompat="@drawable/ic_repeat" tools:ignore="ContentDescription" /> diff --git a/app/src/main/res/layout/channel_header.xml b/app/src/main/res/layout/channel_header.xml deleted file mode 100644 index 86a308b9f..000000000 --- a/app/src/main/res/layout/channel_header.xml +++ /dev/null @@ -1,131 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/comment_replies_header.xml b/app/src/main/res/layout/comment_replies_header.xml new file mode 100644 index 000000000..ed5ba1a10 --- /dev/null +++ b/app/src/main/res/layout/comment_replies_header.xml @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_feed_group_create.xml b/app/src/main/res/layout/dialog_feed_group_create.xml index a08041e97..464940238 100644 --- a/app/src/main/res/layout/dialog_feed_group_create.xml +++ b/app/src/main/res/layout/dialog_feed_group_create.xml @@ -193,8 +193,8 @@ android:layout_centerVertical="true" android:minWidth="0dp" android:scaleType="centerInside" + android:src="@drawable/ic_delete" android:visibility="gone" - app:srcCompat="@drawable/ic_delete" tools:ignore="ContentDescription" tools:visibility="visible" /> diff --git a/app/src/main/res/layout/dialog_playback_parameter.xml b/app/src/main/res/layout/dialog_playback_parameter.xml index 862b2ea67..cc506cc79 100644 --- a/app/src/main/res/layout/dialog_playback_parameter.xml +++ b/app/src/main/res/layout/dialog_playback_parameter.xml @@ -29,9 +29,9 @@ @@ -117,10 +110,8 @@ android:layout_width="wrap_content" android:layout_height="match_parent" android:layout_alignParentEnd="true" - android:layout_alignParentRight="true" android:layout_centerVertical="true" android:layout_marginEnd="4dp" - android:layout_marginRight="4dp" android:background="?attr/selectableItemBackground" android:clickable="true" android:focusable="true" @@ -138,9 +129,9 @@ android:layout_height="1dp" android:layout_below="@id/tempoControl" android:layout_marginStart="12dp" - android:layout_marginTop="6dp" - android:layout_marginEnd="6dp" - android:layout_marginBottom="6dp" + android:layout_marginTop="5dp" + android:layout_marginEnd="12dp" + android:layout_marginBottom="5dp" android:background="?attr/separator_color" /> - + + + android:layout_marginStart="22dp" + android:layout_marginEnd="22dp" + android:orientation="horizontal" + android:visibility="gone" + tools:visibility="visible"> + android:text="@string/percent" + android:textColor="?attr/colorAccent" /> + + + + + + - - + tools:text="-5%" /> + + + + + + + + + + + - - + tools:text="+5%" /> - - - - - - - + android:orientation="horizontal" + tools:visibility="gone"> - - - - - - + android:layout_height="match_parent" + android:layout_marginLeft="4dp" + android:layout_marginRight="4dp" + android:layout_toStartOf="@+id/pitchSemitoneStepUp" + android:layout_toEndOf="@+id/pitchSemitoneStepDown" + android:orientation="horizontal"> - + + + + + + + + + + + @@ -403,7 +430,8 @@ android:clickable="true" android:focusable="true" android:gravity="center" - android:textColor="?attr/colorAccent" /> + android:textColor="?attr/colorAccent" + tools:text="1%" /> + android:textColor="?attr/colorAccent" + tools:text="5%" /> + android:textColor="?attr/colorAccent" + tools:text="10%" /> + android:textColor="?attr/colorAccent" + tools:text="25%" /> - + android:textColor="?attr/colorAccent" + tools:text="100%" /> - diff --git a/app/src/main/res/layout/dialog_playlists.xml b/app/src/main/res/layout/dialog_playlists.xml index 99c46e552..ab4691fa9 100644 --- a/app/src/main/res/layout/dialog_playlists.xml +++ b/app/src/main/res/layout/dialog_playlists.xml @@ -1,5 +1,4 @@ @@ -20,7 +19,7 @@ android:layout_centerVertical="true" android:layout_marginLeft="12dp" android:layout_marginRight="12dp" - app:srcCompat="@drawable/ic_playlist_add" + android:src="@drawable/ic_playlist_add" tools:ignore="ContentDescription,RtlHardcoded" /> + + diff --git a/app/src/main/res/layout/download_dialog.xml b/app/src/main/res/layout/download_dialog.xml index 33e18c64a..67aa1577c 100644 --- a/app/src/main/res/layout/download_dialog.xml +++ b/app/src/main/res/layout/download_dialog.xml @@ -71,24 +71,59 @@ android:minWidth="150dp" tools:listitem="@layout/stream_quality_item" /> + + + + + + + android:layout_marginBottom="12dp" + android:orientation="horizontal"> + + + diff --git a/app/src/main/res/layout/download_loading_dialog.xml b/app/src/main/res/layout/download_loading_dialog.xml new file mode 100644 index 000000000..3a7869f3f --- /dev/null +++ b/app/src/main/res/layout/download_loading_dialog.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/drawer_header.xml b/app/src/main/res/layout/drawer_header.xml index d2e936870..fe076ff7c 100644 --- a/app/src/main/res/layout/drawer_header.xml +++ b/app/src/main/res/layout/drawer_header.xml @@ -39,7 +39,7 @@ android:layout_width="@dimen/drawer_header_newpipe_icon_size" android:layout_height="@dimen/drawer_header_newpipe_icon_size" android:layout_marginEnd="@dimen/drawer_header_newpipe_icon_title_space" - app:srcCompat="@drawable/splash_foreground" + android:src="@drawable/splash_foreground" tools:ignore="ContentDescription" /> + tools:src="@drawable/ic_smart_display" /> diff --git a/app/src/main/res/layout/error_panel.xml b/app/src/main/res/layout/error_panel.xml index 03f47cc99..e294037da 100644 --- a/app/src/main/res/layout/error_panel.xml +++ b/app/src/main/res/layout/error_panel.xml @@ -33,8 +33,8 @@ android:id="@+id/error_message_service_explanation_view" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:gravity="center" android:layout_marginTop="4dp" + android:gravity="center" android:text="@string/general_error" android:textSize="16sp" android:textStyle="italic" @@ -62,7 +62,6 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="4dp" - android:layout_marginBottom="8dp" android:text="@string/retry" android:textAlignment="center" android:textAllCaps="true" @@ -72,4 +71,19 @@ android:visibility="gone" tools:visibility="visible" /> +