diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ecaf94d5d..311e5248c 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -3,9 +3,9 @@ NewPipe contribution guidelines ## Crash reporting -Report crashes through the automated crash report system of NewPipe. +Report crashes through the **automated crash report system** of NewPipe. This way all the data needed for debugging is included in your bugreport for GitHub. -You'll see exactly what is sent, be able to add your comments, and then send it. +You'll see *exactly* what is sent, be able to add **your comments**, and then send it. ## Issue reporting/feature requests @@ -25,22 +25,61 @@ You'll see exactly what is sent, be able to add your comments, and then send it. ## Code contribution -* If you want to help out with an existing bug report or feature request, leave a comment on that issue saying you want to try your hand at it. -* If there is no existing issue for what you want to work on, open a new one describing your changes. This gives the team and the community a chance to give feedback before you spend time on something that is already in development, should be done differently, or should be avoided completely. -* Stick to NewPipe's style conventions of [checkStyle](https://github.com/checkstyle/checkstyle). It runs each time you build the project. -* Do not bring non-free software (e.g. binary blobs) into the project. Make sure you do not introduce Google - libraries. +### Guidelines + +* Stick to NewPipe's *style conventions* of [checkStyle](https://github.com/checkstyle/checkstyle) and [ktlint](https://github.com/pinterest/ktlint). They run each time you build the project. * Stick to [F-Droid contribution guidelines](https://f-droid.org/wiki/page/Inclusion_Policy). -* Make changes on a separate branch with a meaningful name, not on the _master_ branch or the _dev_ branch. This is commonly known as *feature branch workflow*. You may then send your changes as a pull request (PR) on GitHub. -* Please test (compile and run) your code before submitting changes! Ideally, provide test feedback in the PR description. Untested code will **not** be merged! -* Make sure your PR is up-to-date with the rest of the code. Often, a simple click on "Update branch" will do the job, but if not, you must rebase the dev branch manually and resolve the problems on your own. You can find help [on the wiki](https://github.com/TeamNewPipe/NewPipe/wiki/How-to-merge-a-PR). That makes the maintainers' jobs way easier. -* Please show intention to maintain your features and code after you contribute a PR. Unmaintained code is a hassle for core developers. If you do not intend to maintain features you plan to contribute, please rethink your submission, or clearly state that in the PR description. +* In particular **do not bring non-free software** (e.g. binary blobs) into the project. Make sure you do not introduce any closed-source library from Google. + +### Before starting development + +* If you want to help out with an existing bug report or feature request, **leave a comment** on that issue saying you want to try your hand at it. +* If there is no existing issue for what you want to work on, **open a new one** describing the changes you are planning to introduce. This gives the team and the community a chance to give **feedback** before you spend time on something that is already in development, should be done differently, or should be avoided completely. +* Please show **intention to maintain your features** and code after you contribute a PR. Unmaintained code is a hassle for core developers. If you do not intend to maintain features you plan to contribute, please rethink your submission, or clearly state that in the PR description. +* Create PRs that cover only **one specific issue/solution/bug**. Do not create PRs that are huge monoliths and could have been split into multiple independent contributions. +* NewPipe uses [NewPipeExtractor](https://github.com/TeamNewPipe/NewPipeExtractor) to fetch data from services. If you need to change something there, you must test your changes in NewPipe. Telling NewPipe to use your extractor version can be accomplished by editing the `app/build.gradle` file: the comments under the "NewPipe libraries" section of `dependencies` will help you out. + +### Kotlin in NewPipe +* NewPipe will remain mostly Java for time being +* Contributions containing a simple conversion from Java to Kotlin should be avoided. Conversions to Kotlin should only be done if Kotlin actually brings improvements like bug fixes or better performance which are not, or only with much more effort, implementable in Java. The core team sees Java as an easier to learn and generally well adopted programming language. + +### Creating a Pull Request (PR) + +* Make changes on a **separate branch** with a meaningful name, not on the _master_ branch or the _dev_ branch. This is commonly known as *feature branch workflow*. You may then send your changes as a pull request (PR) on GitHub. +* Please **test** (compile and run) your code before submitting changes! Ideally, provide test feedback in the PR description. Untested code will **not** be merged! * Respond if someone requests changes or otherwise raises issues about your PRs. -* Send PRs that only cover one specific issue/solution/bug. Do not send PRs that are huge and consist of multiple independent solutions. * Try to figure out yourself why builds on our CI fail. +* Make sure your PR is **up-to-date** with the rest of the code. Often, a simple click on "Update branch" will do the job, but if not, you must *rebase* your branch on the `dev` branch manually and resolve the conflicts on your own. You can find help [on the wiki](https://github.com/TeamNewPipe/NewPipe/wiki/How-to-merge-a-PR). Doing this makes the maintainers' job way easier. + +## IDE setup & building the app + +### Basic setup + +NewPipe is developed using [Android Studio](https://developer.android.com/studio/). Learn more about how to install it and how it works in the [official documentation](https://developer.android.com/studio/intro). In particular, make sure you have accepted Android Studio's SDK licences. Once Android Studio is ready, setting up the NewPipe project is fairly simple: +- Clone the NewPipe repository with `git clone https://github.com/TeamNewPipe/NewPipe.git` (or use the link from your own fork, if you want to open a PR). +- Open the folder you just cloned with Android Studio. +- Build and run it just like you would do with any other app, with the green triangle in the top bar. + +You may find [SonarLint](https://www.sonarlint.org/intellij)'s **inspections** useful in helping you to write good code and prevent bugs. + +### checkStyle setup + +The [checkStyle](https://github.com/checkstyle/checkstyle) plugin verifies that Java code abides by the project style. It runs automatically each time you build the project. If you want to view errors directly in the editor, instead of having to skim through the build output, you can install an Android Studio plugin: +- Go to `File -> Settings -> Plugins`, search for `checkstyle` and install `CheckStyle-IDEA`. +- Go to `File -> Settings -> Tools -> Checkstyle`. +- Add NewPipe's configuration file by clicking the `+` in the right toolbar of the "Configuration File" list. +- Under the "Use a local Checkstyle file" bullet, click on `Browse` and pick the file named `checkstyle.xml` in the project's root folder. +- Enable "Store relative to project location" so that moving the directory around does not create issues. +- Insert a description in the top bar, then click `Next` and then `Finish`. +- Activate the configuration file you just added by enabling the checkbox on the left. +- Click `Ok` and you are done. + +### ktlint setup + +The [ktlint](https://github.com/pinterest/ktlint) plugin does the same job as checkStyle for Kotlin files. Installing the related plugin is as simple as going to `File -> Settings -> Plugins`, searching for `ktlint` and installing `Ktlint (unofficial)`. ## Communication -* The [#newpipe](irc:irc.freenode.net/newpipe) channel on freenode has the core team and other developers in it. [Click here for webchat](https://webchat.freenode.net/?channels=newpipe)! -* You can also use a Matrix account to join the Newpipe channel at [#freenode_#newpipe:matrix.org](https://matrix.to/#/#freenode_#newpipe:matrix.org). -* Post suggestions, changes, ideas etc. on GitHub or IRC. +* The #newpipe channel on Libera Chat (`ircs://irc.libera.chat:6697/newpipe`) has the core team and other developers in it. [Click here for webchat](https://web.libera.chat/#newpipe)! +* You can also use a Matrix account to join the NewPipe channel at [#newpipe:libera.chat](https://matrix.to/#/#newpipe:libera.chat). Some convenient clients, available both for phone and desktop, are listed at that link. +* You can post your suggestions, changes, ideas etc. on either GitHub or IRC. diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index e74a5a761..ad9f1f82f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -33,7 +33,7 @@ Oh no, a bug! It happens. Thanks for reporting an issue with NewPipe. To make it -### Actual behaviour +### Actual behavior diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 5a97b3662..b0fdb56db 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ blank_issues_enabled: false contact_links: - name: 💬 IRC - url: https://webchat.freenode.net/#newpipe + url: https://web.libera.chat/#newpipe about: Chat with us via IRC for quick Q/A - name: 💬 Matrix - url: https://matrix.to/#/#freenode_#newpipe:matrix.org + url: https://matrix.to/#/#newpipe:libera.chat about: Chat with us via Matrix for quick Q/A diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 000000000..5582fd407 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,24 @@ +--- +name: Question +about: Ask about anything NewPipe-related +labels: question +assignees: '' + +--- + + + + + +### Checklist + + +- [x] I checked, but didn't find any duplicates (open OR closed) of this issue in the repo. +- [ ] I have read the contribution guidelines given at https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md. + +#### What's your question(s)? + + +#### Additional context + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2787e2238..10e40af2a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,18 +12,23 @@ - create clones - take over the world +#### Before/After Screenshots/Screen Record + +- Before: +- After: + #### Fixes the following issue(s) - -- + +- Fixes # #### Relies on the following changes - + - #### APK testing -On the website the APK can be found by going to the "Checks" tab below the title and then on "artifacts" on the right. +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. #### Due diligence - [ ] I read the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md). diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3ea00e03..4da04c052 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,29 +1,75 @@ name: CI on: + workflow_dispatch: pull_request: branches: - dev + - master + paths-ignore: + - 'README*.md' + - 'fastlane/**' + - 'assets/**' + - '.github/**/*.md' push: - branches: + branches: - dev - master + paths-ignore: + - 'README*.md' + - 'fastlane/**' + - 'assets/**' + - '.github/**/*.md' jobs: - build-and-test: + build-and-test-jvm: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 + - uses: gradle/wrapper-validation-action@v1 - name: create and checkout branch # push events already checked out the branch if: github.event_name == 'pull_request' run: git checkout -B ${{ github.head_ref }} - - name: set up JDK 1.8 - uses: actions/setup-java@v1.4.3 + - name: set up JDK 8 + uses: actions/setup-java@v2 with: - java-version: 1.8 + java-version: 8 + distribution: "adopt" + + - name: Cache Gradle dependencies + uses: actions/cache@v2 + with: + path: ~/.gradle/caches + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} + restore-keys: ${{ runner.os }}-gradle + + - name: Build debug APK and run jvm tests + run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint + + - name: Upload APK + uses: actions/upload-artifact@v2 + with: + name: app + path: app/build/outputs/apk/debug/*.apk + + test-android: + # macos has hardware acceleration. See android-emulator-runner action + runs-on: macos-latest + strategy: + matrix: + # api-level 19 is min sdk, but throws errors related to desugaring + api-level: [ 21, 29 ] + steps: + - uses: actions/checkout@v2 + + - name: set up JDK 8 + uses: actions/setup-java@v2 + with: + java-version: 8 + distribution: "adopt" - name: Cache Gradle dependencies uses: actions/cache@v2 @@ -32,14 +78,14 @@ jobs: key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle') }} restore-keys: ${{ runner.os }}-gradle - - name: Build debug APK and run Tests - run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace - - - name: Upload APK - uses: actions/upload-artifact@v2 + - name: Run android tests + uses: reactivecircus/android-emulator-runner@v2 with: - name: app - path: app/build/outputs/apk/debug/*.apk + api-level: ${{ matrix.api-level }} + # workaround to emulator bug: https://github.com/ReactiveCircus/android-emulator-runner/issues/160 + emulator-build: 7425822 + script: ./gradlew connectedCheck + # sonar: # runs-on: ubuntu-latest # steps: @@ -48,9 +94,10 @@ jobs: # fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis # - name: Set up JDK 11 -# uses: actions/setup-java@v1.4.3 +# uses: actions/setup-java@v2 # with: # java-version: 11 # Sonar requires JDK 11 +# distribution: "adopt" # - name: Cache SonarCloud packages # uses: actions/cache@v2 diff --git a/README.es.md b/README.es.md index 0aa198d2c..3920545d5 100644 --- a/README.es.md +++ b/README.es.md @@ -9,7 +9,7 @@ - +


@@ -18,7 +18,7 @@

Sitio webBlogPreguntas FrecuentesPrensa


-*Lea esto en otros idiomas: [English](README.md), [한국어](README.ko.md), [Soomaali](README.so.md), [Português Brasil](README.pt_BR.md), [日本語](README.ja.md), [Română](README.ro.md) .* +*Lea esto en otros idiomas: [English](README.md), [한국어](README.ko.md), [Soomaali](README.so.md), [Português Brasil](README.pt_BR.md), [日本語](README.ja.md), [Română](README.ro.md), [Türkçe](README.tr.md).* AVISO: ESTA ES UNA VERSIÓN BETA, POR LO TANTO, PUEDE ENCONTRAR BUGS (ERRORES). SI ENCUENTRA UNO, ABRA UN ISSUE A TRAVÉS DE NUESTRO REPOSITORIO GITHUB. @@ -85,7 +85,7 @@ NewPipe apoya varios servicios. Nuestras [documentaciones](https://teamnewpipe.g ## Installación y actualizaciones Se puede instalar NewPipe usando uno de los métodos siguientes: - 1. Agregar nuestro repositorio personalizado a F-Droid e instalarlo desde allí. Las instrucciones están aquí: https://newpipe.schabi.org/FAQ/tutorials/install-add-fdroid-repo/ + 1. Agregar nuestro repositorio personalizado a F-Droid e instalarlo desde allí. Las instrucciones están aquí: https://newpipe.net/FAQ/tutorials/install-add-fdroid-repo/ 2. Descargar el archivo APK del enlace [Github Releases](https://github.com/TeamNewPipe/NewPipe/releases) e instalarlo. 3. Actualizar a través de F-Droid. Este es el método más lento para obtener la actualización, como F-Droid debe reconocer cambios, construir el APK aparte, firmarlo con una clave, y finalmente empujar la actualización a los usuarios. 4. Construir un APK de depuración por si mismo. Este es el modo más rápido para realizar nuevas características en su dispositivo, pero es mucho más complicado, asi que recomendamos uno de los otros métodos. @@ -135,6 +135,6 @@ El proyecto NewPipe tiene como objetivo proveer una experience privada y anónim Por lo tanto, la app no colecciona ningunos datos sin su consentimiento. La politica de privacidad de NewPipe explica en detalle los datos enviados y almacenados cuando envia un informe de error, o comentario en nuestro blog. Puede encontrar el documento [aqui](https://newpipe.net/legal/privacy/). ## Licencia -[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html) +[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](https://www.gnu.org/licenses/gpl-3.0.en.html) NewPipe es Software Libre: Puede usar, estudiar, compartir, y mejorarlo a su voluntad. Especificamente puede redistribuir y/o modificarlo bajo los términos de la [GNU General Public License](https://www.gnu.org/licenses/gpl.html) como publicado por la Free Software Foundation, o versión 3 de la licencia, o (en su opción) cualquier versión posterior. diff --git a/README.ja.md b/README.ja.md index 685202bf3..980dc914a 100644 --- a/README.ja.md +++ b/README.ja.md @@ -9,7 +9,7 @@ - +


@@ -17,7 +17,7 @@

ウェブサイトブログFAQニュース


-*他の言語で読む: [English](README.md), [Español](README.es.md), [한국어](README.ko.md), [Soomaali](README.so.md), [Português Brasil](README.pt.br.md), [日本語](README.ja.md), [Română](README.ro.md) 。* +*他の言語で読む: [English](README.md), [Español](README.es.md), [한국어](README.ko.md), [Soomaali](README.so.md), [Português Brasil](README.pt.br.md), [日本語](README.ja.md), [Română](README.ro.md), [Türkçe](README.tr.md)。* 注意: これはベータ版のため、バグが発生する可能性があります。もしバグが発生した場合、GitHub のリポジトリで Issue を開いてください。 @@ -143,7 +143,7 @@ NewPipe プロジェクトはメディアウェブサービスを使用する上 ## ライセンス -[![GNU GPLv3 のロゴ](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html) +[![GNU GPLv3 のロゴ](https://www.gnu.org/graphics/gplv3-127x51.png)](https://www.gnu.org/licenses/gpl-3.0.en.html) NewPipe はフリーソフトウェアなので、あなたはあなたの望むように使用、習得、共有、改善を行えます。 具体的には、フリーソフトウェア財団により公開された [GNU General Public License](https://www.gnu.org/licenses/gpl.html) のバージョン3のライセンスもしくは、(あなたの選択で) いずれかの後継バージョンの規約の元で配布または改変を行うことができます。 diff --git a/README.ko.md b/README.ko.md index 8bbda9b5d..269cfda49 100644 --- a/README.ko.md +++ b/README.ko.md @@ -9,7 +9,7 @@ - +


@@ -17,7 +17,7 @@

WebsiteBlogFAQPress


-*Read this in other languages: [English](README.md), [Español](README.es.md), [한국어](README.ko.md), [Soomaali](README.so.md), [Português Brasil](README.pt_BR.md), [日本語](README.ja.md), [Română](README.ro.md).* +*Read this in other languages: [English](README.md), [Español](README.es.md), [한국어](README.ko.md), [Soomaali](README.so.md), [Português Brasil](README.pt_BR.md), [日本語](README.ja.md), [Română](README.ro.md), [Türkçe](README.tr.md).* 경고: 이 버전은 베타 버전이므로, 버그가 발생할 수도 있습니다. 만약 버그가 발생하였다면, 우리의 GITHUB 저장소에서 ISSUE를 열람하여 주십시오. @@ -139,7 +139,7 @@ NewPipe 프로젝트는 미디어 웹 서비스를 사용하는 것에 대한 그러므로, 앱은 당신의 동의 없이 어떤 데이터도 수집하지 않습니다. NewPipe의 개인정보보호정책은 당신이 충돌 리포트를 보내거나, 또는 우리의 블로그에 글을 남길 때 어떤 데이터가 보내지고 저장되는지에 대해 상세히 설명합니다. 이 문서는 [여기](https://newpipe.net/legal/privacy/)에서 확인할 수 있습니다. ## License -[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html) +[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](https://www.gnu.org/licenses/gpl-3.0.en.html) NewPipe는 자유 소프트웨어입니다: 당신의 마음대로 이것을 사용하고, 연구하고, 공유하고, 개선할 수 있습니다. 구체적으로 당신은 자유 소프트웨어 재단에서 발행되는, 버전 3 또는 (당신의 선택에 따라)이후 버전의, diff --git a/README.md b/README.md index 27ede1c03..93c70a532 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ - +


@@ -17,7 +17,7 @@

WebsiteBlogFAQPress


-*Read this in other languages: [English](README.md), [Español](README.es.md), [한국어](README.ko.md), [Soomaali](README.so.md), [Português Brasil](README.pt_BR.md), [日本語](README.ja.md), [Română](README.ro.md) .* +*Read this in other languages: [English](README.md), [Español](README.es.md), [한국어](README.ko.md), [Soomaali](README.so.md), [Português Brasil](README.pt_BR.md), [日本語](README.ja.md), [Română](README.ro.md), [Türkçe](README.tr.md).* WARNING: THIS IS A BETA VERSION, THEREFORE YOU MAY ENCOUNTER BUGS. IF YOU DO, OPEN AN ISSUE VIA OUR GITHUB REPOSITORY. @@ -45,6 +45,7 @@ NewPipe does not use any Google framework libraries, nor the YouTube API. Websit ### Features * Search videos +* No Login Required * Display general info about videos * Watch YouTube videos * Listen to YouTube videos @@ -87,14 +88,14 @@ NewPipe supports multiple services. Our [docs](https://teamnewpipe.github.io/doc ## 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.schabi.org/FAQ/tutorials/install-add-fdroid-repo/ + 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. 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. 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. -In the meanwhile, if you want to switch sources for some reason (e.g. NewPipe's core functionality was broken and F-Droid doesn't have the update yet), we recommend following this procedure: +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 2. Uninstall NewPipe 3. Download the APK from the new source and install it @@ -137,7 +138,7 @@ The NewPipe project aims to provide a private, anonymous experience for using me 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/). ## License -[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html) +[![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 your will. Specifically you can redistribute and/or modify it under the terms of the diff --git a/README.pt_BR.md b/README.pt_BR.md index 033b6f0f7..5cdd8ae13 100644 --- a/README.pt_BR.md +++ b/README.pt_BR.md @@ -10,7 +10,7 @@ - +


@@ -18,7 +18,7 @@

SiteBlogFAQPress


-*Read this in other languages: [English](README.md), [Español](README.es.md), [한국어](README.ko.md), [Soomaali](README.so.md), [Português Brasil](README.pt_BR.md), [日本語](README.ja.md), [Română](README.ro.md).* +*Read this in other languages: [English](README.md), [Español](README.es.md), [한국어](README.ko.md), [Soomaali](README.so.md), [Português Brasil](README.pt_BR.md), [日本語](README.ja.md), [Română](README.ro.md), [Türkçe](README.tr.md).* AVISO: ESTA É UMA VERSÃO BETA, PORTANTO, VOCÊ PODE ENCONTRAR BUGS. ENCONTROU ALGUM, ABRA UM ISSUE ATRAVÉS DO NOSSO REPOSITÓRIO GITHUB. @@ -93,7 +93,7 @@ Quando uma alteração no código NewPipe (devido à adição de recursos ou fix Recomendamos o método 2 para a maioria dos usuários. Os APKs instalados usando o método 2 ou 3 são compatíveis entre si, mas não com aqueles instalados usando o método 4. Isso se deve à mesma chave de assinatura (nossa) sendo usada para 2 e 3, mas uma chave de assinatura diferente (F-Droid's) está sendo usada para 4. Construir um APK depuração usando o método 1 exclui totalmente uma chave. Assinar chaves ajudam a garantir que um usuário não seja enganado para instalar uma atualização maliciosa em um aplicativo. Enquanto isso, se você quiser trocar de fontes por algum motivo (por exemplo, a funcionalidade principal do NewPipe foi quebrada e o F-Droid ainda não tem a atualização), recomendamos seguir este procedimento: -1. Back up your data via Settings > Content > Export Database so you keep your history, subscriptions, and playlistsFaça backup de seus dados através de Configurações > Conteúdo > Exportar Base de Dados para que você mantenha seu histórico, inscrições e playlists +1. Faça backup de seus dados através de Configurações > Conteúdo > Exportar Base de Dados para que você mantenha seu histórico, inscrições e playlists 2. Desinstale o NewPipe 3. Baixe o APK da nova fonte e instale-o 4. Importe os dados da etapa 1 via Configurações > Conteúdo > Inportar Banco de Dados @@ -135,7 +135,7 @@ O projeto NewPipe tem como objetivo proporcionar uma experiência privada e anô Portanto, o aplicativo não coleta nenhum dado sem o seu consentimento. A política de privacidade da NewPipe explica em detalhes quais dados são enviados e armazenados quando você envia um relatório de erro ou comenta em nosso blog. Você pode encontrar o documento [aqui](https://newpipe.net/legal/privacy/). ## Licença -[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html) +[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](https://www.gnu.org/licenses/gpl-3.0.en.html) NewPipe é Software Livre: Você pode usar, estudar compartilhamento e melhorá-lo à sua vontade. Especificamente, você pode redistribuir e/ou modificá-lo sob os termos do diff --git a/README.ro.md b/README.ro.md index cffbcb510..a53019c9c 100644 --- a/README.ro.md +++ b/README.ro.md @@ -9,7 +9,7 @@ - +


@@ -17,7 +17,7 @@

WebsiteBlogFAQPresă


-*Citiţi în alte limbi: [English](README.md), [Español](README.es.md), [한국어](README.ko.md), [Soomaali](README.so.md), [Português Brasil](README.pt_BR.md), [日本語](README.ja.md), [Română](README.ro.md)* +*Citiţi în alte limbi: [English](README.md), [Español](README.es.md), [한국어](README.ko.md), [Soomaali](README.so.md), [Português Brasil](README.pt_BR.md), [日本語](README.ja.md), [Română](README.ro.md), [Türkçe](README.tr.md).* Atenţionare: ACEASTA ESTE O VERSIUNE BETA, AŞA CĂ S-AR PUTE SĂ ÎNTÂLNIŢI ERORI. DACĂ SE ÎNTÂMPLĂ ACEST LUCRU, DESCHIDEŢI UN ISSUE PRIN REPSITORY-UL NOSTRU GITHUB. @@ -45,6 +45,7 @@ NewPipe nu foloseşte nici-o bibliotecă Google framework sau API-ul Youtube. We ### Funcţii * Căutarea videoclipurilor +* Nu este necesară logarea * Afişarea informaţiilor generale despre videoclipuri * Urmărirea videoclipurilor Youtube * Ascultarea videoclipurilor Youtube @@ -87,7 +88,7 @@ NewPipe suportă servicii multiple. [Documentele](https://teamnewpipe.github.io/ ## Instalare şi actualizări Puteţi instala NewPipe folosind una dintre următoarele metode: - 1. Adăugaţi depozitul nostru F-droid personalizat. Instrucţiunile sunt aici: https://newpipe.schabi.org/FAQ/tutorials/install-add-fdroid-repo/ + 1. Adăugaţi depozitul nostru F-droid personalizat. Instrucţiunile sunt aici: https://newpipe.net/FAQ/tutorials/install-add-fdroid-repo/ 2. Descărcaţi APK-ul din [Github Releases](https://github.com/TeamNewPipe/NewPipe/releases) şi instalaţi-l. 3. Actualizaţi via F-Droid. Aceasta este cea mai lentă metodă de a obţine actualizări, deoarece F-Droid trebuie să recunoască schimbările, să constriască APK-ul, să îl semneze, iar apoi să îl trimită utilizatorilor. 4. Construiţi un APK de depanare. Aceasta este cea mai rapidă metodă de a primi funcţii noi, dar este mult mai complicată, aşa că vă recomandăm să folosiţi una dintre celelalte metode. @@ -137,7 +138,7 @@ Proiectul NewPipe îşi propune să furnizeze o experienţă privată şi anonim Prin urmare, aplicaţia nu colectează niciun fel de informaţii fără acordul dumneavoastră. Politica de confidențialitate a NewPipe explică în detaliu ce date sunt trimise și stocate atunci când trimiteți un raport de blocare sau comentați pe blogul nostru. Puteți găsi documentul [aici](https://newpipe.net/legal/privacy/). ## Licenţă -[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html) +[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](https://www.gnu.org/licenses/gpl-3.0.en.html) NewPipe este Software Gratuit: Îl puteţi folosi şi împărtăşi cum doriţi. Mai exact, îl puteți redistribui și / sau modifica în conformitate cu termenii [GNU General Public License](https://www.gnu.org/licenses/gpl.html) aşa cum a fost publicat de Free Software Foundation, fie versiunea 3 a Licenței, fie diff --git a/README.so.md b/README.so.md index f0bba8b79..ed42fde0d 100644 --- a/README.so.md +++ b/README.so.md @@ -9,7 +9,7 @@ - +


@@ -17,7 +17,7 @@

Website-kaMaqaaladaSu'aalaha Aalaa La-iswaydiiyoWarbaahinta


-*Ku akhri luuqad kale: [English](README.md), [Español](README.es.md), [한국어](README.ko.md), [Soomaali](README.so.md), [Português Brasil](README.pt_BR.md), [日本語](README.ja.md), [Română](README.ro.md).* +*Ku akhri luuqad kale: [English](README.md), [Español](README.es.md), [한국어](README.ko.md), [Soomaali](README.so.md), [Português Brasil](README.pt_BR.md), [日本語](README.ja.md), [Română](README.ro.md), [Türkçe](README.tr.md).* DIGNIIN: MIDKAN, NOOCA APP-KA EE HADDA WALI TIJAABO AYUU KU JIRAA, SIDAA DARTEED CILLADO AYAAD LA KULMI KARTAA. HADAAD LA KULANTO, KA FUR ARIN SHARAXAYA QAYBTANADA ARRIMAHA EE GITHUB-KA. @@ -133,6 +133,6 @@ Mashruuca NewPipe waxay ujeedadiisu tahay inuu bixiyo wax kuu gaar ah, oo adoon Sidaa darteed, app-ku wax xog ah ma uruuriyo fasaxaaga la'aantii. Siyaasada Sirdhawrka NewPipe ayaa si faahfaahsan u sharaxda waxii xog ah ee la diro markaad cillad wariso, ama aad bogganaga faallo ka dhiibato. Warqada waxaad ka heli kartaa [halkan](https://newpipe.net/legal/privacy/). ## Laysinka -[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html) +[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](https://www.gnu.org/licenses/gpl-3.0.en.html) NewPipe waa barnaamij bilaash ah oon lahayn xuquuqda daabacaada: Waad isticmaali kartaa, waad wadaagi kartaa waadna hormarin kartaa hadaad rabto. Gaar ahaan waad sii daabici kartaa ama wax baad ka badali kartaa ayadoo la raacayo shuruudaha sharciga guud ee [GNU](https://www.gnu.org/licenses/gpl.html) sida ay soosaareen Ururka Barnaamijyada Bilaashka ah, soosaarista 3aad ee laysinka, ama (hadaad doonto) nooc walba oo kasii dambeeyay laysinkii 3aad. diff --git a/README.tr.md b/README.tr.md new file mode 100644 index 000000000..0bd644934 --- /dev/null +++ b/README.tr.md @@ -0,0 +1,145 @@ +

+

NewPipe

+

Android için hafif ve özgür bir akış arayüzü.

+ +

+ +

+ + + + + + +

+
+

Ekran fotoğraflarıAçıklamaÖzelliklerKurulum ve güncellemelerKatkıda bulunmaBağışLisans

+

Web sitesiBlogSSSBasın

+
+ +*Bu sayfayı diğer dillerde okuyun: [English](README.md), [Español](README.es.md), [한국어](README.ko.md), [Soomaali](README.so.md), [Português Brasil](README.pt_BR.md), [日本語](README.ja.md), [Română](README.ro.md), [Türkçe](README.tr.md).* + +UYARI: BU SÜRÜM BETA SÜRÜMÜDÜR, BU NEDENLE HATALARLA KARŞILAŞABİLİRSİNİZ. HATA BULURSANIZ BU GITHUB DEPOSUNDA BUNU BİLDİRİN. + +GOOGLE PLAY STORE'A NEWPIPE VEYA BAŞKA BİR KOPYASINI KOYMAK, PLAY STORE ŞARTLARINI VE KOŞULLARINI İHLAL EDER. + +## Ekran fotoğrafları + +[](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) + +## Açıklama + +NewPipe herhangi bir Google çerçeve kütüphanesini, ya da YouTube API hizmetlerini kullanmaz. Gerekli web hizmetleri yalnızca gerekli bilgileri almak için kaynak olarak kullanılır, bu nedenle bu uygulama Google hizmetleri yüklü olmayan cihazlarda da kullanılabilir. Ayrıca, copyleft özgür yazılımı olan NewPipe'ı kullanmak için bir YouTube hesabına ihtiyacınız yoktur. + +### Özellikler + +* Video arama +* Videolar hakkında genel bilgileri görüntüleme +* YouTube videoları izleme +* YouTube videolarını dinleme +* Pop-up modu (hareketli oynatıcı) +* Video izlemek için akış oynatıcısını seçme +* Video indirme +* Sadece ses indirme +* Videoyu Kodi'de açma +* Sonraki video/ilgili videolar +* YouTube'u belirli bir dilde arayın +* Yaş sınırlı içeriği izleme/engelleme +* Kanallar hakkındaki genel bilgileri görüntüleme +* Kanal arama +* Bir kanaldaki videoları izleme +* Orbot/Tor desteği (henüz direkt olarak değil) +* 1080p/2K/4K desteği +* Geçmişi görme +* Kanallara abone olma +* Geçmişte arama +* Oynatma listesi arama/oynatma +* Çalma listelerini sıralayıp oynatın +* Videoları sırayla oynatın +* Yerel oynatma listeleri +* Altyazılar +* Canlı yayın desteği +* Yorumları görme + +### Desteklenen servisler + +NewPipe birden fazla hizmeti destekler. Uygulamaya ve ayıklayıcıya yeni bir hizmet ekleme konusunda daha fazla bilgiye [kılavuzlarımızdan](https://teamnewpipe.github.io/documentation/) ulaşabilirsiniz. Yeni bir hizmet eklemek istiyorsanız lütfen bizimle iletişime geçin. Şu anda desteklenen hizmetler şunlardır: + +* YouTube +* SoundCloud \[beta\] +* media.ccc.de \[beta\] +* PeerTube \[beta\] +* Bandcamp \[beta\] + + + + +## Kurulum ve güncellemeler +Aşağıdaki yöntemlerden birini kullanarak NewPipe'ı kurabilirsiniz: + 1. Özel depomuzu F-Droid'e ekleyin ve oradan yükleyin. Kılavuzu şurada bulabilirsiniz: https://newpipe.net/FAQ/tutorials/install-add-fdroid-repo/ + 2. APK'yı [Github sürümlerinden](https://github.com/TeamNewPipe/NewPipe/releases) indirin ve kurun. + 3. F-Droid ile güncelleyin. Bu, güncellemeleri almanın en yavaş yöntemidir, çünkü F-Droid değişiklikleri tanımalı, APK'yı kendisi oluşturmalı, imzalamalı ve ardından güncellemeyi kullanıcılara dağıtmalıdır. + 4. Kendiniz bir APK derleyin. Bu yöntem, cihazınızda yeni özellikler edinmenin en hızlı yoludur, ancak çok daha karmaşıktır, bu nedenle diğer yöntemlerden birini kullanmanızı öneririz. + +Çoğu kullanıcı için yöntem 1'i öneririz. Yöntem 1 veya 2 kullanılarak yüklenen APK'lar birbiriyle uyumludur, ancak yöntem 3 kullanılarak yüklenenlerle uyumlu değildir. Bu durum, 1 ve 2 için kullanılan aynı imzalama anahtarıın (bizim anahtarımız) 3 için kullanılan imzalama anahtarından (F-Droid'in anahtarı) farklı olmasından kaynaklanmaktadır. Yöntem 4 kullanılarak oluşturulan deneysel APK'larda anahtar yoktur. İmzalama anahtarları, bir kullanıcının bir uygulamaya kötü amaçlı bir güncelleme yüklemek için kandırılmadığından emin olmanıza yardımcı olur. + +Bu arada, herhangi bir nedenle kaynakları değiştirmek istiyorsanız (örneğin, NewPipe'ın temel bir işlevi bozuldu ve F-Droid tarafında henüz bir güncelleme yayınlanmadı), bu prosedürü izlemenizi öneririz: +1. Verilerinizi yedekleyin. `NewPipe Ayarları > İçerik > Veritabanını dışa aktar` seçeneklerini izleyerek aboneliklerinizi, oynatma listelerinizi ve geçmişinizi yedekleyin. +2. NewPipe'ı kaldırın +3. APK dosyasını yeni bir kaynaktan indirin ve yükleyin +4. `Ayarlar > İçerik > Veritabanını içe aktar` seçeneklerini izleyerek 1. adımdaki verileri içe aktarın + +## Katkıda bulunma +Fikirleriniz, çevirileriniz, tasarım değişiklikleriniz, kod temizlemeniz veya ağır kod değişiklikleriniz olsun, yardımınıza her zaman açığız. +Yapılan her değişiklikle NewPipe daha da iyi bir konuma geliyor! + +Eğer yer almak istiyorsanız, [katkı sağlayanlar için hazırladığımız notları](.github/CONTRIBUTING.md) kontrol edin. + + +Çeviri istatistikleri + + +## Bağış +NewPipe'ı beğendiyseniz, yapacağınız bağışlar bizi motive eder. Bitcoin gönderebilir veya Bountysource veya Liberapay aracılığıyla bağış yapabilirsiniz. NewPipe'a bağış yapma hakkında daha fazla bilgi için lütfen [web sitemizi](https://newpipe.net/donate) ziyaret edin. + + + + + + + + + + + + + + + + + +
BitcoinBitcoin QR kodu16A9J59ahMRqkLSZjhYj33n9j3fMztFxnh
Liberapayliberapay.com üzerinde NewPipe'ı ziyaret edinLiberapay aracılığıyla bağış yapın
Bountysourcebountysource.com üzerinde NewPipe'ı ziyaret edinNe kadar ödül kazanabileceğinizi kontrol edin.
+ +## Gizlilik politikası + +NewPipe projesi, çevrimiçi akış hizmetlerini kullanmak için özel, özgür ve anonim bir deneyim sunmayı amaçlamaktadır. +Bu doğrultuda, uygulama sizin izniniz olmadan herhangi bir veri toplamaz. NewPipe'ın Gizlilik Politikası, bir çökme raporu gönderdiğinizde veya blogumuzda yorum yaptığınızda hangi verilerin gönderildiğini ve saklandığını ayrıntılı olarak açıklar. İlgili belgeyi [burada](https://newpipe.net/legal/privacy/) bulabilirsiniz. + +## Lisans +[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](https://www.gnu.org/licenses/gpl-3.0.en.html) + +NewPipe özgür bir yazılımdır. Kendi başınıza kullanabilir, öğrenebilir, paylaşabilir +ve geliştirebilirsiniz. Free Software Foundation tarafından yayınlanan GNU Genel Kamu Lisansı, +Lisansın 3. sürümü veya (isteğe bağlı olarak) daha sonraki bir sürümü şartları ve +koşulları altında yeniden dağıtabilir ve/veya değiştirebilirsiniz. diff --git a/app/build.gradle b/app/build.gradle index 88ed8998e..f88167b2e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -9,16 +9,16 @@ apply plugin: 'kotlin-kapt' apply plugin: 'checkstyle' android { - compileSdkVersion 29 - buildToolsVersion '29.0.3' + compileSdkVersion 30 + buildToolsVersion '30.0.3' defaultConfig { applicationId "org.schabi.newpipe" resValue "string", "app_name", "NewPipe" minSdkVersion 19 targetSdkVersion 29 - versionCode 966 - versionName "0.21.0" + versionCode 974 + versionName "0.21.8" multiDexEnabled true @@ -66,6 +66,9 @@ android { // Or, if you prefer, you can continue to check for errors in release builds, // but continue the build even when errors are found: abortOnError false + // suppress false warning ("Resource IDs will be non-final in Android Gradle Plugin version + // 5.0, avoid using them in switch case statements"), which affects only library projects + disable 'NonConstantResourceId' } compileOptions { @@ -96,16 +99,19 @@ android { } ext { - icepickVersion = '3.2.0' checkstyleVersion = '8.38' - stethoVersion = '1.5.1' - leakCanaryVersion = '2.5' + + androidxLifecycleVersion = '2.3.1' + androidxRoomVersion = '2.3.0' + + icepickVersion = '3.2.0' exoPlayerVersion = '2.12.3' - androidxLifecycleVersion = '2.2.0' - androidxRoomVersion = '2.3.0-alpha03' + googleAutoServiceVersion = '1.0' groupieVersion = '2.8.1' - markwonVersion = '4.6.0' - googleAutoServiceVersion = '1.0-rc7' + markwonVersion = '4.6.2' + + leakCanaryVersion = '2.5' + stethoVersion = '1.6.0' mockitoVersion = '3.6.0' } @@ -115,7 +121,7 @@ configurations { } checkstyle { - configDir rootProject.file(".") + getConfigDirectory().set(rootProject.file(".")) ignoreFailures false showViolations true toolVersion = checkstyleVersion @@ -134,8 +140,8 @@ task runCheckstyle(type: Checkstyle) { showViolations true reports { - xml.enabled true - html.enabled true + xml.getRequired().set(true) + html.getRequired().set(true) } } @@ -145,7 +151,7 @@ def inputFiles = project.fileTree(dir: "src", include: "**/*.kt") task runKtlint(type: JavaExec) { inputs.files(inputFiles) outputs.dir(outputDir) - main = "com.pinterest.ktlint.Main" + getMainClass().set("com.pinterest.ktlint.Main") classpath = configurations.ktlint args "src/**/*.kt" } @@ -153,13 +159,16 @@ task runKtlint(type: JavaExec) { task formatKtlint(type: JavaExec) { inputs.files(inputFiles) outputs.dir(outputDir) - main = "com.pinterest.ktlint.Main" + getMainClass().set("com.pinterest.ktlint.Main") classpath = configurations.ktlint args "-F", "src/**/*.kt" } afterEvaluate { - preDebugBuild.dependsOn formatKtlint, runCheckstyle, runKtlint + if (!System.properties.containsKey('skipFormatKtlint')) { + preDebugBuild.dependsOn formatKtlint + } + preDebugBuild.dependsOn runCheckstyle, runKtlint } sonarqube { @@ -171,83 +180,104 @@ sonarqube { } dependencies { - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.1' +/** Desugaring **/ + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' +/** NewPipe libraries **/ + // You can use a local version by uncommenting a few lines in settings.gradle + // Or you can use a commit you pushed to GitHub by just replacing TeamNewPipe with your GitHub + // name and the commit hash with the commit hash of the (pushed) commit you want to test + // This works thanks to JitPack: https://jitpack.io/ + implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751' + implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.21.8' + +/** Checkstyle **/ + checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}" + ktlint 'com.pinterest:ktlint:0.40.0' + +/** Kotlin **/ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin_version}" - implementation "frankiesardo:icepick:${icepickVersion}" - kapt "frankiesardo:icepick-processor:${icepickVersion}" - - checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}" - ktlint "com.pinterest:ktlint:0.40.0" - - debugImplementation "com.facebook.stetho:stetho:${stethoVersion}" - debugImplementation "com.facebook.stetho:stetho-okhttp3:${stethoVersion}" - - debugImplementation "com.squareup.leakcanary:leakcanary-android:${leakCanaryVersion}" - implementation "com.squareup.leakcanary:leakcanary-object-watcher-android:${leakCanaryVersion}" - implementation "com.squareup.leakcanary:plumber-android:${leakCanaryVersion}" - - implementation "androidx.multidex:multidex:2.0.1" - - // NewPipe dependencies - // You can use a local version by uncommenting a few lines in settings.gradle - implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.21.0' - implementation "com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751" - - implementation "org.jsoup:jsoup:1.13.1" - - //noinspection GradleDependency --> do not update okhttp to keep supporting Android 4.4 users - implementation "com.squareup.okhttp3:okhttp:3.12.13" - - implementation "com.google.android.exoplayer:exoplayer:${exoPlayerVersion}" - implementation "com.google.android.exoplayer:extension-mediasession:${exoPlayerVersion}" - - implementation "com.google.android.material:material:1.2.1" - - compileOnly "com.google.auto.service:auto-service-annotations:${googleAutoServiceVersion}" - kapt "com.google.auto.service:auto-service:${googleAutoServiceVersion}" - - implementation "androidx.appcompat:appcompat:1.2.0" - implementation "androidx.preference:preference:1.1.1" - implementation "androidx.recyclerview:recyclerview:1.1.0" - implementation "androidx.cardview:cardview:1.0.0" - implementation "androidx.constraintlayout:constraintlayout:2.0.4" - implementation 'androidx.core:core-ktx:1.3.2' +/** AndroidX **/ + implementation 'androidx.appcompat:appcompat:1.3.1' + implementation 'androidx.cardview:cardview:1.0.0' + implementation 'androidx.constraintlayout:constraintlayout:2.0.4' + implementation 'androidx.core:core-ktx:1.6.0' implementation 'androidx.documentfile:documentfile:1.0.1' - implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0' - implementation 'androidx.media:media:1.2.1' - implementation 'androidx.webkit:webkit:1.4.0' - + implementation 'androidx.fragment:fragment-ktx:1.3.6' implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}" implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}" - + implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0' + implementation 'androidx.media:media:1.3.1' + implementation 'androidx.multidex:multidex:2.0.1' + implementation 'androidx.preference:preference:1.1.1' + implementation 'androidx.recyclerview:recyclerview:1.1.0' 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' + implementation 'androidx.webkit:webkit:1.4.0' + implementation 'com.google.android.material:material:1.2.1' - implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" +/** Third-party libraries **/ + // Instance state boilerplate elimination + implementation "frankiesardo:icepick:${icepickVersion}" + kapt "frankiesardo:icepick-processor:${icepickVersion}" - implementation "com.xwray:groupie:${groupieVersion}" - implementation "com.xwray:groupie-viewbinding:${groupieVersion}" + // HTML parser + implementation "org.jsoup:jsoup:1.13.1" + // HTTP client + //noinspection GradleDependency --> do not update okhttp to keep supporting Android 4.4 users + implementation "com.squareup.okhttp3:okhttp:3.12.13" + + // Media player + implementation "com.google.android.exoplayer:exoplayer:${exoPlayerVersion}" + implementation "com.google.android.exoplayer:extension-mediasession:${exoPlayerVersion}" + + // Metadata generator for service descriptors + compileOnly "com.google.auto.service:auto-service-annotations:${googleAutoServiceVersion}" + kapt "com.google.auto.service:auto-service:${googleAutoServiceVersion}" + + // Manager for complex RecyclerView layouts + implementation "com.github.lisawray.groupie:groupie:${groupieVersion}" + implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}" + + // Circular ImageView implementation "de.hdodenhof:circleimageview:3.1.0" + // Image loading implementation "com.nostra13.universalimageloader:universal-image-loader:1.9.5" + // Markdown library for Android 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.7.0" + // Reactive extensions for Java VM implementation "io.reactivex.rxjava3:rxjava:3.0.7" implementation "io.reactivex.rxjava3:rxandroid:3.0.0" + // RxJava binding APIs for Android UI widgets implementation "com.jakewharton.rxbinding4:rxbinding:4.0.0" - implementation "org.ocpsoft.prettytime:prettytime:5.0.0.Final" + // Date and time formatting + implementation "org.ocpsoft.prettytime:prettytime:5.0.1.Final" - testImplementation 'junit:junit:4.13.1' +/** 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}" + // 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}" diff --git a/app/src/androidTest/java/org/schabi/newpipe/local/playlist/LocalPlaylistManagerTest.kt b/app/src/androidTest/java/org/schabi/newpipe/local/playlist/LocalPlaylistManagerTest.kt new file mode 100644 index 000000000..211312bc0 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/local/playlist/LocalPlaylistManagerTest.kt @@ -0,0 +1,85 @@ +package org.schabi.newpipe.local.playlist + +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.Timeout +import org.schabi.newpipe.database.AppDatabase +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.testUtil.TrampolineSchedulerRule +import java.util.concurrent.TimeUnit + +class LocalPlaylistManagerTest { + + private lateinit var manager: LocalPlaylistManager + private lateinit var database: AppDatabase + + @get:Rule + val trampolineScheduler = TrampolineSchedulerRule() + + @get:Rule + val timeout = Timeout(10, TimeUnit.SECONDS) + + @Before + fun setup() { + database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + AppDatabase::class.java + ) + .allowMainThreadQueries() + .build() + + manager = LocalPlaylistManager(database) + } + + @After + fun cleanUp() { + database.close() + } + + @Test + fun createPlaylist() { + val stream = StreamEntity( + serviceId = 1, url = "https://newpipe.net/", title = "title", + streamType = StreamType.VIDEO_STREAM, duration = 1, uploader = "uploader" + ) + + val result = manager.createPlaylist("name", listOf(stream)) + + // This should not behave like this. + // Currently list of all stream ids is returned instead of playlist id + result.test().await().assertValue(listOf(1L)) + } + + @Test + fun createPlaylist_emptyPlaylistMustReturnEmpty() { + val result = manager.createPlaylist("name", emptyList()) + + // This should not behave like this. + // It should throw an error because currently the result is null + result.test().await().assertComplete() + manager.playlists.test().awaitCount(1).assertValue(emptyList()) + } + + @Test() + fun createPlaylist_nonExistentStreamsAreUpserted() { + val stream = StreamEntity( + serviceId = 1, url = "https://newpipe.net/", title = "title", + streamType = StreamType.VIDEO_STREAM, duration = 1, uploader = "uploader" + ) + database.streamDAO().insert(stream) + val upserted = StreamEntity( + serviceId = 1, url = "https://newpipe.net/2", title = "title2", + streamType = StreamType.VIDEO_STREAM, duration = 1, uploader = "uploader" + ) + + val result = manager.createPlaylist("name", listOf(stream, upserted)) + + result.test().await().assertComplete() + database.streamDAO().all.test().awaitCount(1).assertValue(listOf(stream, upserted)) + } +} diff --git a/app/src/androidTest/java/org/schabi/newpipe/testUtil/TrampolineSchedulerRule.kt b/app/src/androidTest/java/org/schabi/newpipe/testUtil/TrampolineSchedulerRule.kt new file mode 100644 index 000000000..75f5c6195 --- /dev/null +++ b/app/src/androidTest/java/org/schabi/newpipe/testUtil/TrampolineSchedulerRule.kt @@ -0,0 +1,37 @@ +package org.schabi.newpipe.testUtil + +import io.reactivex.rxjava3.android.plugins.RxAndroidPlugins +import io.reactivex.rxjava3.plugins.RxJavaPlugins +import io.reactivex.rxjava3.schedulers.Schedulers +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.Statement + +/** + * Always run on [Schedulers.trampoline]. + * This executes the task in the current thread in FIFO manner. + * This ensures that tasks are run quickly inside the tests + * and not scheduled away to another thread for later execution + */ +class TrampolineSchedulerRule : TestRule { + + private val scheduler = Schedulers.trampoline() + + override fun apply(base: Statement, description: Description): Statement = + object : Statement() { + override fun evaluate() { + try { + RxJavaPlugins.setComputationSchedulerHandler { scheduler } + RxJavaPlugins.setIoSchedulerHandler { scheduler } + RxJavaPlugins.setNewThreadSchedulerHandler { scheduler } + RxJavaPlugins.setSingleSchedulerHandler { scheduler } + RxAndroidPlugins.setInitMainThreadSchedulerHandler { scheduler } + + base.evaluate() + } finally { + RxJavaPlugins.reset() + RxAndroidPlugins.reset() + } + } + } +} diff --git a/app/src/debug/java/org/schabi/newpipe/settings/DebugSettingsFragment.java b/app/src/debug/java/org/schabi/newpipe/settings/DebugSettingsFragment.java index 7e2ff69b8..d5d223ff2 100644 --- a/app/src/debug/java/org/schabi/newpipe/settings/DebugSettingsFragment.java +++ b/app/src/debug/java/org/schabi/newpipe/settings/DebugSettingsFragment.java @@ -2,7 +2,6 @@ package org.schabi.newpipe.settings; import android.os.Bundle; -import androidx.annotation.Nullable; import androidx.preference.Preference; import org.schabi.newpipe.R; @@ -11,8 +10,8 @@ import leakcanary.LeakCanary; public class DebugSettingsFragment extends BasePreferenceFragment { @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResource(R.xml.debug_settings); final Preference showMemoryLeaksPreference = findPreference(getString(R.string.show_memory_leaks_key)); @@ -31,9 +30,4 @@ public class DebugSettingsFragment extends BasePreferenceFragment { throw new RuntimeException(); }); } - - @Override - public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResource(R.xml.debug_settings); - } } diff --git a/app/src/debug/res/xml/main_settings.xml b/app/src/debug/res/xml/main_settings.xml index dfb8ffa34..d482d033c 100644 --- a/app/src/debug/res/xml/main_settings.xml +++ b/app/src/debug/res/xml/main_settings.xml @@ -6,50 +6,50 @@ diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 23128117a..75479345d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,8 @@ + package="org.schabi.newpipe" + android:installLocation="auto"> @@ -21,7 +22,6 @@ android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:logo="@mipmap/ic_launcher" - android:requestLegacyExternalStorage="true" android:theme="@style/OpeningTheme" android:resizeableActivity="true" tools:ignore="AllowBackup"> @@ -231,11 +231,10 @@ - - + + - @@ -243,6 +242,15 @@ + + + + + + + + + @@ -318,7 +326,7 @@ - + @@ -329,10 +337,23 @@ - - + + + + + + + + + + + + + + + * ExitActivity.java is part of NewPipe. @@ -48,6 +50,6 @@ public class ExitActivity extends Activity { finish(); } - System.exit(0); + 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 1b8f3190e..5b1cf48e5 100644 --- a/app/src/main/java/org/schabi/newpipe/MainActivity.java +++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java @@ -95,6 +95,7 @@ import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; public class MainActivity extends AppCompatActivity { private static final String TAG = "MainActivity"; + @SuppressWarnings("ConstantConditions") public static final boolean DEBUG = !BuildConfig.BUILD_TYPE.equals("release"); private ActivityMainBinding mainBinding; @@ -133,6 +134,8 @@ public class MainActivity extends AppCompatActivity { if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) { TLSSocketFactoryCompat.setAsDefault(); } + + ThemeHelper.setDayNightMode(this); ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this)); assureCorrectAppLanguage(this); @@ -180,27 +183,27 @@ public class MainActivity extends AppCompatActivity { drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER, R.string.tab_subscriptions) - .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_channel)); + .setIcon(R.drawable.ic_tv); drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title) - .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_rss)); + .setIcon(R.drawable.ic_rss_feed); drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks) - .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_bookmark)); + .setIcon(R.drawable.ic_bookmark); drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_tabs_group, ITEM_ID_DOWNLOADS, ORDER, R.string.downloads) - .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_file_download)); + .setIcon(R.drawable.ic_file_download); drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_tabs_group, ITEM_ID_HISTORY, ORDER, R.string.action_history) - .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_history)); + .setIcon(R.drawable.ic_history); //Settings and About drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_options_about_group, ITEM_ID_SETTINGS, ORDER, R.string.settings) - .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_settings)); + .setIcon(R.drawable.ic_settings); drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_options_about_group, ITEM_ID_ABOUT, ORDER, R.string.tab_about) - .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_info_outline)); + .setIcon(R.drawable.ic_info_outline); toggle = new ActionBarDrawerToggle(this, mainBinding.getRoot(), toolbarLayoutBinding.toolbar, R.string.drawer_open, R.string.drawer_close); @@ -346,7 +349,7 @@ public class MainActivity extends AppCompatActivity { } private void showServices() { - drawerHeaderBinding.drawerArrow.setImageResource(R.drawable.ic_arrow_drop_up_white_24dp); + drawerHeaderBinding.drawerArrow.setImageResource(R.drawable.ic_arrow_drop_up); for (final StreamingService s : NewPipe.getServices()) { final String title = s.getServiceInfo().getName() @@ -399,7 +402,7 @@ public class MainActivity extends AppCompatActivity { new Handler(Looper.getMainLooper()).postDelayed(() -> { getSupportFragmentManager().popBackStack(null, FragmentManager.POP_BACK_STACK_INCLUSIVE); - recreate(); + ActivityCompat.recreate(MainActivity.this); }, 300); } @@ -412,7 +415,7 @@ public class MainActivity extends AppCompatActivity { } private void showTabs() throws ExtractionException { - drawerHeaderBinding.drawerArrow.setImageResource(R.drawable.ic_arrow_drop_down_white_24dp); + drawerHeaderBinding.drawerArrow.setImageResource(R.drawable.ic_arrow_drop_down); //Tabs final int currentServiceId = ServiceHelper.getSelectedServiceId(this); @@ -430,27 +433,27 @@ public class MainActivity extends AppCompatActivity { drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER, R.string.tab_subscriptions) - .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_channel)); + .setIcon(R.drawable.ic_tv); drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title) - .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_rss)); + .setIcon(R.drawable.ic_rss_feed); drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks) - .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_bookmark)); + .setIcon(R.drawable.ic_bookmark); drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_tabs_group, ITEM_ID_DOWNLOADS, ORDER, R.string.downloads) - .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_file_download)); + .setIcon(R.drawable.ic_file_download); drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_tabs_group, ITEM_ID_HISTORY, ORDER, R.string.action_history) - .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_history)); + .setIcon(R.drawable.ic_history); //Settings and About drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_options_about_group, ITEM_ID_SETTINGS, ORDER, R.string.settings) - .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_settings)); + .setIcon(R.drawable.ic_settings); drawerLayoutBinding.navigation.getMenu() .add(R.id.menu_options_about_group, ITEM_ID_ABOUT, ORDER, R.string.tab_about) - .setIcon(ThemeHelper.resolveResourceIdFromAttr(this, R.attr.ic_info_outline)); + .setIcon(R.drawable.ic_info_outline); } @Override @@ -600,6 +603,7 @@ public class MainActivity extends AppCompatActivity { public void onRequestPermissionsResult(final int requestCode, @NonNull final String[] permissions, @NonNull final int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); for (final int i : grantResults) { if (i == PackageManager.PERMISSION_DENIED) { return; @@ -819,7 +823,7 @@ public class MainActivity extends AppCompatActivity { return; } - if (PlayerHolder.isPlayerOpen()) { + if (PlayerHolder.getInstance().isPlayerOpen()) { // if the player is already open, no need for a broadcast receiver openMiniPlayerIfMissing(); } else { diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java index 988a5ed98..8f7732218 100644 --- a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java +++ b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java @@ -51,4 +51,15 @@ public final class NewPipeDatabase { throw new RuntimeException("Checkpoint was blocked from completing"); } } + + public static void close() { + if (databaseInstance != null) { + synchronized (NewPipeDatabase.class) { + if (databaseInstance != null) { + databaseInstance.close(); + databaseInstance = null; + } + } + } + } } diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java index 179fab8dc..feb9e029d 100644 --- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java +++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java @@ -69,7 +69,7 @@ import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.PermissionHelper; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.urlfinder.UrlFinder; import org.schabi.newpipe.views.FocusOverlayView; @@ -91,7 +91,6 @@ import io.reactivex.rxjava3.schedulers.Schedulers; import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.AUDIO; import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.VIDEO; -import static org.schabi.newpipe.util.ThemeHelper.resolveResourceIdFromAttr; /** * Get the url from the intent and open it in the chosen preferred player. @@ -108,6 +107,7 @@ public class RouterActivity extends AppCompatActivity { protected String currentUrl; private StreamingService currentService; private boolean selectionIsDownload = false; + private AlertDialog alertDialogChoice = null; @Override protected void onCreate(final Bundle savedInstanceState) { @@ -127,6 +127,15 @@ public class RouterActivity extends AppCompatActivity { ? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark); } + @Override + protected void onStop() { + super.onStop(); + // we need to dismiss the dialog before leaving the activity or we get leaks + if (alertDialogChoice != null) { + alertDialogChoice.dismiss(); + } + } + @Override protected void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); @@ -231,7 +240,7 @@ public class RouterActivity extends AppCompatActivity { new AlertDialog.Builder(context) .setTitle(R.string.unsupported_url) .setMessage(R.string.unsupported_url_dialog_message) - .setIcon(ThemeHelper.resolveResourceIdFromAttr(context, R.attr.ic_share)) + .setIcon(R.drawable.ic_share) .setPositiveButton(R.string.open_in_browser, (dialog, which) -> ShareUtils.openUrlInBrowser(this, url)) .setNegativeButton(R.string.share, @@ -334,7 +343,7 @@ public class RouterActivity extends AppCompatActivity { } }; - final AlertDialog alertDialog = new AlertDialog.Builder(themeWrapperContext) + alertDialogChoice = new AlertDialog.Builder(themeWrapperContext) .setTitle(R.string.preferred_open_action_share_menu_title) .setView(radioGroup) .setCancelable(true) @@ -348,12 +357,12 @@ public class RouterActivity extends AppCompatActivity { .create(); //noinspection CodeBlock2Expr - alertDialog.setOnShowListener(dialog -> { - setDialogButtonsState(alertDialog, radioGroup.getCheckedRadioButtonId() != -1); + alertDialogChoice.setOnShowListener(dialog -> { + setDialogButtonsState(alertDialogChoice, radioGroup.getCheckedRadioButtonId() != -1); }); radioGroup.setOnCheckedChangeListener((group, checkedId) -> - setDialogButtonsState(alertDialog, true)); + setDialogButtonsState(alertDialogChoice, true)); final View.OnClickListener radioButtonsClickListener = v -> { final int indexOfChild = radioGroup.indexOfChild(v); if (indexOfChild == -1) { @@ -373,7 +382,7 @@ public class RouterActivity extends AppCompatActivity { final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater).getRoot(); radioButton.setText(item.description); TextViewCompat.setCompoundDrawablesRelativeWithIntrinsicBounds(radioButton, - AppCompatResources.getDrawable(getApplicationContext(), item.icon), + AppCompatResources.getDrawable(themeWrapperContext, item.icon), null, null, null); radioButton.setChecked(false); radioButton.setId(id++); @@ -403,10 +412,10 @@ public class RouterActivity extends AppCompatActivity { } selectedPreviously = selectedRadioPosition; - alertDialog.show(); + alertDialogChoice.show(); if (DeviceUtils.isTv(this)) { - FocusOverlayView.setupFocusObserver(alertDialog); + FocusOverlayView.setupFocusObserver(alertDialogChoice); } } @@ -427,16 +436,16 @@ public class RouterActivity extends AppCompatActivity { final AdapterChoiceItem videoPlayer = new AdapterChoiceItem( getString(R.string.video_player_key), getString(R.string.video_player), - resolveResourceIdFromAttr(context, R.attr.ic_play_arrow)); + R.drawable.ic_play_arrow); final AdapterChoiceItem showInfo = new AdapterChoiceItem( getString(R.string.show_info_key), getString(R.string.show_info), - resolveResourceIdFromAttr(context, R.attr.ic_info_outline)); + R.drawable.ic_info_outline); final AdapterChoiceItem popupPlayer = new AdapterChoiceItem( getString(R.string.popup_player_key), getString(R.string.popup_player), - resolveResourceIdFromAttr(context, R.attr.ic_popup)); + R.drawable.ic_picture_in_picture); final AdapterChoiceItem backgroundPlayer = new AdapterChoiceItem( getString(R.string.background_player_key), getString(R.string.background_player), - resolveResourceIdFromAttr(context, R.attr.ic_headset)); + R.drawable.ic_headset); if (linkType == LinkType.STREAM) { if (isExtVideoEnabled) { @@ -444,7 +453,7 @@ public class RouterActivity extends AppCompatActivity { returnList.add(showInfo); returnList.add(videoPlayer); } else { - final MainPlayer.PlayerType playerType = PlayerHolder.getType(); + final MainPlayer.PlayerType playerType = PlayerHolder.getInstance().getType(); if (capabilities.contains(VIDEO) && PlayerHelper.isAutoplayAllowedByUser(context) && playerType == null || playerType == MainPlayer.PlayerType.VIDEO) { @@ -467,6 +476,11 @@ public class RouterActivity extends AppCompatActivity { if (capabilities.contains(AUDIO)) { returnList.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), + getString(R.string.download), + R.drawable.ic_file_download)); } else { returnList.add(showInfo); @@ -479,10 +493,6 @@ public class RouterActivity extends AppCompatActivity { } } - returnList.add(new AdapterChoiceItem(getString(R.string.download_key), - getString(R.string.download), - resolveResourceIdFromAttr(context, R.attr.ic_file_download))); - return returnList; } @@ -579,9 +589,9 @@ public class RouterActivity extends AppCompatActivity { downloadDialog.setVideoStreams(sortedVideoStreams); downloadDialog.setAudioStreams(result.getAudioStreams()); downloadDialog.setSelectedVideoStream(selectedVideoStreamIndex); + downloadDialog.setOnDismissListener(dialog -> finish()); downloadDialog.show(fm, "downloadDialog"); fm.executePendingTransactions(); - downloadDialog.requireDialog().setOnDismissListener(dialog -> finish()); }, throwable -> showUnsupportedUrlDialog(currentUrl))); } @@ -590,6 +600,7 @@ public class RouterActivity extends AppCompatActivity { public void onRequestPermissionsResult(final int requestCode, @NonNull final String[] permissions, @NonNull final int[] grantResults) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults); for (final int i : grantResults) { if (i == PackageManager.PERMISSION_DENIED) { finish(); diff --git a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.java b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.java deleted file mode 100644 index ec28be237..000000000 --- a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.java +++ /dev/null @@ -1,192 +0,0 @@ -package org.schabi.newpipe.about; - -import android.content.Context; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatActivity; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentActivity; -import androidx.viewpager2.adapter.FragmentStateAdapter; - -import com.google.android.material.tabs.TabLayoutMediator; - -import org.schabi.newpipe.BuildConfig; -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.ActivityAboutBinding; -import org.schabi.newpipe.databinding.FragmentAboutBinding; -import org.schabi.newpipe.util.ThemeHelper; - -import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; -import static org.schabi.newpipe.util.ShareUtils.openUrlInBrowser; - -public class AboutActivity extends AppCompatActivity { - /** - * List of all software components. - */ - private static final SoftwareComponent[] SOFTWARE_COMPONENTS = { - new SoftwareComponent("ACRA", "2013", "Kevin Gaudin", - "https://github.com/ACRA/acra", StandardLicenses.APACHE2), - new SoftwareComponent("AndroidX", "2005 - 2011", "The Android Open Source Project", - "https://developer.android.com/jetpack", StandardLicenses.APACHE2), - new SoftwareComponent("CircleImageView", "2014 - 2020", "Henning Dodenhof", - "https://github.com/hdodenhof/CircleImageView", - StandardLicenses.APACHE2), - new SoftwareComponent("ExoPlayer", "2014 - 2020", "Google, Inc.", - "https://github.com/google/ExoPlayer", StandardLicenses.APACHE2), - new SoftwareComponent("GigaGet", "2014 - 2015", "Peter Cai", - "https://github.com/PaperAirplane-Dev-Team/GigaGet", StandardLicenses.GPL3), - new SoftwareComponent("Groupie", "2016", "Lisa Wray", - "https://github.com/lisawray/groupie", StandardLicenses.MIT), - new SoftwareComponent("Icepick", "2015", "Frankie Sardo", - "https://github.com/frankiesardo/icepick", StandardLicenses.EPL1), - new SoftwareComponent("Jsoup", "2009 - 2020", "Jonathan Hedley", - "https://github.com/jhy/jsoup", StandardLicenses.MIT), - new SoftwareComponent("Markwon", "2019", "Dimitry Ivanov", - "https://github.com/noties/Markwon", StandardLicenses.APACHE2), - new SoftwareComponent("Material Components for Android", "2016 - 2020", "Google, Inc.", - "https://github.com/material-components/material-components-android", - StandardLicenses.APACHE2), - new SoftwareComponent("NewPipe Extractor", "2017 - 2020", "Christian Schabesberger", - "https://github.com/TeamNewPipe/NewPipeExtractor", StandardLicenses.GPL3), - new SoftwareComponent("NoNonsense-FilePicker", "2016", "Jonas Kalderstam", - "https://github.com/spacecowboy/NoNonsense-FilePicker", - StandardLicenses.MPL2), - new SoftwareComponent("OkHttp", "2019", "Square, Inc.", - "https://square.github.io/okhttp/", StandardLicenses.APACHE2), - new SoftwareComponent("PrettyTime", "2012 - 2020", "Lincoln Baxter, III", - "https://github.com/ocpsoft/prettytime", StandardLicenses.APACHE2), - new SoftwareComponent("RxAndroid", "2015", "The RxAndroid authors", - "https://github.com/ReactiveX/RxAndroid", StandardLicenses.APACHE2), - new SoftwareComponent("RxBinding", "2015", "Jake Wharton", - "https://github.com/JakeWharton/RxBinding", StandardLicenses.APACHE2), - new SoftwareComponent("RxJava", "2016 - 2020", "RxJava Contributors", - "https://github.com/ReactiveX/RxJava", StandardLicenses.APACHE2), - new SoftwareComponent("Universal Image Loader", "2011 - 2015", "Sergey Tarasevich", - "https://github.com/nostra13/Android-Universal-Image-Loader", - StandardLicenses.APACHE2), - }; - - private static final int POS_ABOUT = 0; - private static final int POS_LICENSE = 1; - private static final int TOTAL_COUNT = 2; - - @Override - protected void onCreate(final Bundle savedInstanceState) { - assureCorrectAppLanguage(this); - super.onCreate(savedInstanceState); - ThemeHelper.setTheme(this); - setTitle(getString(R.string.title_activity_about)); - - final ActivityAboutBinding aboutBinding = ActivityAboutBinding.inflate(getLayoutInflater()); - setContentView(aboutBinding.getRoot()); - - setSupportActionBar(aboutBinding.toolbar); - getSupportActionBar().setDisplayHomeAsUpEnabled(true); - // Create the adapter that will return a fragment for each of the three - // primary sections of the activity. - final SectionsPagerAdapter mSectionsPagerAdapter = new SectionsPagerAdapter(this); - - // Set up the ViewPager with the sections adapter. - aboutBinding.container.setAdapter(mSectionsPagerAdapter); - - new TabLayoutMediator(aboutBinding.tabs, aboutBinding.container, (tab, position) -> { - switch (position) { - default: - case POS_ABOUT: - tab.setText(R.string.tab_about); - break; - case POS_LICENSE: - tab.setText(R.string.tab_licenses); - break; - } - }).attach(); - } - - @Override - public boolean onOptionsItemSelected(final MenuItem item) { - final int id = item.getItemId(); - - switch (id) { - case android.R.id.home: - finish(); - return true; - } - - return super.onOptionsItemSelected(item); - } - - /** - * A placeholder fragment containing a simple view. - */ - public static class AboutFragment extends Fragment { - public AboutFragment() { - } - - /** - * Created a new instance of this fragment for the given section number. - * - * @return New instance of {@link AboutFragment} - */ - public static AboutFragment newInstance() { - return new AboutFragment(); - } - - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, - final Bundle savedInstanceState) { - final FragmentAboutBinding aboutBinding = - FragmentAboutBinding.inflate(inflater, container, false); - final Context context = getContext(); - - aboutBinding.appVersion.setText(BuildConfig.VERSION_NAME); - - aboutBinding.githubLink.setOnClickListener(nv -> - openUrlInBrowser(context, context.getString(R.string.github_url), false)); - - aboutBinding.donationLink.setOnClickListener(v -> - openUrlInBrowser(context, context.getString(R.string.donation_url), false)); - - aboutBinding.websiteLink.setOnClickListener(nv -> - openUrlInBrowser(context, context.getString(R.string.website_url), false)); - - aboutBinding.privacyPolicyLink.setOnClickListener(v -> - openUrlInBrowser(context, context.getString(R.string.privacy_policy_url), - false)); - - return aboutBinding.getRoot(); - } - } - - /** - * A {@link FragmentStateAdapter} that returns a fragment corresponding to - * one of the sections/tabs/pages. - */ - public static class SectionsPagerAdapter extends FragmentStateAdapter { - public SectionsPagerAdapter(final FragmentActivity fa) { - super(fa); - } - - @NonNull - @Override - public Fragment createFragment(final int position) { - switch (position) { - default: - case POS_ABOUT: - return AboutFragment.newInstance(); - case POS_LICENSE: - return LicenseFragment.newInstance(SOFTWARE_COMPONENTS); - } - } - - @Override - public int getItemCount() { - // Show 2 total pages. - return TOTAL_COUNT; - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt new file mode 100644 index 000000000..0199f30d8 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt @@ -0,0 +1,191 @@ +package org.schabi.newpipe.about + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import android.widget.Button +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.viewpager2.adapter.FragmentStateAdapter +import com.google.android.material.tabs.TabLayout +import com.google.android.material.tabs.TabLayoutMediator +import org.schabi.newpipe.BuildConfig +import org.schabi.newpipe.R +import org.schabi.newpipe.databinding.ActivityAboutBinding +import org.schabi.newpipe.databinding.FragmentAboutBinding +import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.ThemeHelper +import org.schabi.newpipe.util.external_communication.ShareUtils + +class AboutActivity : AppCompatActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + Localization.assureCorrectAppLanguage(this) + super.onCreate(savedInstanceState) + ThemeHelper.setTheme(this) + title = getString(R.string.title_activity_about) + val aboutBinding = ActivityAboutBinding.inflate(layoutInflater) + setContentView(aboutBinding.root) + setSupportActionBar(aboutBinding.aboutToolbar) + supportActionBar!!.setDisplayHomeAsUpEnabled(true) + // Create the adapter that will return a fragment for each of the three + // primary sections of the activity. + val mAboutStateAdapter = AboutStateAdapter(this) + + // Set up the ViewPager with the sections adapter. + aboutBinding.aboutViewPager2.adapter = mAboutStateAdapter + TabLayoutMediator( + aboutBinding.aboutTabLayout, + aboutBinding.aboutViewPager2 + ) { tab: TabLayout.Tab, position: Int -> + when (position) { + POS_ABOUT -> tab.setText(R.string.tab_about) + POS_LICENSE -> tab.setText(R.string.tab_licenses) + else -> throw IllegalArgumentException("Unknown position for ViewPager2") + } + }.attach() + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + if (item.itemId == android.R.id.home) { + finish() + return true + } + return super.onOptionsItemSelected(item) + } + + /** + * A placeholder fragment containing a simple view. + */ + class AboutFragment : Fragment() { + private fun Button.openLink(url: Int) { + setOnClickListener { + ShareUtils.openUrlInBrowser( + context, + requireContext().getString(url), + false + ) + } + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val aboutBinding = FragmentAboutBinding.inflate(inflater, container, false) + aboutBinding.aboutAppVersion.text = BuildConfig.VERSION_NAME + aboutBinding.aboutGithubLink.openLink(R.string.github_url) + aboutBinding.aboutDonationLink.openLink(R.string.donation_url) + aboutBinding.aboutWebsiteLink.openLink(R.string.website_url) + aboutBinding.aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url) + return aboutBinding.root + } + } + + /** + * A [FragmentStateAdapter] that returns a fragment corresponding to + * one of the sections/tabs/pages. + */ + private class AboutStateAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) { + override fun createFragment(position: Int): Fragment { + return when (position) { + POS_ABOUT -> AboutFragment() + POS_LICENSE -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS) + else -> throw IllegalArgumentException("Unknown position for ViewPager2") + } + } + + override fun getItemCount(): Int { + // Show 2 total pages. + return TOTAL_COUNT + } + } + + companion object { + /** + * List of all software components. + */ + private val SOFTWARE_COMPONENTS = arrayOf( + SoftwareComponent( + "ACRA", "2013", "Kevin Gaudin", + "https://github.com/ACRA/acra", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "AndroidX", "2005 - 2011", "The Android Open Source Project", + "https://developer.android.com/jetpack", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "CircleImageView", "2014 - 2020", "Henning Dodenhof", + "https://github.com/hdodenhof/CircleImageView", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "ExoPlayer", "2014 - 2020", "Google, Inc.", + "https://github.com/google/ExoPlayer", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "GigaGet", "2014 - 2015", "Peter Cai", + "https://github.com/PaperAirplane-Dev-Team/GigaGet", StandardLicenses.GPL3 + ), + SoftwareComponent( + "Groupie", "2016", "Lisa Wray", + "https://github.com/lisawray/groupie", StandardLicenses.MIT + ), + SoftwareComponent( + "Icepick", "2015", "Frankie Sardo", + "https://github.com/frankiesardo/icepick", StandardLicenses.EPL1 + ), + SoftwareComponent( + "Jsoup", "2009 - 2020", "Jonathan Hedley", + "https://github.com/jhy/jsoup", StandardLicenses.MIT + ), + SoftwareComponent( + "Markwon", "2019", "Dimitry Ivanov", + "https://github.com/noties/Markwon", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "Material Components for Android", "2016 - 2020", "Google, Inc.", + "https://github.com/material-components/material-components-android", + StandardLicenses.APACHE2 + ), + SoftwareComponent( + "NewPipe Extractor", "2017 - 2020", "Christian Schabesberger", + "https://github.com/TeamNewPipe/NewPipeExtractor", StandardLicenses.GPL3 + ), + SoftwareComponent( + "NoNonsense-FilePicker", "2016", "Jonas Kalderstam", + "https://github.com/spacecowboy/NoNonsense-FilePicker", StandardLicenses.MPL2 + ), + SoftwareComponent( + "OkHttp", "2019", "Square, Inc.", + "https://square.github.io/okhttp/", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "PrettyTime", "2012 - 2020", "Lincoln Baxter, III", + "https://github.com/ocpsoft/prettytime", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "RxAndroid", "2015", "The RxAndroid authors", + "https://github.com/ReactiveX/RxAndroid", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "RxBinding", "2015", "Jake Wharton", + "https://github.com/JakeWharton/RxBinding", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "RxJava", "2016 - 2020", "RxJava Contributors", + "https://github.com/ReactiveX/RxJava", StandardLicenses.APACHE2 + ), + SoftwareComponent( + "Universal Image Loader", "2011 - 2015", "Sergey Tarasevich", + "https://github.com/nostra13/Android-Universal-Image-Loader", + StandardLicenses.APACHE2 + ) + ) + private const val POS_ABOUT = 0 + private const val POS_LICENSE = 1 + private const val TOTAL_COUNT = 2 + } +} diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.java b/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.java deleted file mode 100644 index f5bf4df19..000000000 --- a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.java +++ /dev/null @@ -1,145 +0,0 @@ -package org.schabi.newpipe.about; - -import android.os.Bundle; -import android.view.ContextMenu; -import android.view.LayoutInflater; -import android.view.MenuInflater; -import android.view.MenuItem; -import android.view.View; -import android.view.ViewGroup; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.FragmentLicensesBinding; -import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding; -import org.schabi.newpipe.util.ShareUtils; - -import java.io.Serializable; -import java.util.Arrays; -import java.util.Comparator; -import java.util.Objects; - -import io.reactivex.rxjava3.disposables.CompositeDisposable; - -/** - * Fragment containing the software licenses. - */ -public class LicenseFragment extends Fragment { - private static final String ARG_COMPONENTS = "components"; - private static final String LICENSE_KEY = "ACTIVE_LICENSE"; - - private SoftwareComponent[] softwareComponents; - private SoftwareComponent componentForContextMenu; - private License activeLicense; - private final CompositeDisposable compositeDisposable = new CompositeDisposable(); - - public static LicenseFragment newInstance(final SoftwareComponent[] softwareComponents) { - final Bundle bundle = new Bundle(); - bundle.putParcelableArray(ARG_COMPONENTS, Objects.requireNonNull(softwareComponents)); - final LicenseFragment fragment = new LicenseFragment(); - fragment.setArguments(bundle); - return fragment; - } - - @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - softwareComponents = (SoftwareComponent[]) getArguments() - .getParcelableArray(ARG_COMPONENTS); - - if (savedInstanceState != null) { - final Serializable license = savedInstanceState.getSerializable(LICENSE_KEY); - if (license != null) { - activeLicense = (License) license; - } - } - // Sort components by name - Arrays.sort(softwareComponents, Comparator.comparing(SoftwareComponent::getName)); - } - - @Override - public void onDestroy() { - compositeDisposable.dispose(); - super.onDestroy(); - } - - @Nullable - @Override - public View onCreateView(@NonNull final LayoutInflater inflater, - @Nullable final ViewGroup container, - @Nullable final Bundle savedInstanceState) { - final FragmentLicensesBinding binding = FragmentLicensesBinding - .inflate(inflater, container, false); - - binding.appReadLicense.setOnClickListener(v -> { - activeLicense = StandardLicenses.GPL3; - compositeDisposable.add(LicenseFragmentHelper.showLicense(getActivity(), - StandardLicenses.GPL3)); - }); - - for (final SoftwareComponent component : softwareComponents) { - final ItemSoftwareComponentBinding componentBinding = ItemSoftwareComponentBinding - .inflate(inflater, container, false); - componentBinding.name.setText(component.getName()); - componentBinding.copyright.setText(getString(R.string.copyright, - component.getYears(), - component.getCopyrightOwner(), - component.getLicense().getAbbreviation())); - - final View root = componentBinding.getRoot(); - root.setTag(component); - root.setOnClickListener(v -> { - activeLicense = component.getLicense(); - compositeDisposable.add(LicenseFragmentHelper.showLicense(getActivity(), - component.getLicense())); - }); - binding.softwareComponents.addView(root); - registerForContextMenu(root); - } - if (activeLicense != null) { - compositeDisposable.add(LicenseFragmentHelper.showLicense(getActivity(), - activeLicense)); - } - return binding.getRoot(); - } - - @Override - public void onCreateContextMenu(final ContextMenu menu, final View v, - final ContextMenu.ContextMenuInfo menuInfo) { - final MenuInflater inflater = getActivity().getMenuInflater(); - final SoftwareComponent component = (SoftwareComponent) v.getTag(); - menu.setHeaderTitle(component.getName()); - inflater.inflate(R.menu.software_component, menu); - super.onCreateContextMenu(menu, v, menuInfo); - componentForContextMenu = (SoftwareComponent) v.getTag(); - } - - @Override - public boolean onContextItemSelected(@NonNull final MenuItem item) { - // item.getMenuInfo() is null so we use the tag of the view - final SoftwareComponent component = componentForContextMenu; - if (component == null) { - return false; - } - switch (item.getItemId()) { - case R.id.action_website: - ShareUtils.openUrlInBrowser(getActivity(), component.getLink()); - return true; - case R.id.action_show_license: - compositeDisposable.add(LicenseFragmentHelper.showLicense(getActivity(), - component.getLicense())); - } - return false; - } - - @Override - public void onSaveInstanceState(@NonNull final Bundle savedInstanceState) { - super.onSaveInstanceState(savedInstanceState); - if (activeLicense != null) { - savedInstanceState.putSerializable(LICENSE_KEY, activeLicense); - } - } -} diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt b/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt new file mode 100644 index 000000000..c816d78be --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt @@ -0,0 +1,87 @@ +package org.schabi.newpipe.about + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import io.reactivex.rxjava3.disposables.CompositeDisposable +import org.schabi.newpipe.R +import org.schabi.newpipe.about.LicenseFragmentHelper.showLicense +import org.schabi.newpipe.databinding.FragmentLicensesBinding +import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding + +/** + * Fragment containing the software licenses. + */ +class LicenseFragment : Fragment() { + private lateinit var softwareComponents: Array + private var activeLicense: License? = 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 } + } + + override fun onDestroy() { + compositeDisposable.dispose() + super.onDestroy() + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + val binding = FragmentLicensesBinding.inflate(inflater, container, false) + binding.licensesAppReadLicense.setOnClickListener { + activeLicense = StandardLicenses.GPL3 + compositeDisposable.add( + showLicense(activity, StandardLicenses.GPL3) + ) + } + for (component in softwareComponents) { + val componentBinding = ItemSoftwareComponentBinding + .inflate(inflater, container, false) + componentBinding.name.text = component.name + componentBinding.copyright.text = getString( + R.string.copyright, + component.years, + component.copyrightOwner, + component.license.abbreviation + ) + val root: View = componentBinding.root + root.tag = component + root.setOnClickListener { + activeLicense = component.license + compositeDisposable.add( + showLicense(activity, component) + ) + } + binding.licensesSoftwareComponents.addView(root) + registerForContextMenu(root) + } + activeLicense?.let { compositeDisposable.add(showLicense(activity, it)) } + return binding.root + } + + override fun onSaveInstanceState(savedInstanceState: Bundle) { + super.onSaveInstanceState(savedInstanceState) + activeLicense?.let { savedInstanceState.putSerializable(LICENSE_KEY, it) } + } + + companion object { + private const val ARG_COMPONENTS = "components" + private const val LICENSE_KEY = "ACTIVE_LICENSE" + fun newInstance(softwareComponents: Array): 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.java b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.java deleted file mode 100644 index b0241049d..000000000 --- a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.java +++ /dev/null @@ -1,109 +0,0 @@ -package org.schabi.newpipe.about; - -import android.content.Context; -import android.util.Base64; -import android.webkit.WebView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.appcompat.app.AlertDialog; - -import org.schabi.newpipe.R; -import org.schabi.newpipe.util.ThemeHelper; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; - -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 static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; - -public final class LicenseFragmentHelper { - private 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 static String getFormattedLicense(@NonNull final Context context, - @NonNull final License license) { - final StringBuilder licenseContent = new StringBuilder(); - final String webViewData; - try (BufferedReader in = new BufferedReader(new InputStreamReader( - context.getAssets().open(license.getFilename()), StandardCharsets.UTF_8))) { - String str; - while ((str = in.readLine()) != null) { - licenseContent.append(str); - } - - // split the HTML file and insert the stylesheet into the HEAD of the file - webViewData = licenseContent.toString().replace("", - ""); - } catch (final IOException e) { - throw new IllegalArgumentException( - "Could not get license file: " + license.getFilename(), e); - } - return webViewData; - } - - /** - * @param context the Android context - * @return String which is a CSS stylesheet according to the context's theme - */ - private static String getLicenseStylesheet(@NonNull final Context context) { - final boolean isLightTheme = ThemeHelper.isLightThemeSelected(context); - return "body{padding:12px 15px;margin:0;" - + "background:#" + getHexRGBColor(context, isLightTheme - ? R.color.light_license_background_color - : R.color.dark_license_background_color) + ";" - + "color:#" + getHexRGBColor(context, isLightTheme - ? R.color.light_license_text_color - : R.color.dark_license_text_color) + "}" - + "a[href]{color:#" + getHexRGBColor(context, isLightTheme - ? R.color.light_youtube_primary_color - : 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 static String getHexRGBColor(@NonNull final Context context, final int color) { - return context.getResources().getString(color).substring(3); - } - - static Disposable showLicense(@Nullable final Context context, @NonNull final License license) { - if (context == null) { - return Disposable.empty(); - } - - return Observable.fromCallable(() -> getFormattedLicense(context, license)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(formattedLicense -> { - final String webViewData = Base64.encodeToString(formattedLicense - .getBytes(StandardCharsets.UTF_8), Base64.NO_PADDING); - final WebView webView = new WebView(context); - webView.loadData(webViewData, "text/html; charset=UTF-8", "base64"); - - final AlertDialog.Builder alert = new AlertDialog.Builder(context); - alert.setTitle(license.getName()); - alert.setView(webView); - assureCorrectAppLanguage(context); - alert.setNegativeButton(context.getString(R.string.finish), - (dialog, which) -> dialog.dismiss()); - alert.show(); - }); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt new file mode 100644 index 000000000..7617ef451 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt @@ -0,0 +1,147 @@ +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) + } + + @JvmStatic + fun showLicense(context: Context?, license: License): Disposable { + return if (context == null) { + Disposable.empty() + } else { + Observable.fromCallable { getFormattedLicense(context, license) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { formattedLicense: String -> + 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") + val alert = AlertDialog.Builder(context) + alert.setTitle(license.name) + alert.setView(webView) + Localization.assureCorrectAppLanguage(context) + alert.setNegativeButton( + context.getString(R.string.finish) + ) { dialog, _ -> dialog.dismiss() } + alert.show() + } + } + } + @JvmStatic + fun showLicense(context: Context?, component: SoftwareComponent): Disposable { + return if (context == null) { + Disposable.empty() + } else { + Observable.fromCallable { getFormattedLicense(context, component.license) } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { formattedLicense: String -> + 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") + val alert = AlertDialog.Builder(context) + alert.setTitle(component.license.name) + alert.setView(webView) + Localization.assureCorrectAppLanguage(context) + alert.setPositiveButton( + R.string.dismiss + ) { dialog, _ -> dialog.dismiss() } + alert.setNeutralButton(R.string.open_website_license) { _, _ -> + ShareUtils.openUrlInBrowser(context, component.link) + } + alert.show() + } + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.java b/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.java deleted file mode 100644 index 60b1e168c..000000000 --- a/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.schabi.newpipe.about; - -/** - * Class containing information about standard software licenses. - */ -public final class StandardLicenses { - public static final License GPL3 - = new License("GNU General Public License, Version 3.0", "GPLv3", "gpl_3.html"); - public static final License APACHE2 - = new License("Apache License, Version 2.0", "ALv2", "apache2.html"); - public static final License MPL2 - = new License("Mozilla Public License, Version 2.0", "MPL 2.0", "mpl2.html"); - public static final License MIT - = new License("MIT License", "MIT", "mit.html"); - public static final License EPL1 - = new License("Eclipse Public License, Version 1.0", "EPL 1.0", "epl1.html"); - - private StandardLicenses() { } -} diff --git a/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.kt b/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.kt new file mode 100644 index 000000000..c5b9618fe --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.kt @@ -0,0 +1,21 @@ +package org.schabi.newpipe.about + +/** + * Class containing information about standard software licenses. + */ +object StandardLicenses { + @JvmField + val GPL3 = License("GNU General Public License, Version 3.0", "GPLv3", "gpl_3.html") + + @JvmField + val APACHE2 = License("Apache License, Version 2.0", "ALv2", "apache2.html") + + @JvmField + val MPL2 = License("Mozilla Public License, Version 2.0", "MPL 2.0", "mpl2.html") + + @JvmField + val MIT = License("MIT License", "MIT", "mit.html") + + @JvmField + val EPL1 = License("Eclipse Public License, Version 1.0", "EPL 1.0", "epl1.html") +} diff --git a/app/src/main/java/org/schabi/newpipe/database/Converters.java b/app/src/main/java/org/schabi/newpipe/database/Converters.java deleted file mode 100644 index c46b5f427..000000000 --- a/app/src/main/java/org/schabi/newpipe/database/Converters.java +++ /dev/null @@ -1,64 +0,0 @@ -package org.schabi.newpipe.database; - -import androidx.room.TypeConverter; - -import org.schabi.newpipe.extractor.stream.StreamType; -import org.schabi.newpipe.local.subscription.FeedGroupIcon; - -import java.time.Instant; -import java.time.OffsetDateTime; -import java.time.ZoneOffset; - -public final class Converters { - private Converters() { } - - /** - * Convert a long value to a {@link OffsetDateTime}. - * - * @param value the long value - * @return the {@code OffsetDateTime} - */ - @TypeConverter - public static OffsetDateTime offsetDateTimeFromTimestamp(final Long value) { - return value == null ? null : OffsetDateTime.ofInstant(Instant.ofEpochMilli(value), - ZoneOffset.UTC); - } - - /** - * Convert a {@link OffsetDateTime} to a long value. - * - * @param offsetDateTime the {@code OffsetDateTime} - * @return the long value - */ - @TypeConverter - public static Long offsetDateTimeToTimestamp(final OffsetDateTime offsetDateTime) { - return offsetDateTime == null ? null : offsetDateTime.withOffsetSameInstant(ZoneOffset.UTC) - .toInstant().toEpochMilli(); - } - - @TypeConverter - public static StreamType streamTypeOf(final String value) { - return StreamType.valueOf(value); - } - - @TypeConverter - public static String stringOf(final StreamType streamType) { - return streamType.name(); - } - - @TypeConverter - public static Integer integerOf(final FeedGroupIcon feedGroupIcon) { - return feedGroupIcon.getId(); - } - - @TypeConverter - public static FeedGroupIcon feedGroupIconOf(final Integer id) { - for (final FeedGroupIcon icon : FeedGroupIcon.values()) { - if (icon.getId() == id) { - return icon; - } - } - - throw new IllegalArgumentException("There's no feed group icon with the id \"" + id + "\""); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/database/Converters.kt b/app/src/main/java/org/schabi/newpipe/database/Converters.kt new file mode 100644 index 000000000..0eafcede1 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/Converters.kt @@ -0,0 +1,52 @@ +package org.schabi.newpipe.database + +import androidx.room.TypeConverter +import org.schabi.newpipe.extractor.stream.StreamType +import org.schabi.newpipe.local.subscription.FeedGroupIcon +import java.time.Instant +import java.time.OffsetDateTime +import java.time.ZoneOffset + +object Converters { + /** + * Convert a long value to a [OffsetDateTime]. + * + * @param value the long value + * @return the `OffsetDateTime` + */ + @TypeConverter + fun offsetDateTimeFromTimestamp(value: Long?): OffsetDateTime? { + return value?.let { OffsetDateTime.ofInstant(Instant.ofEpochMilli(it), ZoneOffset.UTC) } + } + + /** + * Convert a [OffsetDateTime] to a long value. + * + * @param offsetDateTime the `OffsetDateTime` + * @return the long value + */ + @TypeConverter + fun offsetDateTimeToTimestamp(offsetDateTime: OffsetDateTime?): Long? { + return offsetDateTime?.withOffsetSameInstant(ZoneOffset.UTC)?.toInstant()?.toEpochMilli() + } + + @TypeConverter + fun streamTypeOf(value: String): StreamType { + return StreamType.valueOf(value) + } + + @TypeConverter + fun stringOf(streamType: StreamType): String { + return streamType.name + } + + @TypeConverter + fun integerOf(feedGroupIcon: FeedGroupIcon): Int { + return feedGroupIcon.id + } + + @TypeConverter + fun feedGroupIconOf(id: Int): FeedGroupIcon { + return FeedGroupIcon.values().first { it.id == id } + } +} 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 f216ba1d8..689f1ead6 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,7 +9,8 @@ import androidx.room.Update import io.reactivex.rxjava3.core.Flowable import org.schabi.newpipe.database.feed.model.FeedEntity import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity -import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.stream.StreamWithState +import org.schabi.newpipe.database.stream.model.StreamStateEntity import org.schabi.newpipe.database.subscription.SubscriptionEntity import java.time.OffsetDateTime @@ -20,21 +21,34 @@ abstract class FeedDAO { @Query( """ - SELECT s.* FROM streams s + 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(): Flowable> + abstract fun getAllStreams(): Flowable> @Query( """ - SELECT s.* FROM streams s + 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 @@ -42,16 +56,88 @@ abstract class FeedDAO { INNER JOIN feed_group_subscription_join fgs ON fgs.subscription_id = f.subscription_id - INNER JOIN feed_group fg - ON fg.uid = fgs.group_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 getAllStreamsFromGroup(groupId: Long): Flowable> + abstract fun getAllStreamsForGroup(groupId: Long): Flowable> + + /** + * @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) + */ + @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 + + WHERE ( + 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 getLiveOrNotPlayedStreams(): Flowable> + + /** + * @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) + */ + @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 + 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): Flowable> @Query( """ diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java index 535f2d2d0..0a765ed4e 100644 --- a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java +++ b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java @@ -21,7 +21,7 @@ import static org.schabi.newpipe.database.stream.StreamStatisticsEntry.STREAM_WA 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.StreamStateEntity.JOIN_STREAM_ID_ALIAS; -import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_TIME; +import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS; import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; @Dao @@ -80,7 +80,7 @@ public abstract class StreamHistoryDAO implements HistoryDAO { +public interface PlaylistDAO extends BasicDAO { @Override @Query("SELECT * FROM " + PLAYLIST_TABLE) - public abstract Flowable> getAll(); + Flowable> getAll(); @Override @Query("DELETE FROM " + PLAYLIST_TABLE) - public abstract int deleteAll(); + int deleteAll(); @Override - public Flowable> listByService(final int serviceId) { + default Flowable> listByService(final int serviceId) { throw new UnsupportedOperationException(); } @Query("SELECT * FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId") - public abstract Flowable> getPlaylist(long playlistId); + Flowable> getPlaylist(long playlistId); @Query("DELETE FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId") - public abstract int deletePlaylist(long playlistId); + int deletePlaylist(long playlistId); @Query("SELECT COUNT(*) FROM " + PLAYLIST_TABLE) - public abstract Flowable getCount(); + Flowable getCount(); } diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java index a488f00fc..6bb849428 100644 --- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java +++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java @@ -17,31 +17,31 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.RE import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL; @Dao -public abstract class PlaylistRemoteDAO implements BasicDAO { +public interface PlaylistRemoteDAO extends BasicDAO { @Override @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE) - public abstract Flowable> getAll(); + Flowable> getAll(); @Override @Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE) - public abstract int deleteAll(); + int deleteAll(); @Override @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") - public abstract Flowable> listByService(int serviceId); + Flowable> listByService(int serviceId); @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE " + REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") - public abstract Flowable> getPlaylist(long serviceId, String url); + Flowable> getPlaylist(long serviceId, String url); @Query("SELECT " + REMOTE_PLAYLIST_ID + " FROM " + REMOTE_PLAYLIST_TABLE + " WHERE " + REMOTE_PLAYLIST_URL + " = :url " + "AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") - abstract Long getPlaylistIdInternal(long serviceId, String url); + Long getPlaylistIdInternal(long serviceId, String url); @Transaction - public long upsert(final PlaylistRemoteEntity playlist) { + default long upsert(final PlaylistRemoteEntity playlist) { final Long playlistId = getPlaylistIdInternal(playlist.getServiceId(), playlist.getUrl()); if (playlistId == null) { @@ -55,5 +55,5 @@ public abstract class PlaylistRemoteDAO implements BasicDAO { +public interface PlaylistStreamDAO extends BasicDAO { @Override @Query("SELECT * FROM " + PLAYLIST_STREAM_JOIN_TABLE) - public abstract Flowable> getAll(); + Flowable> getAll(); @Override @Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE) - public abstract int deleteAll(); + int deleteAll(); @Override - public Flowable> listByService(final int serviceId) { + default Flowable> listByService(final int serviceId) { throw new UnsupportedOperationException(); } @Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId") - public abstract void deleteBatch(long playlistId); + void deleteBatch(long playlistId); @Query("SELECT COALESCE(MAX(" + JOIN_INDEX + "), -1)" + " FROM " + PLAYLIST_STREAM_JOIN_TABLE + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId") - public abstract Flowable getMaximumIndexOf(long playlistId); + Flowable getMaximumIndexOf(long playlistId); @Transaction @Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN " @@ -64,12 +64,12 @@ public abstract class PlaylistStreamDAO implements BasicDAO> getOrderedStreamsOf(long playlistId); + Flowable> getOrderedStreamsOf(long playlistId); @Transaction @Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", " + PLAYLIST_THUMBNAIL_URL + ", " @@ -80,5 +80,5 @@ public abstract class PlaylistStreamDAO implements BasicDAO> getPlaylistMetadata(); + Flowable> getPlaylistMetadata(); } 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 eca12f584..9a622f643 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 @@ -5,7 +5,7 @@ import androidx.room.Embedded import org.schabi.newpipe.database.LocalItem 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_TIME +import org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS import org.schabi.newpipe.extractor.stream.StreamInfoItem import java.time.OffsetDateTime @@ -13,8 +13,8 @@ class StreamStatisticsEntry( @Embedded val streamEntity: StreamEntity, - @ColumnInfo(name = STREAM_PROGRESS_TIME, defaultValue = "0") - val progressTime: Long, + @ColumnInfo(name = STREAM_PROGRESS_MILLIS, defaultValue = "0") + val progressMillis: Long, @ColumnInfo(name = StreamHistoryEntity.JOIN_STREAM_ID) val streamId: Long, diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamWithState.kt b/app/src/main/java/org/schabi/newpipe/database/stream/StreamWithState.kt new file mode 100644 index 000000000..abeabf888 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/database/stream/StreamWithState.kt @@ -0,0 +1,14 @@ +package org.schabi.newpipe.database.stream + +import androidx.room.ColumnInfo +import androidx.room.Embedded +import org.schabi.newpipe.database.stream.model.StreamEntity +import org.schabi.newpipe.database.stream.model.StreamStateEntity + +data class StreamWithState( + @Embedded + val stream: StreamEntity, + + @ColumnInfo(name = StreamStateEntity.STREAM_PROGRESS_MILLIS) + val stateProgressMillis: Long? +) diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java index a6b36e3ff..06371248d 100644 --- a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java +++ b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java @@ -17,31 +17,31 @@ import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_ST import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE; @Dao -public abstract class StreamStateDAO implements BasicDAO { +public interface StreamStateDAO extends BasicDAO { @Override @Query("SELECT * FROM " + STREAM_STATE_TABLE) - public abstract Flowable> getAll(); + Flowable> getAll(); @Override @Query("DELETE FROM " + STREAM_STATE_TABLE) - public abstract int deleteAll(); + int deleteAll(); @Override - public Flowable> listByService(final int serviceId) { + default Flowable> listByService(final int serviceId) { throw new UnsupportedOperationException(); } @Query("SELECT * FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") - public abstract Flowable> getState(long streamId); + Flowable> getState(long streamId); @Query("DELETE FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId") - public abstract int deleteState(long streamId); + int deleteState(long streamId); @Insert(onConflict = OnConflictStrategy.IGNORE) - abstract void silentInsertInternal(StreamStateEntity streamState); + void silentInsertInternal(StreamStateEntity streamState); @Transaction - public long upsert(final StreamStateEntity stream) { + default long upsert(final StreamStateEntity stream) { silentInsertInternal(stream); return update(stream); } 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 1ce834a82..75766850f 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 @@ -5,7 +5,7 @@ import androidx.room.ColumnInfo; import androidx.room.Entity; import androidx.room.ForeignKey; -import java.util.concurrent.TimeUnit; +import java.util.Objects; import static androidx.room.ForeignKey.CASCADE; import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID; @@ -25,26 +25,31 @@ public class StreamStateEntity { // This additional field is required for the SQL query because 'stream_id' is used // for some other joins already public static final String JOIN_STREAM_ID_ALIAS = "stream_id_alias"; - public static final String STREAM_PROGRESS_TIME = "progress_time"; + public static final String STREAM_PROGRESS_MILLIS = "progress_time"; /** - * Playback state will not be saved, if playback time is less than this threshold. + * Playback state will not be saved, if playback time is less than this threshold (5000ms = 5s). */ - private static final int PLAYBACK_SAVE_THRESHOLD_START_SECONDS = 5; + private static final long PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS = 5000; + /** - * Playback state will not be saved, if time left is less than this threshold. + * Stream will be considered finished if the playback time left exceeds this threshold + * (60000ms = 60s). + * @see #isFinished(long) + * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreams() + * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreamsForGroup(long) */ - private static final int PLAYBACK_SAVE_THRESHOLD_END_SECONDS = 10; + public static final long PLAYBACK_FINISHED_END_MILLISECONDS = 60000; @ColumnInfo(name = JOIN_STREAM_ID) private long streamUid; - @ColumnInfo(name = STREAM_PROGRESS_TIME) - private long progressTime; + @ColumnInfo(name = STREAM_PROGRESS_MILLIS) + private long progressMillis; - public StreamStateEntity(final long streamUid, final long progressTime) { + public StreamStateEntity(final long streamUid, final long progressMillis) { this.streamUid = streamUid; - this.progressTime = progressTime; + this.progressMillis = progressMillis; } public long getStreamUid() { @@ -55,27 +60,53 @@ public class StreamStateEntity { this.streamUid = streamUid; } - public long getProgressTime() { - return progressTime; + public long getProgressMillis() { + return progressMillis; } - public void setProgressTime(final long progressTime) { - this.progressTime = progressTime; + public void setProgressMillis(final long progressMillis) { + this.progressMillis = progressMillis; } - public boolean isValid(final int durationInSeconds) { - final int seconds = (int) TimeUnit.MILLISECONDS.toSeconds(progressTime); - return seconds > PLAYBACK_SAVE_THRESHOLD_START_SECONDS - && seconds < durationInSeconds - PLAYBACK_SAVE_THRESHOLD_END_SECONDS; + /** + * The state will be considered valid, and thus be saved, if the progress is more than {@link + * #PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS} or at least 1/4 of the video length. + * @param durationInSeconds the duration of the stream connected with this state, in seconds + * @return whether this stream state entity should be saved or not + */ + public boolean isValid(final long durationInSeconds) { + return progressMillis > PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS + || progressMillis > durationInSeconds * 1000 / 4; + } + + /** + * The video will be considered as finished, if the time left is less than {@link + * #PLAYBACK_FINISHED_END_MILLISECONDS} and the progress is at least 3/4 of the video length. + * The state will be saved anyway, so that it can be shown under stream info items, but the + * player will not resume if a state is considered as finished. Finished streams are also the + * ones that can be filtered out in the feed fragment. + * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreams() + * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreamsForGroup(long) + * @param durationInSeconds the duration of the stream connected with this state, in seconds + * @return whether the stream is finished or not + */ + public boolean isFinished(final long durationInSeconds) { + return progressMillis >= durationInSeconds * 1000 - PLAYBACK_FINISHED_END_MILLISECONDS + && progressMillis >= durationInSeconds * 1000 * 3 / 4; } @Override public boolean equals(@Nullable final Object obj) { if (obj instanceof StreamStateEntity) { return ((StreamStateEntity) obj).streamUid == streamUid - && ((StreamStateEntity) obj).progressTime == progressTime; + && ((StreamStateEntity) obj).progressMillis == progressMillis; } else { return false; } } + + @Override + public int hashCode() { + return Objects.hash(streamUid, progressMillis); + } } 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 484a46497..628496c01 100644 --- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java +++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java @@ -3,6 +3,8 @@ package org.schabi.newpipe.download; 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; @@ -20,6 +22,9 @@ import android.widget.RadioGroup; import android.widget.SeekBar; import android.widget.Toast; +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.IdRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -35,7 +40,6 @@ import com.nononsenseapps.filepicker.Utils; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; -import org.schabi.newpipe.RouterActivity; import org.schabi.newpipe.databinding.DownloadDialogBinding; import org.schabi.newpipe.error.ErrorActivity; import org.schabi.newpipe.error.ErrorInfo; @@ -49,6 +53,8 @@ import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.SubtitlesStream; import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.settings.NewPipeSettings; +import org.schabi.newpipe.streams.io.StoredDirectoryHelper; +import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.FilePickerActivityHelper; import org.schabi.newpipe.util.FilenameUtils; import org.schabi.newpipe.util.ListHelper; @@ -68,8 +74,6 @@ import icepick.Icepick; import icepick.State; import io.reactivex.rxjava3.disposables.CompositeDisposable; import us.shandian.giga.get.MissionRecoveryInfo; -import us.shandian.giga.io.StoredDirectoryHelper; -import us.shandian.giga.io.StoredFileHelper; import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManagerService; @@ -82,7 +86,6 @@ public class DownloadDialog extends DialogFragment implements RadioGroup.OnCheckedChangeListener, AdapterView.OnItemSelectedListener { private static final String TAG = "DialogFragment"; private static final boolean DEBUG = MainActivity.DEBUG; - private static final int REQUEST_DOWNLOAD_SAVE_AS = 0x1230; @State StreamInfo currentInfo; @@ -99,6 +102,9 @@ public class DownloadDialog extends DialogFragment @State int selectedSubtitleIndex = 0; + @Nullable + private OnDismissListener onDismissListener = null; + private StoredDirectoryHelper mainStorageAudio = null; private StoredDirectoryHelper mainStorageVideo = null; private DownloadManager downloadManager = null; @@ -116,6 +122,25 @@ public class DownloadDialog extends DialogFragment private SharedPreferences prefs; + // Variables for file name and MIME type when picking new folder because it's not set yet + private String filenameTmp; + private String mimeTmp; + + private final ActivityResultLauncher requestDownloadSaveAsLauncher = + registerForActivityResult( + new StartActivityForResult(), this::requestDownloadSaveAsResult); + private final ActivityResultLauncher requestDownloadPickAudioFolderLauncher = + registerForActivityResult( + new StartActivityForResult(), this::requestDownloadPickAudioFolderResult); + private final ActivityResultLauncher requestDownloadPickVideoFolderLauncher = + registerForActivityResult( + new StartActivityForResult(), this::requestDownloadPickVideoFolderResult); + + + /*////////////////////////////////////////////////////////////////////////// + // Instance creation + //////////////////////////////////////////////////////////////////////////*/ + public static DownloadDialog newInstance(final StreamInfo info) { final DownloadDialog dialog = new DownloadDialog(); dialog.setInfo(info); @@ -137,6 +162,11 @@ public class DownloadDialog extends DialogFragment return instance; } + + /*////////////////////////////////////////////////////////////////////////// + // Setters + //////////////////////////////////////////////////////////////////////////*/ + private void setInfo(final StreamInfo info) { this.currentInfo = info; } @@ -153,10 +183,6 @@ public class DownloadDialog extends DialogFragment setVideoStreams(new StreamSizeWrapper<>(videoStreams, getContext())); } - /*////////////////////////////////////////////////////////////////////////// - // LifeCycle - //////////////////////////////////////////////////////////////////////////*/ - public void setVideoStreams(final StreamSizeWrapper wvs) { this.wrappedVideoStreams = wvs; } @@ -182,6 +208,14 @@ public class DownloadDialog extends DialogFragment this.selectedSubtitleIndex = ssi; } + public void setOnDismissListener(@Nullable final OnDismissListener onDismissListener) { + this.onDismissListener = onDismissListener; + } + + /*////////////////////////////////////////////////////////////////////////// + // Android lifecycle + //////////////////////////////////////////////////////////////////////////*/ + @Override public void onCreate(@Nullable final Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -192,7 +226,7 @@ public class DownloadDialog extends DialogFragment if (!PermissionHelper.checkStoragePermissions(getActivity(), PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { - getDialog().dismiss(); + dismiss(); return; } @@ -251,10 +285,6 @@ public class DownloadDialog extends DialogFragment }, Context.BIND_AUTO_CREATE); } - /*////////////////////////////////////////////////////////////////////////// - // Inits - //////////////////////////////////////////////////////////////////////////*/ - @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { @@ -310,27 +340,35 @@ public class DownloadDialog extends DialogFragment fetchStreamsSize(); } - private void fetchStreamsSize() { - disposables.clear(); + private void initToolbar(final Toolbar toolbar) { + if (DEBUG) { + Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]"); + } - disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedVideoStreams) - .subscribe(result -> { - if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.video_button) { - setupVideoSpinner(); + toolbar.setTitle(R.string.download_dialog_title); + toolbar.setNavigationIcon(R.drawable.ic_arrow_back); + toolbar.inflateMenu(R.menu.dialog_url); + toolbar.setNavigationOnClickListener(v -> dismiss()); + toolbar.setNavigationContentDescription(R.string.cancel); + + okButton = toolbar.findViewById(R.id.okay); + okButton.setEnabled(false); // disable until the download service connection is done + + toolbar.setOnMenuItemClickListener(item -> { + if (item.getItemId() == R.id.okay) { + prepareSelectedDownload(); + return true; } - })); - disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedAudioStreams) - .subscribe(result -> { - if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) { - setupAudioSpinner(); - } - })); - disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedSubtitleStreams) - .subscribe(result -> { - if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.subtitle_button) { - setupSubtitleSpinner(); - } - })); + return false; + }); + } + + @Override + public void onDismiss(@NonNull final DialogInterface dialog) { + super.onDismiss(dialog); + if (onDismissListener != null) { + onDismissListener.onDismiss(dialog); + } } @Override @@ -345,80 +383,51 @@ public class DownloadDialog extends DialogFragment super.onDestroyView(); } - /*////////////////////////////////////////////////////////////////////////// - // Radio group Video&Audio options - Listener - //////////////////////////////////////////////////////////////////////////*/ - @Override public void onSaveInstanceState(@NonNull final Bundle outState) { super.onSaveInstanceState(outState); Icepick.saveInstanceState(this, outState); } - /*////////////////////////////////////////////////////////////////////////// - // Streams Spinner Listener - //////////////////////////////////////////////////////////////////////////*/ - - @Override - public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { - super.onActivityResult(requestCode, resultCode, data); - - if (requestCode == REQUEST_DOWNLOAD_SAVE_AS && resultCode == Activity.RESULT_OK) { - if (data.getData() == null) { - showFailedDialog(R.string.general_error); - return; - } - - if (FilePickerActivityHelper.isOwnFileUri(context, data.getData())) { - final File file = Utils.getFileForUri(data.getData()); - checkSelectedDownload(null, Uri.fromFile(file), file.getName(), - StoredFileHelper.DEFAULT_MIME); - return; - } - - final DocumentFile docFile = DocumentFile.fromSingleUri(context, data.getData()); - if (docFile == null) { - showFailedDialog(R.string.general_error); - return; - } - - // check if the selected file was previously used - checkSelectedDownload(null, data.getData(), docFile.getName(), - docFile.getType()); - } - } - - private void initToolbar(final Toolbar toolbar) { - if (DEBUG) { - Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]"); - } - - toolbar.setTitle(R.string.download_dialog_title); - toolbar.setNavigationIcon( - ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_arrow_back)); - toolbar.inflateMenu(R.menu.dialog_url); - toolbar.setNavigationOnClickListener(v -> requireDialog().dismiss()); - toolbar.setNavigationContentDescription(R.string.cancel); - - okButton = toolbar.findViewById(R.id.okay); - okButton.setEnabled(false); // disable until the download service connection is done - - toolbar.setOnMenuItemClickListener(item -> { - if (item.getItemId() == R.id.okay) { - prepareSelectedDownload(); - if (getActivity() instanceof RouterActivity) { - getActivity().finish(); - } - return true; - } - return false; - }); - } /*////////////////////////////////////////////////////////////////////////// - // Utils + // Video, audio and subtitle spinners //////////////////////////////////////////////////////////////////////////*/ + private void fetchStreamsSize() { + disposables.clear(); + disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedVideoStreams) + .subscribe(result -> { + if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() + == R.id.video_button) { + setupVideoSpinner(); + } + }, throwable -> ErrorActivity.reportErrorInSnackbar(context, + new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, + "Downloading video stream size", + currentInfo.getServiceId())))); + disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedAudioStreams) + .subscribe(result -> { + if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() + == R.id.audio_button) { + setupAudioSpinner(); + } + }, throwable -> ErrorActivity.reportErrorInSnackbar(context, + new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, + "Downloading audio stream size", + currentInfo.getServiceId())))); + disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedSubtitleStreams) + .subscribe(result -> { + if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() + == R.id.subtitle_button) { + setupSubtitleSpinner(); + } + }, throwable -> ErrorActivity.reportErrorInSnackbar(context, + new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, + "Downloading subtitle stream size", + currentInfo.getServiceId())))); + } + private void setupAudioSpinner() { if (getContext() == null) { return; @@ -449,6 +458,88 @@ public class DownloadDialog extends DialogFragment setRadioButtonsState(true); } + + /*////////////////////////////////////////////////////////////////////////// + // Activity results + //////////////////////////////////////////////////////////////////////////*/ + + private void requestDownloadPickAudioFolderResult(final ActivityResult result) { + requestDownloadPickFolderResult( + result, getString(R.string.download_path_audio_key), DownloadManager.TAG_AUDIO); + } + + private void requestDownloadPickVideoFolderResult(final ActivityResult result) { + requestDownloadPickFolderResult( + result, getString(R.string.download_path_video_key), DownloadManager.TAG_VIDEO); + } + + private void requestDownloadSaveAsResult(final ActivityResult result) { + if (result.getResultCode() != Activity.RESULT_OK) { + return; + } + + if (result.getData() == null || result.getData().getData() == null) { + showFailedDialog(R.string.general_error); + return; + } + + if (FilePickerActivityHelper.isOwnFileUri(context, result.getData().getData())) { + final File file = Utils.getFileForUri(result.getData().getData()); + checkSelectedDownload(null, Uri.fromFile(file), file.getName(), + StoredFileHelper.DEFAULT_MIME); + return; + } + + final DocumentFile docFile + = DocumentFile.fromSingleUri(context, result.getData().getData()); + if (docFile == null) { + showFailedDialog(R.string.general_error); + return; + } + + // check if the selected file was previously used + checkSelectedDownload(null, result.getData().getData(), docFile.getName(), + docFile.getType()); + } + + private void requestDownloadPickFolderResult(final ActivityResult result, + final String key, + final String tag) { + if (result.getResultCode() != Activity.RESULT_OK) { + return; + } + + if (result.getData() == null || result.getData().getData() == null) { + showFailedDialog(R.string.general_error); + return; + } + + Uri uri = result.getData().getData(); + if (FilePickerActivityHelper.isOwnFileUri(context, uri)) { + uri = Uri.fromFile(Utils.getFileForUri(uri)); + } else { + context.grantUriPermission(context.getPackageName(), uri, + StoredDirectoryHelper.PERMISSION_FLAGS); + } + + PreferenceManager.getDefaultSharedPreferences(context).edit() + .putString(key, uri.toString()).apply(); + + try { + final StoredDirectoryHelper mainStorage + = new StoredDirectoryHelper(context, uri, tag); + checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), + filenameTmp, mimeTmp); + } catch (final IOException e) { + showFailedDialog(R.string.general_error); + } + } + + + /*////////////////////////////////////////////////////////////////////////// + // Listeners + //////////////////////////////////////////////////////////////////////////*/ + @Override public void onCheckedChanged(final RadioGroup group, @IdRes final int checkedId) { if (DEBUG) { @@ -498,6 +589,11 @@ public class DownloadDialog extends DialogFragment public void onNothingSelected(final AdapterView parent) { } + + /*////////////////////////////////////////////////////////////////////////// + // Download + //////////////////////////////////////////////////////////////////////////*/ + protected void setupDownloadOptions() { setRadioButtonsState(false); @@ -510,7 +606,7 @@ public class DownloadDialog extends DialogFragment dialogBinding.subtitleButton.setVisibility(isSubtitleStreamsAvailable ? View.VISIBLE : View.GONE); - prefs = PreferenceManager.getDefaultSharedPreferences(getContext()); + prefs = PreferenceManager.getDefaultSharedPreferences(requireContext()); final String defaultMedia = prefs.getString(getString(R.string.last_used_download_type), getString(R.string.last_download_type_video_key)); @@ -538,7 +634,7 @@ public class DownloadDialog extends DialogFragment } else { Toast.makeText(getContext(), R.string.no_streams_available_download, Toast.LENGTH_SHORT).show(); - getDialog().dismiss(); + dismiss(); } } @@ -590,91 +686,98 @@ public class DownloadDialog extends DialogFragment .show(); } + private void launchDirectoryPicker(final ActivityResultLauncher launcher) { + launcher.launch(StoredDirectoryHelper.getPicker(context)); + } + private void prepareSelectedDownload() { final StoredDirectoryHelper mainStorage; final MediaFormat format; - final String mime; final String selectedMediaType; // first, build the filename and get the output folder (if possible) // later, run a very very very large file checking logic - String filename = getNameEditText().concat("."); + filenameTmp = getNameEditText().concat("."); switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) { case R.id.audio_button: selectedMediaType = getString(R.string.last_download_type_audio_key); mainStorage = mainStorageAudio; format = audioStreamsAdapter.getItem(selectedAudioIndex).getFormat(); - switch (format) { - case WEBMA_OPUS: - mime = "audio/ogg"; - filename += "opus"; - break; - default: - mime = format.mimeType; - filename += format.suffix; - break; + if (format == MediaFormat.WEBMA_OPUS) { + mimeTmp = "audio/ogg"; + filenameTmp += "opus"; + } else { + mimeTmp = format.mimeType; + filenameTmp += format.suffix; } break; case R.id.video_button: selectedMediaType = getString(R.string.last_download_type_video_key); mainStorage = mainStorageVideo; format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat(); - mime = format.mimeType; - filename += format.suffix; + mimeTmp = format.mimeType; + filenameTmp += format.suffix; 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(); - mime = format.mimeType; - filename += (format == MediaFormat.TTML ? MediaFormat.SRT : format).suffix; + mimeTmp = format.mimeType; + filenameTmp += (format == MediaFormat.TTML ? MediaFormat.SRT : format).suffix; break; default: throw new RuntimeException("No stream selected"); } - if (mainStorage == null || askForSavePath) { - // This part is called if with SAF preferred: - // * older android version running - // * save path not defined (via download settings) - // * the user checked the "ask where to download" option + if (!askForSavePath + && (mainStorage == null + || mainStorage.isDirect() == NewPipeSettings.useStorageAccessFramework(context) + || mainStorage.isInvalidSafStorage())) { + // Pick new download folder if one of: + // - Download folder is not set + // - Download folder uses SAF while SAF is disabled + // - Download folder doesn't use SAF while SAF is enabled + // - Download folder uses SAF but the user manually revoked access to it + Toast.makeText(context, getString(R.string.no_dir_yet), + Toast.LENGTH_LONG).show(); - if (!askForSavePath) { - Toast.makeText(context, getString(R.string.no_available_dir), - Toast.LENGTH_LONG).show(); - } - - if (NewPipeSettings.useStorageAccessFramework(context)) { - StoredFileHelper.requestSafWithFileCreation(this, REQUEST_DOWNLOAD_SAVE_AS, - filename, mime); + if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) { + launchDirectoryPicker(requestDownloadPickAudioFolderLauncher); } else { - File initialSavePath; - if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) { - initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MUSIC); - } else { - initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MOVIES); - } - - initialSavePath = new File(initialSavePath, filename); - startActivityForResult(FilePickerActivityHelper.chooseFileToSave(context, - initialSavePath.getAbsolutePath()), REQUEST_DOWNLOAD_SAVE_AS); + launchDirectoryPicker(requestDownloadPickVideoFolderLauncher); } return; } + if (askForSavePath) { + final Uri initialPath; + if (NewPipeSettings.useStorageAccessFramework(context)) { + initialPath = null; + } else { + final File initialSavePath; + if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() == R.id.audio_button) { + initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MUSIC); + } else { + initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MOVIES); + } + initialPath = Uri.parse(initialSavePath.getAbsolutePath()); + } + + requestDownloadSaveAsLauncher.launch(StoredFileHelper.getNewPicker(context, + filenameTmp, mimeTmp, initialPath)); + + return; + } + // check for existing file with the same name - checkSelectedDownload(mainStorage, mainStorage.findFile(filename), filename, mime); + 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) + prefs.edit().putString(getString(R.string.last_used_download_type), selectedMediaType) .apply(); - - Toast.makeText(context, getString(R.string.download_has_started), - Toast.LENGTH_SHORT).show(); } private void checkSelectedDownload(final StoredDirectoryHelper mainStorage, @@ -701,15 +804,14 @@ public class DownloadDialog extends DialogFragment return; } - // check if is our file + // get state of potential mission referring to the same file final MissionState state = downloadManager.checkForExistingMission(storage); - @StringRes - final int msgBtn; - @StringRes - final int msgBody; + @StringRes final int msgBtn; + @StringRes final int msgBody; + // this switch checks if there is already a mission referring to the same file switch (state) { - case Finished: + case Finished: // there is already a finished mission msgBtn = R.string.overwrite; msgBody = R.string.overwrite_finished_warning; break; @@ -721,7 +823,7 @@ public class DownloadDialog extends DialogFragment msgBtn = R.string.generate_unique_name; msgBody = R.string.download_already_running; break; - case None: + case None: // there is no mission referring to the same file if (mainStorage == null) { // This part is called if: // * using SAF on older android version @@ -756,7 +858,7 @@ public class DownloadDialog extends DialogFragment msgBody = R.string.overwrite_unrelated_warning; break; default: - return; + return; // unreachable } final AlertDialog.Builder askDialog = new AlertDialog.Builder(context) @@ -930,6 +1032,9 @@ public class DownloadDialog extends DialogFragment DownloadManagerService.startMission(context, urls, storage, kind, threads, currentInfo.getUrl(), psName, psArgs, nearLength, recoveryInfo); + Toast.makeText(context, getString(R.string.download_has_started), + Toast.LENGTH_SHORT).show(); + dismiss(); } } 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 c39d616e6..c0d88c8ec 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java @@ -1,7 +1,6 @@ package org.schabi.newpipe.error; import android.app.Activity; -import android.app.AlertDialog; import android.content.Context; import android.content.Intent; import android.graphics.Color; @@ -16,6 +15,7 @@ import android.view.View; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; +import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.fragment.app.Fragment; @@ -27,7 +27,7 @@ import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; import org.schabi.newpipe.databinding.ActivityErrorBinding; import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.ThemeHelper; import java.time.LocalDateTime; @@ -137,6 +137,8 @@ public class ErrorActivity extends AppCompatActivity { protected void onCreate(final Bundle savedInstanceState) { assureCorrectAppLanguage(this); super.onCreate(savedInstanceState); + + ThemeHelper.setDayNightMode(this); ThemeHelper.setTheme(this); activityErrorBinding = ActivityErrorBinding.inflate(getLayoutInflater()); @@ -188,15 +190,17 @@ public class ErrorActivity extends AppCompatActivity { @Override public boolean onOptionsItemSelected(final MenuItem item) { - final int id = item.getItemId(); - if (id == android.R.id.home) { - onBackPressed(); - } else if (id == R.id.menu_item_share_error) { - ShareUtils.shareText(this, getString(R.string.error_report_title), buildJson()); - } else { - return false; + switch (item.getItemId()) { + case android.R.id.home: + onBackPressed(); + return true; + case R.id.menu_item_share_error: + ShareUtils.shareText(getApplicationContext(), + getString(R.string.error_report_title), buildJson()); + return true; + default: + return false; } - return true; } private void openPrivacyPolicyDialog(final Context context, final String action) { @@ -217,13 +221,10 @@ public class ErrorActivity extends AppCompatActivity { + getString(R.string.app_name) + " " + BuildConfig.VERSION_NAME) .putExtra(Intent.EXTRA_TEXT, buildJson()); - if (i.resolveActivity(getPackageManager()) != null) { - ShareUtils.openIntentInApp(context, i); - } + ShareUtils.openIntentInApp(context, i, true); } else if (action.equals("GITHUB")) { // open the NewPipe issue page on GitHub ShareUtils.openUrlInBrowser(this, ERROR_GITHUB_ISSUE_URL, false); } - }) .setNegativeButton(R.string.decline, (dialog, which) -> { // do nothing 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 e1249bc83..487e7c7fb 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt @@ -6,6 +6,7 @@ import kotlinx.android.parcel.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 @@ -95,6 +96,7 @@ class ErrorInfo( action: UserAction ): Int { return when { + throwable is AccountTerminatedException -> R.string.account_terminated throwable is ContentNotAvailableException -> R.string.content_not_available throwable != null && throwable.isNetworkRelated -> R.string.network_error throwable is ContentNotSupportedException -> R.string.content_not_supported 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 49bcfa926..e790c5fc5 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt +++ b/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt @@ -13,6 +13,8 @@ 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 import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException @@ -22,9 +24,11 @@ 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.utils.Utils.isNullOrEmpty 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 java.util.concurrent.TimeUnit class ErrorPanelHelper( @@ -35,6 +39,8 @@ class ErrorPanelHelper( private val context: Context = rootView.context!! private val errorPanelRoot: View = rootView.findViewById(R.id.error_panel) private val errorTextView: TextView = errorPanelRoot.findViewById(R.id.error_message_view) + private val errorServiceInfoTextView: TextView = errorPanelRoot.findViewById(R.id.error_message_service_info_view) + private val errorServiceExplenationTextView: TextView = errorPanelRoot.findViewById(R.id.error_message_service_explenation_view) private val errorButtonAction: Button = errorPanelRoot.findViewById(R.id.error_button_action) private val errorButtonRetry: Button = errorPanelRoot.findViewById(R.id.error_button_retry) @@ -70,13 +76,40 @@ class ErrorPanelHelper( errorButtonAction.setOnClickListener(null) } errorTextView.setText(R.string.recaptcha_request_toast) + // additional info is only provided by AccountTerminatedException + errorServiceInfoTextView.isVisible = false + errorServiceExplenationTextView.isVisible = false errorButtonRetry.isVisible = true + } else if (errorInfo.throwable is AccountTerminatedException) { + errorButtonRetry.isVisible = false + errorButtonAction.isVisible = false + errorTextView.setText(R.string.account_terminated) + if (!isNullOrEmpty((errorInfo.throwable as AccountTerminatedException).message)) { + errorServiceInfoTextView.setText( + context.resources.getString( + R.string.service_provides_reason, + NewPipe.getNameOfService(ServiceHelper.getSelectedServiceId(context)) + ) + ) + errorServiceExplenationTextView.setText( + (errorInfo.throwable as AccountTerminatedException).message + ) + errorServiceInfoTextView.isVisible = true + errorServiceExplenationTextView.isVisible = true + } else { + errorServiceInfoTextView.isVisible = false + errorServiceExplenationTextView.isVisible = false + } } else { errorButtonAction.setText(R.string.error_snackbar_action) errorButtonAction.setOnClickListener { ErrorActivity.reportError(context, errorInfo) } + // additional info is only provided by AccountTerminatedException + errorServiceInfoTextView.isVisible = false + errorServiceExplenationTextView.isVisible = false + // hide retry button by default, then show only if not unavailable/unsupported content errorButtonRetry.isVisible = false errorTextView.setText( 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 23df7ed95..cd6a882ae 100644 --- a/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java +++ b/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java @@ -162,6 +162,9 @@ public class ReCaptchaActivity extends AppCompatActivity { setResult(RESULT_OK); } + // Navigate to blank page (unloads youtube to prevent background playback) + recaptchaBinding.reCaptchaWebView.loadUrl("about:blank"); + final Intent intent = new Intent(this, org.schabi.newpipe.MainActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); NavUtils.navigateUpTo(this, intent); diff --git a/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java index fbf2711bc..d4e73bcac 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java @@ -11,15 +11,20 @@ import org.schabi.newpipe.BaseFragment; import org.schabi.newpipe.R; public class EmptyFragment extends BaseFragment { - final boolean showMessage; + private static final String SHOW_MESSAGE = "SHOW_MESSAGE"; - public EmptyFragment(final boolean showMessage) { - this.showMessage = showMessage; + public static final EmptyFragment newInstance(final boolean showMessage) { + final EmptyFragment emptyFragment = new EmptyFragment(); + final Bundle bundle = new Bundle(1); + bundle.putBoolean(SHOW_MESSAGE, showMessage); + emptyFragment.setArguments(bundle); + return emptyFragment; } @Override public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container, final Bundle savedInstanceState) { + final boolean showMessage = getArguments().getBoolean(SHOW_MESSAGE); final View view = inflater.inflate(R.layout.fragment_empty, container, false); view.findViewById(R.id.empty_state_view).setVisibility( showMessage ? View.VISIBLE : View.GONE); 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 5fb68ba30..7e0186e1c 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/MainFragment.java @@ -1,7 +1,6 @@ package org.schabi.newpipe.fragments; import android.content.Context; -import android.content.res.ColorStateList; import android.os.Bundle; import android.util.Log; import android.view.LayoutInflater; @@ -30,7 +29,6 @@ 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 java.util.ArrayList; import java.util.List; @@ -87,10 +85,10 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte binding = FragmentMainBinding.bind(rootView); - binding.mainTabLayout.setTabIconTint(ColorStateList.valueOf( - ThemeHelper.resolveColorFromAttr(requireContext(), R.attr.colorAccent))); binding.mainTabLayout.setupWithViewPager(binding.pager); binding.mainTabLayout.addOnTabSelectedListener(this); + binding.mainTabLayout.setTabRippleColor(binding.mainTabLayout.getTabRippleColor() + .withAlpha(32)); setupTabs(); } @@ -132,7 +130,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte Log.d(TAG, "onCreateOptionsMenu() called with: " + "menu = [" + menu + "], inflater = [" + inflater + "]"); } - inflater.inflate(R.menu.main_fragment_menu, menu); + inflater.inflate(R.menu.menu_main_fragment, menu); final ActionBar supportActionBar = activity.getSupportActionBar(); if (supportActionBar != null) { 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 b4424928f..9b1bf121b 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 @@ -4,30 +4,45 @@ import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; -import android.widget.TextView; +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.stream.Description; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.util.Localization; -import org.schabi.newpipe.util.TextLinkifier; +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.Disposable; +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 { @State StreamInfo streamInfo = null; - @Nullable - Disposable descriptionDisposable = null; + final CompositeDisposable descriptionDisposables = new CompositeDisposable(); + FragmentDescriptionBinding binding; public DescriptionFragment() { } @@ -40,54 +55,212 @@ public class DescriptionFragment extends BaseFragment { public View onCreateView(@NonNull final LayoutInflater inflater, @Nullable final ViewGroup container, @Nullable final Bundle savedInstanceState) { - final FragmentDescriptionBinding binding = - FragmentDescriptionBinding.inflate(inflater, container, false); + binding = FragmentDescriptionBinding.inflate(inflater, container, false); if (streamInfo != null) { - setupUploadDate(binding.detailUploadDateView); - setupDescription(binding.detailDescriptionView); + setupUploadDate(); + setupDescription(); + setupMetadata(inflater, binding.detailMetadataLayout); } return binding.getRoot(); } @Override public void onDestroy() { + descriptionDisposables.clear(); super.onDestroy(); - if (descriptionDisposable != null) { - descriptionDisposable.dispose(); - } } - private void setupUploadDate(final TextView uploadDateTextView) { + + private void setupUploadDate() { if (streamInfo.getUploadDate() != null) { - uploadDateTextView.setText(Localization + binding.detailUploadDateView.setText(Localization .localizeUploadDate(activity, streamInfo.getUploadDate().offsetDateTime())); } else { - uploadDateTextView.setVisibility(View.GONE); + binding.detailUploadDateView.setVisibility(View.GONE); } } - private void setupDescription(final TextView descriptionTextView) { + + private void setupDescription() { final Description description = streamInfo.getDescription(); if (description == null || isEmpty(description.getContent()) || description == Description.emptyDescription) { - descriptionTextView.setText(""); + 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 + 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: - descriptionDisposable = TextLinkifier.createLinksFromHtmlBlock(requireContext(), - description.getContent(), descriptionTextView, - HtmlCompat.FROM_HTML_MODE_LEGACY); + TextLinkifier.createLinksFromHtmlBlock(binding.detailDescriptionView, + description.getContent(), HtmlCompat.FROM_HTML_MODE_LEGACY, streamInfo, + descriptionDisposables); break; case Description.MARKDOWN: - descriptionDisposable = TextLinkifier.createLinksFromMarkdownText(requireContext(), - description.getContent(), descriptionTextView); + TextLinkifier.createLinksFromMarkdownText(binding.detailDescriptionView, + description.getContent(), streamInfo, descriptionDisposables); break; case Description.PLAIN_TEXT: default: - descriptionDisposable = TextLinkifier.createLinksFromPlainText(requireContext(), - description.getContent(), descriptionTextView); + 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()); + + addPrivacyMetadataItem(inflater, layout); + + if (streamInfo.getAgeLimit() != NO_AGE_LIMIT) { + 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, 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()); + + 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; + } + + private void addPrivacyMetadataItem(final LayoutInflater inflater, final LinearLayout layout) { + if (streamInfo.getPrivacy() != null) { + @StringRes final int contentRes; + switch (streamInfo.getPrivacy()) { + case PUBLIC: + contentRes = R.string.metadata_privacy_public; + break; + case UNLISTED: + contentRes = R.string.metadata_privacy_unlisted; + break; + case PRIVATE: + contentRes = R.string.metadata_privacy_private; + break; + case INTERNAL: + contentRes = R.string.metadata_privacy_internal; + break; + case OTHER: default: + contentRes = 0; + break; + } + + if (contentRes != 0) { + 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 a5dfe2057..14d023dc7 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 @@ -73,7 +73,7 @@ import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.EmptyFragment; import org.schabi.newpipe.fragments.list.comments.CommentsFragment; -import org.schabi.newpipe.fragments.list.videos.RelatedVideosFragment; +import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment; import org.schabi.newpipe.ktx.AnimationType; import org.schabi.newpipe.local.dialog.PlaylistAppendDialog; import org.schabi.newpipe.local.dialog.PlaylistCreationDialog; @@ -91,12 +91,12 @@ import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ImageDisplayConstants; -import org.schabi.newpipe.util.KoreUtil; +import org.schabi.newpipe.util.external_communication.KoreUtils; 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.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.ThemeHelper; import java.util.ArrayList; @@ -153,7 +153,7 @@ public final class VideoDetailFragment // tabs private boolean showComments; - private boolean showRelatedStreams; + private boolean showRelatedItems; private boolean showDescription; private String selectedTabTag; @AttrRes @NonNull final List tabIcons = new ArrayList<>(); @@ -201,6 +201,7 @@ public final class VideoDetailFragment @Nullable private MainPlayer playerService; private Player player; + private PlayerHolder playerHolder = PlayerHolder.getInstance(); /*////////////////////////////////////////////////////////////////////////// // Service management @@ -280,7 +281,7 @@ public final class VideoDetailFragment final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(activity); showComments = prefs.getBoolean(getString(R.string.show_comments_key), true); - showRelatedStreams = prefs.getBoolean(getString(R.string.show_next_video_key), true); + showRelatedItems = prefs.getBoolean(getString(R.string.show_next_video_key), true); showDescription = prefs.getBoolean(getString(R.string.show_description_key), true); selectedTabTag = prefs.getString( getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG); @@ -304,7 +305,8 @@ public final class VideoDetailFragment @Override public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container, final Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_video_detail, container, false); + binding = FragmentVideoDetailBinding.inflate(inflater, container, false); + return binding.getRoot(); } @Override @@ -355,14 +357,13 @@ public final class VideoDetailFragment @Override public void onDestroy() { super.onDestroy(); - binding = null; // Stop the service when user leaves the app with double back press // if video player is selected. Otherwise unbind - if (activity.isFinishing() && player != null && player.videoPlayerSelected()) { - PlayerHolder.stopService(App.getApp()); + if (activity.isFinishing() && isPlayerAvailable() && player.videoPlayerSelected()) { + playerHolder.stopService(); } else { - PlayerHolder.removeListener(); + playerHolder.setListener(null); } PreferenceManager.getDefaultSharedPreferences(activity) @@ -388,6 +389,12 @@ public final class VideoDetailFragment } } + @Override + public void onDestroyView() { + super.onDestroyView(); + binding = null; + } + @Override public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { super.onActivityResult(requestCode, resultCode, data); @@ -413,7 +420,7 @@ public final class VideoDetailFragment showComments = sharedPreferences.getBoolean(key, true); tabSettingsChanged = true; } else if (key.equals(getString(R.string.show_next_video_key))) { - showRelatedStreams = sharedPreferences.getBoolean(key, true); + showRelatedItems = sharedPreferences.getBoolean(key, true); tabSettingsChanged = true; } else if (key.equals(getString(R.string.show_description_key))) { showComments = sharedPreferences.getBoolean(key, true); @@ -454,8 +461,8 @@ public final class VideoDetailFragment break; case R.id.detail_controls_share: if (currentInfo != null) { - ShareUtils.shareText(requireContext(), - currentInfo.getName(), currentInfo.getUrl()); + ShareUtils.shareText(requireContext(), currentInfo.getName(), + currentInfo.getUrl(), currentInfo.getThumbnailUrl()); } break; case R.id.detail_controls_open_in_browser: @@ -472,7 +479,7 @@ public final class VideoDetailFragment if (DEBUG) { Log.i(TAG, "Failed to start kore", e); } - KoreUtil.showInstallKoreDialog(requireContext()); + KoreUtils.showInstallKoreDialog(requireContext()); } } break; @@ -512,7 +519,7 @@ public final class VideoDetailFragment openVideoPlayer(); } - setOverlayPlayPauseImage(player != null && player.isPlaying()); + setOverlayPlayPauseImage(isPlayerAvailable() && player.isPlaying()); break; case R.id.overlay_close_button: bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN); @@ -586,10 +593,9 @@ public final class VideoDetailFragment // Init //////////////////////////////////////////////////////////////////////////*/ - @Override + @Override // called from onViewCreated in {@link BaseFragment#onViewCreated} protected void initViews(final View rootView, final Bundle savedInstanceState) { super.initViews(rootView, savedInstanceState); - binding = FragmentVideoDetailBinding.bind(rootView); pageAdapter = new TabAdapter(getChildFragmentManager()); binding.viewPager.setAdapter(pageAdapter); @@ -631,7 +637,7 @@ public final class VideoDetailFragment binding.detailControlsShare.setOnClickListener(this); binding.detailControlsOpenInBrowser.setOnClickListener(this); binding.detailControlsPlayWithKodi.setOnClickListener(this); - binding.detailControlsPlayWithKodi.setVisibility(KoreUtil.shouldShowPlayWithKodi( + binding.detailControlsPlayWithKodi.setVisibility(KoreUtils.shouldShowPlayWithKodi( requireContext(), serviceId) ? View.VISIBLE : View.GONE); binding.overlayThumbnail.setOnClickListener(this); @@ -655,10 +661,10 @@ public final class VideoDetailFragment }); setupBottomPlayer(); - if (!PlayerHolder.bound) { + if (!playerHolder.bound) { setHeightThumbnail(); } else { - PlayerHolder.startService(App.getApp(), false, this); + playerHolder.startService(false, this); } } @@ -721,7 +727,7 @@ public final class VideoDetailFragment @Override public boolean onKeyDown(final int keyCode) { - return player != null && player.onKeyDown(keyCode); + return isPlayerAvailable() && player.onKeyDown(keyCode); } @Override @@ -731,7 +737,7 @@ public final class VideoDetailFragment } // If we are in fullscreen mode just exit from it via first back press - if (player != null && player.isFullscreen()) { + if (isPlayerAvailable() && player.isFullscreen()) { if (!DeviceUtils.isTablet(activity)) { player.pause(); } @@ -741,7 +747,7 @@ public final class VideoDetailFragment } // If we have something in history of played items we replay it here - if (player != null + if (isPlayerAvailable() && player.getPlayQueue() != null && player.videoPlayerSelected() && player.getPlayQueue().previous()) { @@ -778,7 +784,7 @@ public final class VideoDetailFragment final PlayQueueItem playQueueItem = item.getPlayQueue().getItem(); // Update title, url, uploader from the last item in the stack (it's current now) - final boolean isPlayerStopped = player == null || player.isStopped(); + final boolean isPlayerStopped = !isPlayerAvailable() || player.isStopped(); if (playQueueItem != null && isPlayerStopped) { updateOverlayData(playQueueItem.getTitle(), playQueueItem.getUploader(), playQueueItem.getThumbnailUrl()); @@ -806,8 +812,8 @@ public final class VideoDetailFragment @Nullable final String newUrl, @NonNull final String newTitle, @Nullable final PlayQueue newQueue) { - if (player != null && newQueue != null && playQueue != null - && !Objects.equals(newQueue.getItem(), playQueue.getItem())) { + if (isPlayerAvailable() && newQueue != null && playQueue != null + && playQueue.getItem() != null && !playQueue.getItem().getUrl().equals(newUrl)) { // Preloading can be disabled since playback is surely being replaced. player.disablePreloadingOfCurrentTrack(); } @@ -923,26 +929,26 @@ public final class VideoDetailFragment if (shouldShowComments()) { pageAdapter.addFragment( CommentsFragment.getInstance(serviceId, url, title), COMMENTS_TAB_TAG); - tabIcons.add(R.drawable.ic_comment_white_24dp); + tabIcons.add(R.drawable.ic_comment); tabContentDescriptions.add(R.string.comments_tab_description); } - if (showRelatedStreams && binding.relatedStreamsLayout == null) { + if (showRelatedItems && binding.relatedItemsLayout == null) { // temp empty fragment. will be updated in handleResult - pageAdapter.addFragment(new EmptyFragment(false), RELATED_TAB_TAG); - tabIcons.add(R.drawable.ic_art_track_white_24dp); - tabContentDescriptions.add(R.string.related_streams_tab_description); + pageAdapter.addFragment(EmptyFragment.newInstance(false), RELATED_TAB_TAG); + tabIcons.add(R.drawable.ic_art_track); + tabContentDescriptions.add(R.string.related_items_tab_description); } if (showDescription) { // temp empty fragment. will be updated in handleResult - pageAdapter.addFragment(new EmptyFragment(false), DESCRIPTION_TAB_TAG); - tabIcons.add(R.drawable.ic_description_white_24dp); + pageAdapter.addFragment(EmptyFragment.newInstance(false), DESCRIPTION_TAB_TAG); + tabIcons.add(R.drawable.ic_description); tabContentDescriptions.add(R.string.description_tab_description); } if (pageAdapter.getCount() == 0) { - pageAdapter.addFragment(new EmptyFragment(true), EMPTY_TAB_TAG); + pageAdapter.addFragment(EmptyFragment.newInstance(true), EMPTY_TAB_TAG); } pageAdapter.notifyDataSetUpdate(); @@ -974,15 +980,15 @@ public final class VideoDetailFragment } private void updateTabs(@NonNull final StreamInfo info) { - if (showRelatedStreams) { - if (binding.relatedStreamsLayout == null) { // phone - pageAdapter.updateItem(RELATED_TAB_TAG, RelatedVideosFragment.getInstance(info)); + if (showRelatedItems) { + if (binding.relatedItemsLayout == null) { // phone + pageAdapter.updateItem(RELATED_TAB_TAG, RelatedItemsFragment.getInstance(info)); } else { // tablet + TV getChildFragmentManager().beginTransaction() - .replace(R.id.relatedStreamsLayout, RelatedVideosFragment.getInstance(info)) + .replace(R.id.relatedItemsLayout, RelatedItemsFragment.getInstance(info)) .commitAllowingStateLoss(); - binding.relatedStreamsLayout.setVisibility( - player != null && player.isFullscreen() ? View.GONE : View.VISIBLE); + binding.relatedItemsLayout.setVisibility( + isPlayerAvailable() && player.isFullscreen() ? View.GONE : View.VISIBLE); } } @@ -1009,6 +1015,12 @@ public final class VideoDetailFragment } public void updateTabLayoutVisibility() { + + if (binding == null) { + //If binding is null we do not need to and should not do anything with its object(s) + return; + } + if (pageAdapter.getCount() < 2 || binding.viewPager.getVisibility() != View.VISIBLE) { // hide tab layout if there is only one tab or if the view pager is also hidden binding.tabLayout.setVisibility(View.GONE); @@ -1053,6 +1065,14 @@ public final class VideoDetailFragment // Play Utils //////////////////////////////////////////////////////////////////////////*/ + 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(); + } + } + private void openBackgroundPlayer(final boolean append) { final AudioStream audioStream = currentInfo.getAudioStreams() .get(ListHelper.getDefaultAudioFormat(activity, currentInfo.getAudioStreams())); @@ -1061,11 +1081,7 @@ public final class VideoDetailFragment .getDefaultSharedPreferences(activity) .getBoolean(activity.getString(R.string.use_external_audio_player_key), false); - // If a user watched video inside fullscreen mode and than chose another player - // return to non-fullscreen mode - if (player != null && player.isFullscreen()) { - player.toggleFullscreen(); - } + toggleFullscreenIfInFullscreenMode(); if (!useExternalAudioPlayer) { openNormalBackgroundPlayer(append); @@ -1081,15 +1097,11 @@ public final class VideoDetailFragment } // See UI changes while remote playQueue changes - if (player == null) { - PlayerHolder.startService(App.getApp(), false, this); + if (!isPlayerAvailable()) { + playerHolder.startService(false, this); } - // If a user watched video inside fullscreen mode and than chose another player - // return to non-fullscreen mode - if (player != null && player.isFullscreen()) { - player.toggleFullscreen(); - } + toggleFullscreenIfInFullscreenMode(); final PlayQueue queue = setupPlayQueueForIntent(append); if (append) { @@ -1111,8 +1123,8 @@ public final class VideoDetailFragment private void openNormalBackgroundPlayer(final boolean append) { // See UI changes while remote playQueue changes - if (player == null) { - PlayerHolder.startService(App.getApp(), false, this); + if (!isPlayerAvailable()) { + playerHolder.startService(false, this); } final PlayQueue queue = setupPlayQueueForIntent(append); @@ -1125,8 +1137,8 @@ public final class VideoDetailFragment } private void openMainPlayer() { - if (playerService == null) { - PlayerHolder.startService(App.getApp(), autoPlayEnabled, this); + if (!isPlayerServiceAvailable()) { + playerHolder.startService(autoPlayEnabled, this); return; } if (currentInfo == null) { @@ -1144,11 +1156,11 @@ public final class VideoDetailFragment final Intent playerIntent = NavigationHelper .getPlayerIntent(requireContext(), MainPlayer.class, queue, true, autoPlayEnabled); - activity.startService(playerIntent); + ContextCompat.startForegroundService(activity, playerIntent); } private void hideMainPlayer() { - if (playerService == null + if (!isPlayerServiceAvailable() || playerService.getView() == null || !player.videoPlayerSelected()) { return; @@ -1205,13 +1217,13 @@ public final class VideoDetailFragment private boolean isAutoplayEnabled() { return autoPlayEnabled && !isExternalPlayerEnabled() - && (player == null || player.videoPlayerSelected()) + && (!isPlayerAvailable() || player.videoPlayerSelected()) && bottomSheetState != BottomSheetBehavior.STATE_HIDDEN && PlayerHelper.isAutoplayAllowedByUser(requireContext()); } private void addVideoPlayerView() { - if (player == null || getView() == null) { + if (!isPlayerAvailable() || getView() == null) { return; } @@ -1271,7 +1283,7 @@ public final class VideoDetailFragment final boolean isPortrait = metrics.heightPixels > metrics.widthPixels; requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener); - if (player != null && player.isFullscreen()) { + if (isPlayerAvailable() && player.isFullscreen()) { final int height = (isInMultiWindow() ? requireView() : activity.getWindow().getDecorView()).getHeight(); @@ -1294,7 +1306,7 @@ public final class VideoDetailFragment new FrameLayout.LayoutParams( RelativeLayout.LayoutParams.MATCH_PARENT, newHeight)); binding.detailThumbnailImageView.setMinimumHeight(newHeight); - if (player != null) { + if (isPlayerAvailable()) { final int maxHeight = (int) (metrics.heightPixels * MAX_PLAYER_HEIGHT); player.getSurfaceView() .setHeights(newHeight, player.isFullscreen() ? newHeight : maxHeight); @@ -1331,8 +1343,8 @@ public final class VideoDetailFragment super.handleError(); setErrorImage(R.drawable.not_available_monkey); - if (binding.relatedStreamsLayout != null) { // hide related streams for tablets - binding.relatedStreamsLayout.setVisibility(View.INVISIBLE); + if (binding.relatedItemsLayout != null) { // hide related streams for tablets + binding.relatedItemsLayout.setVisibility(View.INVISIBLE); } // hide comments / related streams / description tabs @@ -1362,9 +1374,9 @@ public final class VideoDetailFragment bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED); } // Rebound to the service if it was closed via notification or mini player - if (!PlayerHolder.bound) { - PlayerHolder.startService( - App.getApp(), false, VideoDetailFragment.this); + if (!playerHolder.bound) { + playerHolder.startService( + false, VideoDetailFragment.this); } break; } @@ -1383,13 +1395,12 @@ public final class VideoDetailFragment //////////////////////////////////////////////////////////////////////////*/ private void restoreDefaultOrientation() { - if (player == null || !player.videoPlayerSelected() || activity == null) { + if (!isPlayerAvailable() || !player.videoPlayerSelected() || activity == null) { return; } - if (player != null && player.isFullscreen()) { - player.toggleFullscreen(); - } + toggleFullscreenIfInFullscreenMode(); + // This will show systemUI and pause the player. // User can tap on Play button and video will be in fullscreen mode again // Note for tablet: trying to avoid orientation changes since it's not easy @@ -1426,12 +1437,12 @@ public final class VideoDetailFragment binding.detailTitleRootLayout.setClickable(false); binding.detailSecondaryControlPanel.setVisibility(View.GONE); - if (binding.relatedStreamsLayout != null) { - if (showRelatedStreams) { - binding.relatedStreamsLayout.setVisibility( - player != null && player.isFullscreen() ? View.GONE : View.INVISIBLE); + if (binding.relatedItemsLayout != null) { + if (showRelatedItems) { + binding.relatedItemsLayout.setVisibility( + isPlayerAvailable() && player.isFullscreen() ? View.GONE : View.INVISIBLE); } else { - binding.relatedStreamsLayout.setVisibility(View.GONE); + binding.relatedItemsLayout.setVisibility(View.GONE); } } @@ -1540,10 +1551,10 @@ public final class VideoDetailFragment .getDefaultResolutionIndex(activity, sortedVideoStreams); updateProgressInfo(info); initThumbnailViews(info); - disposables.add(showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, - binding.detailMetaInfoSeparator)); + showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, + binding.detailMetaInfoSeparator, disposables); - if (player == null || player.isStopped()) { + if (!isPlayerAvailable() || player.isStopped()) { updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnailUrl()); } @@ -1663,7 +1674,7 @@ public final class VideoDetailFragment .onErrorComplete() .observeOn(AndroidSchedulers.mainThread()) .subscribe(state -> { - showPlaybackProgress(state.getProgressTime(), info.getDuration() * 1000); + showPlaybackProgress(state.getProgressMillis(), info.getDuration() * 1000); animate(binding.positionView, true, 500); animate(binding.detailPositionView, true, 500); }, e -> { @@ -1806,9 +1817,7 @@ public final class VideoDetailFragment if (error.type == ExoPlaybackException.TYPE_SOURCE || error.type == ExoPlaybackException.TYPE_UNEXPECTED) { // Properly exit from fullscreen - if (playerService != null && player.isFullscreen()) { - player.toggleFullscreen(); - } + toggleFullscreenIfInFullscreenMode(); hideMainPlayer(); } } @@ -1826,7 +1835,9 @@ public final class VideoDetailFragment @Override public void onFullscreenStateChanged(final boolean fullscreen) { setupBrightness(); - if (playerService.getView() == null || player.getParentActivity() == null) { + if (!isPlayerAndPlayerServiceAvailable() + || playerService.getView() == null + || player.getParentActivity() == null) { return; } @@ -1843,8 +1854,8 @@ public final class VideoDetailFragment showSystemUi(); } - if (binding.relatedStreamsLayout != null) { - binding.relatedStreamsLayout.setVisibility(fullscreen ? View.GONE : View.VISIBLE); + if (binding.relatedItemsLayout != null) { + binding.relatedItemsLayout.setVisibility(fullscreen ? View.GONE : View.VISIBLE); } scrollToTop(); @@ -1949,7 +1960,7 @@ public final class VideoDetailFragment activity.getWindow().getDecorView().setSystemUiVisibility(visibility); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP - && (isInMultiWindow() || (player != null && player.isFullscreen()))) { + && (isInMultiWindow() || (isPlayerAvailable() && player.isFullscreen()))) { activity.getWindow().setStatusBarColor(Color.TRANSPARENT); activity.getWindow().setNavigationBarColor(Color.TRANSPARENT); } @@ -1958,7 +1969,7 @@ public final class VideoDetailFragment // Listener implementation public void hideSystemUiIfNeeded() { - if (player != null + if (isPlayerAvailable() && player.isFullscreen() && bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) { hideSystemUi(); @@ -1966,7 +1977,7 @@ public final class VideoDetailFragment } private boolean playerIsNotStopped() { - return player != null && !player.isStopped(); + return isPlayerAvailable() && !player.isStopped(); } private void restoreDefaultBrightness() { @@ -1987,7 +1998,7 @@ public final class VideoDetailFragment } final WindowManager.LayoutParams lp = activity.getWindow().getAttributes(); - if (player == null + if (!isPlayerAvailable() || !player.videoPlayerSelected() || !player.isFullscreen() || bottomSheetState != BottomSheetBehavior.STATE_EXPANDED) { @@ -2053,7 +2064,7 @@ public final class VideoDetailFragment } private void replaceQueueIfUserConfirms(final Runnable onAllow) { - @Nullable final PlayQueue activeQueue = player == null ? null : player.getPlayQueue(); + @Nullable final PlayQueue activeQueue = isPlayerAvailable() ? player.getPlayQueue() : null; // Player will have STATE_IDLE when a user pressed back button if (isClearingQueueConfirmationRequired(activity) @@ -2109,7 +2120,7 @@ public final class VideoDetailFragment if (currentWorker != null) { currentWorker.dispose(); } - PlayerHolder.stopService(App.getApp()); + playerHolder.stopService(); setInitialData(0, null, "", null); currentInfo = null; updateOverlayData(null, null, null); @@ -2213,7 +2224,7 @@ public final class VideoDetailFragment hideSystemUiIfNeeded(); // Conditions when the player should be expanded to fullscreen if (isLandscape() - && player != null + && isPlayerAvailable() && player.isPlaying() && !player.isFullscreen() && !DeviceUtils.isTablet(activity) @@ -2230,17 +2241,17 @@ public final class VideoDetailFragment // Re-enable clicks setOverlayElementsClickable(true); - if (player != null) { + if (isPlayerAvailable()) { player.closeItemsList(); } setOverlayLook(binding.appBarLayout, behavior, 0); break; case BottomSheetBehavior.STATE_DRAGGING: case BottomSheetBehavior.STATE_SETTLING: - if (player != null && player.isFullscreen()) { + if (isPlayerAvailable() && player.isFullscreen()) { showSystemUi(); } - if (player != null && player.isControlsVisible()) { + if (isPlayerAvailable() && player.isControlsVisible()) { player.hideControls(0, 0); } break; @@ -2274,11 +2285,10 @@ public final class VideoDetailFragment } private void setOverlayPlayPauseImage(final boolean playerIsPlaying) { - final int attr = playerIsPlaying - ? R.attr.ic_pause - : R.attr.ic_play_arrow; - binding.overlayPlayPauseButton.setImageResource( - ThemeHelper.resolveResourceIdFromAttr(activity, attr)); + final int drawable = playerIsPlaying + ? R.drawable.ic_pause + : R.drawable.ic_play_arrow; + binding.overlayPlayPauseButton.setImageResource(drawable); } private void setOverlayLook(final AppBarLayout appBar, @@ -2305,4 +2315,17 @@ public final class VideoDetailFragment binding.overlayPlayPauseButton.setClickable(enable); binding.overlayCloseButton.setClickable(enable); } + + // helpers to check the state of player and playerService + boolean isPlayerAvailable() { + return (player != null); + } + + boolean isPlayerServiceAvailable() { + return (playerService != null); + } + + boolean isPlayerAndPlayerServiceAvailable() { + return (player != null && playerService != null); + } } 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 3c37bd128..ae661cfa3 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 @@ -33,7 +33,7 @@ import org.schabi.newpipe.fragments.OnScrollBelowItemsListener; import org.schabi.newpipe.info_list.InfoItemDialog; import org.schabi.newpipe.info_list.InfoListAdapter; import org.schabi.newpipe.player.helper.PlayerHolder; -import org.schabi.newpipe.util.KoreUtil; +import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.StateSaver; @@ -45,6 +45,7 @@ import java.util.Arrays; import java.util.List; import java.util.Queue; +import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; @@ -124,8 +125,8 @@ public abstract class BaseListFragment extends BaseStateFragment /** * If the default implementation of {@link StateSaver.WriteRead} should be used. * - * @see StateSaver * @param useDefaultStateSaving Whether the default implementation should be used + * @see StateSaver */ public void setUseDefaultStateSaving(final boolean useDefaultStateSaving) { this.useDefaultStateSaving = useDefaultStateSaving; @@ -350,9 +351,9 @@ public abstract class BaseListFragment extends BaseStateFragment return; } - final ArrayList entries = new ArrayList<>(); + final List entries = new ArrayList<>(); - if (PlayerHolder.getType() != null) { + if (PlayerHolder.getInstance().getType() != null) { entries.add(StreamDialogEntry.enqueue); } if (item.getStreamType() == StreamType.AUDIO_STREAM) { @@ -361,7 +362,7 @@ public abstract class BaseListFragment extends BaseStateFragment StreamDialogEntry.append_playlist, StreamDialogEntry.share )); - } else { + } else { entries.addAll(Arrays.asList( StreamDialogEntry.start_here_on_background, StreamDialogEntry.start_here_on_popup, @@ -369,9 +370,14 @@ public abstract class BaseListFragment extends BaseStateFragment StreamDialogEntry.share )); } - if (KoreUtil.shouldShowPlayWithKodi(context, item.getServiceId())) { + entries.add(StreamDialogEntry.open_in_browser); + if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) { entries.add(StreamDialogEntry.play_with_kodi); } + if (!isNullOrEmpty(item.getUploaderUrl())) { + entries.add(StreamDialogEntry.show_channel_details); + } + StreamDialogEntry.setEnabledEntries(entries); new InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context), @@ -383,7 +389,8 @@ public abstract class BaseListFragment extends BaseStateFragment //////////////////////////////////////////////////////////////////////////*/ @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { if (DEBUG) { Log.d(TAG, "onCreateOptionsMenu() called with: " + "menu = [" + menu + "], inflater = [" + inflater + "]"); 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 6874f80d5..e98dc9fda 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 @@ -12,6 +12,7 @@ import org.schabi.newpipe.error.UserAction; 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; @@ -227,7 +228,11 @@ public abstract class BaseListInfoFragment showListFooter(hasMoreItems()); } else { infoListAdapter.clearStreamItemList(); - showEmptyState(); + // showEmptyState should be called only if there is no item as + // well as no header in infoListAdapter + if (!(result instanceof ChannelInfo && infoListAdapter.getItemCount() == 1)) { + showEmptyState(); + } } } 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 a94581cfd..bc6718021 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 @@ -43,7 +43,7 @@ import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ImageDisplayConstants; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.ThemeHelper; import java.util.ArrayList; @@ -164,7 +164,8 @@ public class ChannelFragment extends BaseListInfoFragment //////////////////////////////////////////////////////////////////////////*/ @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); final ActionBar supportActionBar = activity.getSupportActionBar(); if (useAsFrontPage && supportActionBar != null) { @@ -203,7 +204,8 @@ public class ChannelFragment extends BaseListInfoFragment break; case R.id.menu_item_share: if (currentInfo != null) { - ShareUtils.shareText(requireContext(), name, currentInfo.getOriginalUrl()); + ShareUtils.shareText(requireContext(), name, currentInfo.getOriginalUrl(), + currentInfo.getAvatarUrl()); } break; default: @@ -449,8 +451,8 @@ public class ChannelFragment extends BaseListInfoFragment if (!TextUtils.isEmpty(currentInfo.getParentChannelName())) { headerBinding.subChannelTitleView.setText(String.format( - getString(R.string.channel_created_by), - currentInfo.getParentChannelName()) + getString(R.string.channel_created_by), + currentInfo.getParentChannelName()) ); headerBinding.subChannelTitleView.setVisibility(View.VISIBLE); headerBinding.subChannelAvatarView.setVisibility(View.VISIBLE); @@ -462,7 +464,13 @@ public class ChannelFragment extends BaseListInfoFragment menuRssButton.setVisible(!TextUtils.isEmpty(result.getFeedUrl())); } - playlistControlBinding.getRoot().setVisibility(View.VISIBLE); + // 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); + } for (final Throwable throwable : result.getErrors()) { if (throwable instanceof ContentNotSupportedException) { 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 35ab663a6..3d11e90c0 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 @@ -6,6 +6,7 @@ import android.view.Menu; import android.view.MenuInflater; import android.view.View; import android.view.ViewGroup; +import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -24,6 +25,8 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable; public class CommentsFragment extends BaseListInfoFragment { private final CompositeDisposable disposables = new CompositeDisposable(); + private TextView emptyStateDesc; + public static CommentsFragment getInstance(final int serviceId, final String url, final String name) { final CommentsFragment instance = new CommentsFragment(); @@ -35,6 +38,13 @@ public class CommentsFragment extends BaseListInfoFragment { super(UserAction.REQUESTED_COMMENTS); } + @Override + protected void initViews(final View rootView, final Bundle savedInstanceState) { + super.initViews(rootView, savedInstanceState); + + emptyStateDesc = rootView.findViewById(R.id.empty_state_desc); + } + /*////////////////////////////////////////////////////////////////////////// // LifeCycle //////////////////////////////////////////////////////////////////////////*/ @@ -73,6 +83,12 @@ public class CommentsFragment extends BaseListInfoFragment { @Override public void handleResult(@NonNull final CommentsInfo result) { super.handleResult(result); + + emptyStateDesc.setText( + result.isCommentsDisabled() + ? R.string.comments_are_disabled + : R.string.no_comments); + ViewUtils.slideUp(requireView(), 120, 150, 0.06f); disposables.clear(); } @@ -85,7 +101,8 @@ public class CommentsFragment extends BaseListInfoFragment { public void setTitle(final String title) { } @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { } + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { } @Override protected boolean isGridLayout() { diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java index 882bb021d..f37f487bf 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java @@ -131,7 +131,8 @@ public class KioskFragment extends BaseListInfoFragment { //////////////////////////////////////////////////////////////////////////*/ @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); final ActionBar supportActionBar = activity.getSupportActionBar(); if (supportActionBar != null && useAsFrontPage) { diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java index 114947923..824aa2612 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java @@ -42,10 +42,10 @@ import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ImageDisplayConstants; -import org.schabi.newpipe.util.KoreUtil; +import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.StreamDialogEntry; import java.util.ArrayList; @@ -59,9 +59,9 @@ import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; +import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; -import static org.schabi.newpipe.util.ThemeHelper.resolveResourceIdFromAttr; public class PlaylistFragment extends BaseListInfoFragment { private CompositeDisposable disposables; @@ -144,7 +144,7 @@ public class PlaylistFragment extends BaseListInfoFragment { final ArrayList entries = new ArrayList<>(); - if (PlayerHolder.getType() != null) { + if (PlayerHolder.getInstance().getType() != null) { entries.add(StreamDialogEntry.enqueue); } if (item.getStreamType() == StreamType.AUDIO_STREAM) { @@ -161,9 +161,15 @@ public class PlaylistFragment extends BaseListInfoFragment { StreamDialogEntry.share )); } - if (KoreUtil.shouldShowPlayWithKodi(context, item.getServiceId())) { + entries.add(StreamDialogEntry.open_in_browser); + if (KoreUtils.shouldShowPlayWithKodi(context, item.getServiceId())) { entries.add(StreamDialogEntry.play_with_kodi); } + + if (!isNullOrEmpty(item.getUploaderUrl())) { + entries.add(StreamDialogEntry.show_channel_details); + } + StreamDialogEntry.setEnabledEntries(entries); StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItem) -> @@ -175,7 +181,8 @@ public class PlaylistFragment extends BaseListInfoFragment { } @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { if (DEBUG) { Log.d(TAG, "onCreateOptionsMenu() called with: " + "menu = [" + menu + "], inflater = [" + inflater + "]"); @@ -245,7 +252,7 @@ public class PlaylistFragment extends BaseListInfoFragment { ShareUtils.openUrlInBrowser(requireContext(), url); break; case R.id.menu_item_share: - ShareUtils.shareText(requireContext(), name, url); + ShareUtils.shareText(requireContext(), name, url, currentInfo.getThumbnailUrl()); break; case R.id.menu_item_bookmark: onBookmarkClicked(); @@ -307,7 +314,7 @@ public class PlaylistFragment extends BaseListInfoFragment { getResources().getColor(R.color.transparent_background_color)); headerBinding.uploaderAvatarView.setImageDrawable( AppCompatResources.getDrawable(requireContext(), - resolveResourceIdFromAttr(requireContext(), R.attr.ic_radio)) + R.drawable.ic_radio) ); } else { IMAGE_LOADER.displayImage(avatarUrl, headerBinding.uploaderAvatarView, @@ -423,7 +430,9 @@ public class PlaylistFragment extends BaseListInfoFragment { @Override public void setTitle(final String title) { super.setTitle(title); - headerBinding.playlistTitleView.setText(title); + if (headerBinding != null) { + headerBinding.playlistTitleView.setText(title); + } } private void onBookmarkClicked() { @@ -459,13 +468,13 @@ public class PlaylistFragment extends BaseListInfoFragment { return; } - final int iconAttr = playlistEntity == null - ? R.attr.ic_playlist_add : R.attr.ic_playlist_check; + final int drawable = playlistEntity == null + ? R.drawable.ic_playlist_add : R.drawable.ic_playlist_add_check; final int titleRes = playlistEntity == null ? R.string.bookmark_playlist : R.string.unbookmark_playlist; - playlistBookmarkButton.setIcon(resolveResourceIdFromAttr(activity, iconAttr)); + playlistBookmarkButton.setIcon(drawable); playlistBookmarkButton.setTitle(titleRes); } } 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 26360137e..478cf94f3 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 @@ -9,6 +9,7 @@ import android.text.Editable; import android.text.Html; import android.text.TextUtils; import android.text.TextWatcher; +import android.text.style.CharacterStyle; import android.util.Log; import android.view.KeyEvent; import android.view.LayoutInflater; @@ -139,7 +140,7 @@ public class SearchFragment extends BaseListFragment menuItemToFilterName; + @Nullable private Map menuItemToFilterName = null; private StreamingService service; private Page nextPage; private boolean isSuggestionsEnabled = true; @@ -226,6 +227,25 @@ public class SearchFragment extends BaseListFragment cf = new ArrayList<>(1); - cf.add(menuItemToFilterName.get(item.getItemId())); - changeContentFilter(item, cf); - + public boolean onOptionsItemSelected(@NonNull final MenuItem item) { + if (menuItemToFilterName != null) { + final List cf = new ArrayList<>(1); + cf.add(menuItemToFilterName.get(item.getItemId())); + changeContentFilter(item, cf); + } return true; } @@ -486,6 +508,9 @@ public class SearchFragment extends BaseListFragment cannot be bundled without creating some containers metaInfo = new MetaInfo[result.getMetaInfo().size()]; metaInfo = result.getMetaInfo().toArray(metaInfo); - disposables.add(showMetaInfoInTextView(result.getMetaInfo(), - searchBinding.searchMetaInfoTextView, searchBinding.searchMetaInfoSeparator)); + showMetaInfoInTextView(result.getMetaInfo(), searchBinding.searchMetaInfoTextView, + searchBinding.searchMetaInfoSeparator, disposables); handleSearchSuggestion(); 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 d4bb4eebd..952316796 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,14 +1,12 @@ package org.schabi.newpipe.fragments.list.search; import android.content.Context; -import android.content.res.TypedArray; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; -import androidx.annotation.AttrRes; import androidx.recyclerview.widget.RecyclerView; import org.schabi.newpipe.R; @@ -117,16 +115,8 @@ public class SuggestionListAdapter queryView = rootView.findViewById(R.id.suggestion_search); insertView = rootView.findViewById(R.id.suggestion_insert); - historyResId = resolveResourceIdFromAttr(rootView.getContext(), R.attr.ic_history); - searchResId = resolveResourceIdFromAttr(rootView.getContext(), R.attr.ic_search); - } - - private static int resolveResourceIdFromAttr(final Context context, - @AttrRes final int attr) { - final TypedArray a = context.getTheme().obtainStyledAttributes(new int[]{attr}); - final int attributeResourceId = a.getResourceId(0, 0); - a.recycle(); - return attributeResourceId; + historyResId = R.drawable.ic_history; + searchResId = R.drawable.ic_search; } private void updateFrom(final SuggestionItem item) { diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedVideosFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java similarity index 79% rename from app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedVideosFragment.java rename to app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java index 902df94bc..6532417c0 100644 --- a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedVideosFragment.java +++ b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java @@ -15,38 +15,38 @@ import androidx.preference.PreferenceManager; import androidx.viewbinding.ViewBinding; import org.schabi.newpipe.R; -import org.schabi.newpipe.databinding.RelatedStreamsHeaderBinding; +import org.schabi.newpipe.databinding.RelatedItemsHeaderBinding; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.ktx.ViewUtils; -import org.schabi.newpipe.util.RelatedStreamInfo; +import org.schabi.newpipe.util.RelatedItemInfo; import java.io.Serializable; import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.disposables.CompositeDisposable; -public class RelatedVideosFragment extends BaseListInfoFragment +public class RelatedItemsFragment extends BaseListInfoFragment implements SharedPreferences.OnSharedPreferenceChangeListener { private static final String INFO_KEY = "related_info_key"; private final CompositeDisposable disposables = new CompositeDisposable(); - private RelatedStreamInfo relatedStreamInfo; + private RelatedItemInfo relatedItemInfo; /*////////////////////////////////////////////////////////////////////////// // Views //////////////////////////////////////////////////////////////////////////*/ - private RelatedStreamsHeaderBinding headerBinding; + private RelatedItemsHeaderBinding headerBinding; - public static RelatedVideosFragment getInstance(final StreamInfo info) { - final RelatedVideosFragment instance = new RelatedVideosFragment(); + public static RelatedItemsFragment getInstance(final StreamInfo info) { + final RelatedItemsFragment instance = new RelatedItemsFragment(); instance.setInitialData(info); return instance; } - public RelatedVideosFragment() { + public RelatedItemsFragment() { super(UserAction.REQUESTED_STREAM); } @@ -63,7 +63,7 @@ public class RelatedVideosFragment extends BaseListInfoFragment loadResult(final boolean forceLoad) { - return Single.fromCallable(() -> relatedStreamInfo); + protected Single loadResult(final boolean forceLoad) { + return Single.fromCallable(() -> relatedItemInfo); } @Override @@ -120,7 +120,7 @@ public class RelatedVideosFragment extends BaseListInfoFragment(R.id.textViewTitle).text = item.title + if (item.channelName == null) { + viewHolder.root.findViewById(R.id.textViewChannel).visibility = View.GONE + // When the channel name is displayed there is less space + // and thus the segment title needs to be only one line height. + // But when there is no channel name displayed, the title can be two lines long. + // The default maxLines value is set to 1 to display all elements in the AS preview, + viewHolder.root.findViewById(R.id.textViewTitle).maxLines = 2 + } else { + viewHolder.root.findViewById(R.id.textViewChannel).text = item.channelName + viewHolder.root.findViewById(R.id.textViewChannel).visibility = View.VISIBLE + } viewHolder.root.findViewById(R.id.textViewStartSeconds).text = Localization.getDurationString(item.startTimeSeconds.toLong()) viewHolder.root.setOnClickListener { onClick.onItemClick(this, item.startTimeSeconds) } 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 index 842d9c455..fb144574a 100644 --- 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 @@ -1,6 +1,8 @@ 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; @@ -31,11 +33,13 @@ import org.schabi.newpipe.local.history.HistoryRecordManager; public class CommentsInfoItemHolder extends CommentsMiniInfoItemHolder { public final TextView itemTitleView; + private final ImageView itemHeartView; 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); } @Override @@ -49,5 +53,7 @@ public class CommentsInfoItemHolder extends CommentsMiniInfoItemHolder { final CommentsInfoItem item = (CommentsInfoItem) infoItem; itemTitleView.setText(item.getUploaderName()); + + itemHeartView.setVisibility(item.isHeartedByUploader() ? 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 index b106e029d..629240dc6 100644 --- 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 @@ -24,7 +24,7 @@ import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.ImageDisplayConstants; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -137,7 +137,10 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder { } if (item.getLikeCount() >= 0) { - itemLikesCountView.setText(String.valueOf(item.getLikeCount())); + itemLikesCountView.setText( + Localization.shortCount( + itemBuilder.getContext(), + item.getLikeCount())); } else { itemLikesCountView.setText("-"); } 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 1915ff283..a84c98404 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 @@ -4,8 +4,6 @@ import android.text.TextUtils; import android.view.ViewGroup; import android.widget.TextView; -import androidx.preference.PreferenceManager; - import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem; @@ -14,6 +12,8 @@ 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; /* 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 227c11f91..98699eb95 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 @@ -66,7 +66,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { itemProgressView.setVisibility(View.VISIBLE); itemProgressView.setMax((int) item.getDuration()); itemProgressView.setProgress((int) TimeUnit.MILLISECONDS - .toSeconds(state2.getProgressTime())); + .toSeconds(state2.getProgressMillis())); } else { itemProgressView.setVisibility(View.GONE); } @@ -121,10 +121,10 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder { itemProgressView.setMax((int) item.getDuration()); if (itemProgressView.getVisibility() == View.VISIBLE) { itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS - .toSeconds(state.getProgressTime())); + .toSeconds(state.getProgressMillis())); } else { itemProgressView.setProgress((int) TimeUnit.MILLISECONDS - .toSeconds(state.getProgressTime())); + .toSeconds(state.getProgressMillis())); ViewUtils.animate(itemProgressView, true, 500); } } else if (itemProgressView.getVisibility() == View.VISIBLE) { diff --git a/app/src/main/java/org/schabi/newpipe/ktx/TextView.kt b/app/src/main/java/org/schabi/newpipe/ktx/TextView.kt index cfb13a107..c70af1e7d 100644 --- a/app/src/main/java/org/schabi/newpipe/ktx/TextView.kt +++ b/app/src/main/java/org/schabi/newpipe/ktx/TextView.kt @@ -2,13 +2,12 @@ package org.schabi.newpipe.ktx -import android.animation.Animator -import android.animation.AnimatorListenerAdapter import android.animation.ArgbEvaluator import android.animation.ValueAnimator import android.util.Log import android.widget.TextView import androidx.annotation.ColorInt +import androidx.core.animation.addListener import androidx.interpolator.view.animation.FastOutSlowInInterpolator import org.schabi.newpipe.MainActivity @@ -34,14 +33,6 @@ fun TextView.animateTextColor(duration: Long, @ColorInt colorStart: Int, @ColorI viewPropertyAnimator.interpolator = FastOutSlowInInterpolator() viewPropertyAnimator.duration = duration viewPropertyAnimator.addUpdateListener { setTextColor(it.animatedValue as Int) } - viewPropertyAnimator.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - setTextColor(colorEnd) - } - - override fun onAnimationCancel(animation: Animator) { - setTextColor(colorEnd) - } - }) + viewPropertyAnimator.addListener(onCancel = { setTextColor(colorEnd) }, onEnd = { setTextColor(colorEnd) }) viewPropertyAnimator.start() } diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Throwable.kt b/app/src/main/java/org/schabi/newpipe/ktx/Throwable.kt index b95f46fd4..63f1b2ab5 100644 --- a/app/src/main/java/org/schabi/newpipe/ktx/Throwable.kt +++ b/app/src/main/java/org/schabi/newpipe/ktx/Throwable.kt @@ -58,10 +58,8 @@ tailrec fun Throwable?.hasCause(checkSubtypes: Boolean, vararg causesToCheck: Cl if (causeClass.isAssignableFrom(this.javaClass)) { return true } - } else { - if (causeClass == this.javaClass) { - return true - } + } else if (causeClass == this.javaClass) { + return true } } 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 2fd80703c..8f2249493 100644 --- a/app/src/main/java/org/schabi/newpipe/ktx/View.kt +++ b/app/src/main/java/org/schabi/newpipe/ktx/View.kt @@ -11,6 +11,7 @@ import android.util.Log import android.view.View import androidx.annotation.ColorInt import androidx.annotation.FloatRange +import androidx.core.animation.addListener import androidx.core.view.ViewCompat import androidx.core.view.isGone import androidx.core.view.isInvisible @@ -106,15 +107,10 @@ fun View.animateBackgroundColor(duration: Long, @ColorInt colorStart: Int, @Colo viewPropertyAnimator.addUpdateListener { animation: ValueAnimator -> backgroundTintListCompat = ColorStateList(empty, intArrayOf(animation.animatedValue as Int)) } - viewPropertyAnimator.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - backgroundTintListCompat = ColorStateList(empty, intArrayOf(colorEnd)) - } - - override fun onAnimationCancel(animation: Animator) { - onAnimationEnd(animation) - } - }) + viewPropertyAnimator.addListener( + onCancel = { backgroundTintListCompat = ColorStateList(empty, intArrayOf(colorEnd)) }, + onEnd = { backgroundTintListCompat = ColorStateList(empty, intArrayOf(colorEnd)) } + ) viewPropertyAnimator.start() } @@ -134,17 +130,16 @@ fun View.animateHeight(duration: Long, targetHeight: Int): ValueAnimator { layoutParams.height = value.toInt() requestLayout() } - animator.addListener(object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { + animator.addListener( + onCancel = { + layoutParams.height = targetHeight + requestLayout() + }, + onEnd = { layoutParams.height = targetHeight requestLayout() } - - override fun onAnimationCancel(animation: Animator) { - layoutParams.height = targetHeight - requestLayout() - } - }) + ) animator.start() return animator } 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 78fb20029..8790c3059 100644 --- a/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/local/BaseLocalListFragment.java @@ -1,7 +1,6 @@ package org.schabi.newpipe.local; import android.content.SharedPreferences; -import android.content.res.Configuration; import android.content.res.Resources; import android.os.Bundle; import android.util.Log; @@ -9,6 +8,7 @@ import android.view.Menu; import android.view.MenuInflater; import android.view.View; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.ActionBar; import androidx.fragment.app.Fragment; @@ -25,6 +25,7 @@ import org.schabi.newpipe.fragments.list.ListViewContract; import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; +import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout; /** * This fragment is design to be used with persistent data such as @@ -76,7 +77,7 @@ public abstract class BaseLocalListFragment extends BaseStateFragment super.onResume(); if (updateFlags != 0) { if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) { - final boolean useGrid = isGridLayout(); + final boolean useGrid = shouldUseGridLayout(requireContext()); itemsList.setLayoutManager( useGrid ? getGridLayoutManager() : getListLayoutManager()); itemListAdapter.setUseGridVariant(useGrid); @@ -120,7 +121,7 @@ public abstract class BaseLocalListFragment extends BaseStateFragment itemListAdapter = new LocalItemListAdapter(activity); - final boolean useGrid = isGridLayout(); + final boolean useGrid = shouldUseGridLayout(requireContext()); itemsList = rootView.findViewById(R.id.items_list); itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager()); @@ -145,7 +146,8 @@ public abstract class BaseLocalListFragment extends BaseStateFragment //////////////////////////////////////////////////////////////////////////*/ @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); if (DEBUG) { Log.d(TAG, "onCreateOptionsMenu() called with: " @@ -258,17 +260,4 @@ public abstract class BaseLocalListFragment extends BaseStateFragment 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); - } - } } 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 da8902c08..5d81c0069 100644 --- a/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/local/LocalItemListAdapter.java @@ -126,8 +126,19 @@ public class LocalItemListAdapter extends RecyclerView.Adapter - changeLocalPlaylistName(selectedItem.uid, editText.getText().toString())) + changeLocalPlaylistName( + selectedItem.uid, + dialogBinding.dialogEditText.getText().toString())) .setNegativeButton(R.string.cancel, null) .setNeutralButton(R.string.delete, (dialog, which) -> { showDeleteDialog(selectedItem.name, 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 4d19f0dd9..f48c72d04 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 @@ -1,18 +1,18 @@ package org.schabi.newpipe.local.dialog; -import android.app.AlertDialog; import android.app.Dialog; import android.os.Bundle; -import android.view.View; -import android.widget.EditText; +import android.text.InputType; import android.widget.Toast; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.database.stream.model.StreamEntity; +import org.schabi.newpipe.databinding.DialogEditTextBinding; import org.schabi.newpipe.local.playlist.LocalPlaylistManager; import java.util.List; @@ -43,18 +43,20 @@ public final class PlaylistCreationDialog extends PlaylistDialog { return super.onCreateDialog(savedInstanceState); } - final View dialogView = View.inflate(getContext(), R.layout.dialog_playlist_name, null); - final EditText nameInput = dialogView.findViewById(R.id.playlist_name); + final DialogEditTextBinding dialogBinding + = DialogEditTextBinding.inflate(getLayoutInflater()); + dialogBinding.dialogEditText.setHint(R.string.name); + dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT); - final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(getContext()) + final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(requireContext()) .setTitle(R.string.create_playlist) - .setView(dialogView) + .setView(dialogBinding.getRoot()) .setCancelable(true) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.create, (dialogInterface, i) -> { - final String name = nameInput.getText().toString(); + final String name = dialogBinding.dialogEditText.getText().toString(); final LocalPlaylistManager playlistManager = - new LocalPlaylistManager(NewPipeDatabase.getInstance(getContext())); + new LocalPlaylistManager(NewPipeDatabase.getInstance(requireContext())); final Toast successToast = Toast.makeText(getActivity(), R.string.playlist_creation_success, Toast.LENGTH_SHORT); 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 9a4832c81..ff7c2848e 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 @@ -12,6 +12,7 @@ import org.schabi.newpipe.NewPipeDatabase 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.StreamEntity import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamType @@ -38,16 +39,19 @@ class FeedDatabaseManager(context: Context) { fun database() = database - fun asStreamItems(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable> { - val streams = when (groupId) { - FeedGroupEntity.GROUP_ALL_ID -> feedTable.getAllStreams() - else -> feedTable.getAllStreamsFromGroup(groupId) - } - - return streams.map { - val items = ArrayList(it.size) - it.mapTo(items) { stream -> stream.toStreamInfoItem() } - return@map items + fun getStreams( + groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + getPlayedStreams: Boolean = true + ): Flowable> { + return when (groupId) { + FeedGroupEntity.GROUP_ALL_ID -> { + if (getPlayedStreams) feedTable.getAllStreams() + else feedTable.getLiveOrNotPlayedStreams() + } + else -> { + if (getPlayedStreams) feedTable.getAllStreamsForGroup(groupId) + else feedTable.getLiveOrNotPlayedStreamsForGroup(groupId) + } } } @@ -60,8 +64,10 @@ class FeedDatabaseManager(context: Context) { } } - fun outdatedSubscriptionsForGroup(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, outdatedThreshold: OffsetDateTime) = - feedTable.getAllOutdatedForGroup(groupId, outdatedThreshold) + fun outdatedSubscriptionsForGroup( + groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + outdatedThreshold: OffsetDateTime + ) = feedTable.getAllOutdatedForGroup(groupId, outdatedThreshold) fun markAsOutdated(subscriptionId: Long) = feedTable .setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null)) @@ -93,10 +99,7 @@ class FeedDatabaseManager(context: Context) { } feedTable.setLastUpdatedForSubscription( - FeedLastUpdatedEntity( - subscriptionId, - OffsetDateTime.now(ZoneOffset.UTC) - ) + FeedLastUpdatedEntity(subscriptionId, OffsetDateTime.now(ZoneOffset.UTC)) ) } @@ -108,7 +111,12 @@ class FeedDatabaseManager(context: Context) { fun clear() { feedTable.deleteAll() val deletedOrphans = streamTable.deleteOrphans() - if (DEBUG) Log.d(this::class.java.simpleName, "clear() → streamTable.deleteOrphans() → $deletedOrphans") + if (DEBUG) { + Log.d( + this::class.java.simpleName, + "clear() → streamTable.deleteOrphans() → $deletedOrphans" + ) + } } // ///////////////////////////////////////////////////////////////////////// @@ -122,7 +130,8 @@ class FeedDatabaseManager(context: Context) { } fun updateSubscriptionsForGroup(groupId: Long, subscriptionIds: List): Completable { - return Completable.fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) } + return Completable + .fromCallable { feedGroupTable.updateSubscriptionsForGroup(groupId, subscriptionIds) } .subscribeOn(Schedulers.io()) .observeOn(AndroidSchedulers.mainThread()) } 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 1df999144..125de0098 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 @@ -19,7 +19,10 @@ package org.schabi.newpipe.local.feed +import android.annotation.SuppressLint +import android.app.Activity import android.content.Intent +import android.content.SharedPreferences import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater @@ -28,41 +31,75 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup +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 import androidx.lifecycle.ViewModelProvider import androidx.preference.PreferenceManager +import androidx.recyclerview.widget.GridLayoutManager +import com.xwray.groupie.GroupAdapter +import com.xwray.groupie.GroupieViewHolder +import com.xwray.groupie.Item +import com.xwray.groupie.OnItemClickListener +import com.xwray.groupie.OnItemLongClickListener import icepick.State +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 org.schabi.newpipe.NewPipeDatabase import org.schabi.newpipe.R 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.UserAction -import org.schabi.newpipe.fragments.list.BaseListFragment +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.stream.StreamType +import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty +import org.schabi.newpipe.fragments.BaseStateFragment +import org.schabi.newpipe.info_list.InfoItemDialog import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling +import org.schabi.newpipe.local.feed.item.StreamItem import org.schabi.newpipe.local.feed.service.FeedLoadService +import org.schabi.newpipe.local.subscription.SubscriptionManager +import org.schabi.newpipe.player.helper.PlayerHolder import org.schabi.newpipe.util.Localization +import org.schabi.newpipe.util.NavigationHelper +import org.schabi.newpipe.util.StreamDialogEntry +import org.schabi.newpipe.util.ThemeHelper.getGridSpanCount +import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout import java.time.OffsetDateTime +import java.util.ArrayList -class FeedFragment : BaseListFragment() { +class FeedFragment : BaseStateFragment() { private var _feedBinding: FragmentFeedBinding? = null private val feedBinding get() = _feedBinding!! + private val disposables = CompositeDisposable() + private lateinit var viewModel: FeedViewModel - @State - @JvmField - var listState: Parcelable? = null + @State @JvmField var listState: Parcelable? = null private var groupId = FeedGroupEntity.GROUP_ALL_ID private var groupName = "" private var oldestSubscriptionUpdate: OffsetDateTime? = null + private lateinit var groupAdapter: GroupAdapter + @State @JvmField var showPlayedItems: Boolean = true + + private var onSettingsChangeListener: SharedPreferences.OnSharedPreferenceChangeListener? = null + private var updateListViewModeOnResume = false + private var isRefreshing = false + init { setHasOptionsMenu(true) - setUseDefaultStateSaving(false) } override fun onCreate(savedInstanceState: Bundle?) { @@ -71,6 +108,14 @@ class FeedFragment : BaseListFragment() { groupId = arguments?.getLong(KEY_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID) ?: FeedGroupEntity.GROUP_ALL_ID groupName = arguments?.getString(KEY_GROUP_NAME) ?: "" + + onSettingsChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key.equals(getString(R.string.list_view_mode_key))) { + updateListViewModeOnResume = true + } + } + PreferenceManager.getDefaultSharedPreferences(activity) + .registerOnSharedPreferenceChangeListener(onSettingsChangeListener) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { @@ -82,8 +127,17 @@ class FeedFragment : BaseListFragment() { _feedBinding = FragmentFeedBinding.bind(rootView) super.onViewCreated(rootView, savedInstanceState) - viewModel = ViewModelProvider(this, FeedViewModel.Factory(requireContext(), groupId)).get(FeedViewModel::class.java) - viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) } + val factory = FeedViewModel.Factory(requireContext(), groupId, showPlayedItems) + viewModel = ViewModelProvider(this, factory).get(FeedViewModel::class.java) + viewModel.stateLiveData.observe(viewLifecycleOwner, { it?.let(::handleResult) }) + + groupAdapter = GroupAdapter().apply { + setOnItemClickListener(listenerStreamItem) + setOnItemLongClickListener(listenerStreamItem) + } + + feedBinding.itemsList.adapter = groupAdapter + setupListViewMode() } override fun onPause() { @@ -94,13 +148,22 @@ class FeedFragment : BaseListFragment() { override fun onResume() { super.onResume() updateRelativeTimeViews() + + if (updateListViewModeOnResume) { + updateListViewModeOnResume = false + + setupListViewMode() + if (viewModel.stateLiveData.value != null) { + handleResult(viewModel.stateLiveData.value!!) + } + } } - override fun setUserVisibleHint(isVisibleToUser: Boolean) { - super.setUserVisibleHint(isVisibleToUser) - - if (!isVisibleToUser && view != null) { - updateRelativeTimeViews() + fun setupListViewMode() { + // does everything needed to setup the layouts for grid or list modes + groupAdapter.spanCount = if (shouldUseGridLayout(context)) getGridSpanCount(context) else 1 + feedBinding.itemsList.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply { + spanSizeLookup = groupAdapter.spanSizeLookup } } @@ -116,21 +179,21 @@ class FeedFragment : BaseListFragment() { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) + + activity.supportActionBar?.setDisplayShowTitleEnabled(true) activity.supportActionBar?.setTitle(R.string.fragment_feed_title) activity.supportActionBar?.subtitle = groupName inflater.inflate(R.menu.menu_feed_fragment, menu) - - if (useAsFrontPage) { - menu.findItem(R.id.menu_item_feed_help).setShowAsAction(MenuItem.SHOW_AS_ACTION_NEVER) - } + updateTogglePlayedItemsButton(menu.findItem(R.id.menu_item_feed_toggle_played_items)) } override fun onOptionsItemSelected(item: MenuItem): Boolean { if (item.itemId == R.id.menu_item_feed_help) { val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - val usingDedicatedMethod = sharedPreferences.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false) + val usingDedicatedMethod = sharedPreferences + .getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false) val enableDisableButtonText = when { usingDedicatedMethod -> R.string.feed_use_dedicated_fetch_method_disable_button else -> R.string.feed_use_dedicated_fetch_method_enable_button @@ -147,6 +210,10 @@ class FeedFragment : BaseListFragment() { .create() .show() return true + } else if (item.itemId == R.id.menu_item_feed_toggle_played_items) { + showPlayedItems = !item.isChecked + updateTogglePlayedItemsButton(item) + viewModel.togglePlayedItems(showPlayedItems) } return super.onOptionsItemSelected(item) @@ -158,18 +225,34 @@ class FeedFragment : BaseListFragment() { } override fun onDestroy() { + disposables.dispose() + if (onSettingsChangeListener != null) { + PreferenceManager.getDefaultSharedPreferences(activity) + .unregisterOnSharedPreferenceChangeListener(onSettingsChangeListener) + onSettingsChangeListener = null + } + super.onDestroy() activity?.supportActionBar?.subtitle = null } override fun onDestroyView() { + feedBinding.itemsList.adapter = null _feedBinding = null 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 - // ///////////////////////////////////////////////////////////////////////// + // ////////////////////////////////////////////////////////////////////////// override fun showLoading() { super.showLoading() @@ -177,13 +260,16 @@ class FeedFragment : BaseListFragment() { feedBinding.refreshRootView.animate(false, 0) feedBinding.loadingProgressText.animate(true, 200) feedBinding.swipeRefreshLayout.isRefreshing = true + isRefreshing = true } override fun hideLoading() { super.hideLoading() + feedBinding.itemsList.animate(true, 0) feedBinding.refreshRootView.animate(true, 200) feedBinding.loadingProgressText.animate(false, 0) feedBinding.swipeRefreshLayout.isRefreshing = false + isRefreshing = false } override fun showEmptyState() { @@ -206,11 +292,11 @@ class FeedFragment : BaseListFragment() { override fun handleError() { super.handleError() - infoListAdapter.clearStreamItemList() feedBinding.itemsList.animateHideRecyclerViewAllowingScrolling() feedBinding.refreshRootView.animate(false, 0) feedBinding.loadingProgressText.animate(false, 0) feedBinding.swipeRefreshLayout.isRefreshing = false + isRefreshing = false } private fun handleProgressState(progressState: FeedState.ProgressState) { @@ -234,24 +320,101 @@ class FeedFragment : BaseListFragment() { feedBinding.loadingProgressBar.max = progressState.maxProgress } + private fun showStreamDialog(item: StreamInfoItem) { + val context = context + val activity: Activity? = getActivity() + if (context == null || context.resources == null || activity == null) return + + val entries = ArrayList() + if (PlayerHolder.getInstance().getType() != null) { + entries.add(StreamDialogEntry.enqueue) + } + if (item.streamType == StreamType.AUDIO_STREAM) { + entries.addAll( + listOf( + StreamDialogEntry.start_here_on_background, + StreamDialogEntry.append_playlist, + StreamDialogEntry.share, + StreamDialogEntry.open_in_browser + ) + ) + } else { + entries.addAll( + listOf( + StreamDialogEntry.start_here_on_background, + StreamDialogEntry.start_here_on_popup, + StreamDialogEntry.append_playlist, + StreamDialogEntry.share, + StreamDialogEntry.open_in_browser + ) + ) + } + if (item.streamType != StreamType.AUDIO_LIVE_STREAM && item.streamType != StreamType.LIVE_STREAM) { + entries.add( + StreamDialogEntry.mark_as_watched + ) + } + + StreamDialogEntry.setEnabledEntries(entries) + InfoItemDialog(activity, item, StreamDialogEntry.getCommands(context)) { _, which -> + StreamDialogEntry.clickOn(which, this, item) + }.show() + } + + private val listenerStreamItem = object : OnItemClickListener, OnItemLongClickListener { + override fun onItemClick(item: Item<*>, view: View) { + if (item is StreamItem && !isRefreshing) { + val stream = item.streamWithState.stream + NavigationHelper.openVideoDetailFragment( + requireContext(), fm, + stream.serviceId, stream.url, stream.title, null, false + ) + } + } + + override fun onItemLongClick(item: Item<*>, view: View): Boolean { + if (item is StreamItem && !isRefreshing) { + showStreamDialog(item.streamWithState.stream.toStreamInfoItem()) + return true + } + return false + } + } + + @SuppressLint("StringFormatMatches") private fun handleLoadedState(loadedState: FeedState.LoadedState) { - infoListAdapter.setInfoItemList(loadedState.items) + + val itemVersion = if (shouldUseGridLayout(context)) { + StreamItem.ItemVersion.GRID + } else { + StreamItem.ItemVersion.NORMAL + } + loadedState.items.forEach { it.itemVersion = itemVersion } + + groupAdapter.updateAsync(loadedState.items, false, null) + listState?.run { feedBinding.itemsList.layoutManager?.onRestoreInstanceState(listState) listState = null } - oldestSubscriptionUpdate = loadedState.oldestUpdate - - val loadedCount = loadedState.notLoadedCount > 0 - feedBinding.refreshSubtitleText.isVisible = loadedCount - if (loadedCount) { + val feedsNotLoaded = loadedState.notLoadedCount > 0 + feedBinding.refreshSubtitleText.isVisible = feedsNotLoaded + if (feedsNotLoaded) { feedBinding.refreshSubtitleText.text = getString( R.string.feed_subscription_not_loaded_count, loadedState.notLoadedCount ) } + if (oldestSubscriptionUpdate != loadedState.oldestUpdate || + (oldestSubscriptionUpdate == null && loadedState.oldestUpdate == null) + ) { + // ignore errors if they have already been handled for the current update + handleItemsErrors(loadedState.itemsErrors) + } + oldestSubscriptionUpdate = loadedState.oldestUpdate + if (loadedState.items.isEmpty()) { showEmptyState() } else { @@ -269,9 +432,78 @@ class FeedFragment : BaseListFragment() { } } + private fun handleItemsErrors(errors: List) { + errors.forEachIndexed { i, t -> + 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 -> throwable.printStackTrace() } + ) + return // this will be called on the remaining errors by handleFeedNotAvailable() + } + } + } + + private fun handleFeedNotAvailable( + subscriptionEntity: SubscriptionEntity, + @Nullable cause: Throwable?, + nextItemsErrors: List + ) { + val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) + val isFastFeedModeEnabled = sharedPreferences.getBoolean( + getString(R.string.feed_use_dedicated_fetch_method_key), false + ) + + val builder = AlertDialog.Builder(requireContext()) + .setTitle(R.string.feed_load_error) + .setPositiveButton( + R.string.unsubscribe + ) { _, _ -> + SubscriptionManager(requireContext()).deleteSubscription( + subscriptionEntity.serviceId, subscriptionEntity.url + ).subscribe() + handleItemsErrors(nextItemsErrors) + } + .setNegativeButton(R.string.cancel) { _, _ -> } + + var message = getString(R.string.feed_load_error_account_info, subscriptionEntity.name) + if (cause is AccountTerminatedException) { + message += "\n" + getString(R.string.feed_load_error_terminated) + } else if (cause is ContentNotAvailableException) { + if (isFastFeedModeEnabled) { + message += "\n" + getString(R.string.feed_load_error_fast_unknown) + builder.setNeutralButton(R.string.feed_use_dedicated_fetch_method_disable_button) { _, _ -> + sharedPreferences.edit { + putBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false) + } + } + } else if (!isNullOrEmpty(cause.message)) { + message += "\n" + cause.message + } + } + builder.setMessage(message).create().show() + } + private fun updateRelativeTimeViews() { updateRefreshViewState() - infoListAdapter.notifyDataSetChanged() + groupAdapter.notifyItemRangeChanged( + 0, groupAdapter.itemCount, + StreamItem.UPDATE_RELATIVE_TIME + ) } private fun updateRefreshViewState() { @@ -286,8 +518,6 @@ class FeedFragment : BaseListFragment() { // ///////////////////////////////////////////////////////////////////////// override fun doInitialLoadLogic() {} - override fun loadMoreItems() {} - override fun hasMoreItems() = false override fun reloadContent() { getActivity()?.startService( 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 dec2773e1..27613e83e 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 @@ -1,7 +1,7 @@ package org.schabi.newpipe.local.feed import androidx.annotation.StringRes -import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.local.feed.item.StreamItem import java.time.OffsetDateTime sealed class FeedState { @@ -12,7 +12,7 @@ sealed class FeedState { ) : FeedState() data class LoadedState( - val items: List, + val items: List, val oldestUpdate: OffsetDateTime? = null, val notLoadedCount: Long, val itemsErrors: List = emptyList() 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 e516cdaca..8bdf412b5 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 @@ -8,9 +8,11 @@ import androidx.lifecycle.ViewModelProvider import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.functions.Function4 +import io.reactivex.rxjava3.processors.BehaviorProcessor import io.reactivex.rxjava3.schedulers.Schedulers import org.schabi.newpipe.database.feed.model.FeedGroupEntity -import org.schabi.newpipe.extractor.stream.StreamInfoItem +import org.schabi.newpipe.database.stream.StreamWithState +import org.schabi.newpipe.local.feed.item.StreamItem import org.schabi.newpipe.local.feed.service.FeedEventManager import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.IdleEvent @@ -20,26 +22,33 @@ import org.schabi.newpipe.util.DEFAULT_THROTTLE_TIMEOUT import java.time.OffsetDateTime import java.util.concurrent.TimeUnit -class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModel() { - class Factory(val context: Context, val groupId: Long = FeedGroupEntity.GROUP_ALL_ID) : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return FeedViewModel(context.applicationContext, groupId) as T - } - } - +class FeedViewModel( + applicationContext: Context, + groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + initialShowPlayedItems: Boolean = true +) : ViewModel() { private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext) + private val toggleShowPlayedItems = BehaviorProcessor.create() + private val streamItems = toggleShowPlayedItems + .startWithItem(initialShowPlayedItems) + .distinctUntilChanged() + .switchMap { showPlayedItems -> + feedDatabaseManager.getStreams(groupId, showPlayedItems) + } + private val mutableStateLiveData = MutableLiveData() val stateLiveData: LiveData = mutableStateLiveData private var combineDisposable = Flowable .combineLatest( FeedEventManager.events(), - feedDatabaseManager.asStreamItems(groupId), + streamItems, feedDatabaseManager.notLoadedCount(groupId), feedDatabaseManager.oldestSubscriptionUpdate(groupId), - Function4 { t1: FeedEventManager.Event, t2: List, t3: Long, t4: List -> + + Function4 { t1: FeedEventManager.Event, t2: List, + t3: Long, t4: List -> return@Function4 CombineResultHolder(t1, t2, t3, t4.firstOrNull()) } ) @@ -49,9 +58,9 @@ class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEn .subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) -> mutableStateLiveData.postValue( when (event) { - is IdleEvent -> FeedState.LoadedState(listFromDB, oldestUpdate, notLoadedCount) + is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount) is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage) - is SuccessResultEvent -> FeedState.LoadedState(listFromDB, oldestUpdate, notLoadedCount, event.itemsErrors) + is SuccessResultEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, event.itemsErrors) is ErrorResultEvent -> FeedState.ErrorState(event.error) } ) @@ -66,5 +75,20 @@ class FeedViewModel(applicationContext: Context, val groupId: Long = FeedGroupEn combineDisposable.dispose() } - private data class CombineResultHolder(val t1: FeedEventManager.Event, val t2: List, val t3: Long, val t4: OffsetDateTime?) + private data class CombineResultHolder(val t1: FeedEventManager.Event, val t2: List, val t3: Long, val t4: OffsetDateTime?) + + fun togglePlayedItems(showPlayedItems: Boolean) { + toggleShowPlayedItems.onNext(showPlayedItems) + } + + class Factory( + private val context: Context, + private val groupId: Long = FeedGroupEntity.GROUP_ALL_ID, + private val showPlayedItems: Boolean + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return FeedViewModel(context.applicationContext, groupId, showPlayedItems) as T + } + } } 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 new file mode 100644 index 000000000..13ba7592b --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/local/feed/item/StreamItem.kt @@ -0,0 +1,153 @@ +package org.schabi.newpipe.local.feed.item + +import android.content.Context +import android.text.TextUtils +import android.view.View +import androidx.core.content.ContextCompat +import androidx.preference.PreferenceManager +import com.nostra13.universalimageloader.core.ImageLoader +import com.xwray.groupie.viewbinding.BindableItem +import org.schabi.newpipe.MainActivity +import org.schabi.newpipe.R +import org.schabi.newpipe.database.stream.StreamWithState +import org.schabi.newpipe.database.stream.model.StreamEntity +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.VIDEO_STREAM +import org.schabi.newpipe.util.ImageDisplayConstants +import org.schabi.newpipe.util.Localization +import java.util.concurrent.TimeUnit + +data class StreamItem( + val streamWithState: StreamWithState, + var itemVersion: ItemVersion = ItemVersion.NORMAL +) : BindableItem() { + companion object { + const val UPDATE_RELATIVE_TIME = 1 + } + + private val stream: StreamEntity = streamWithState.stream + private val stateProgressTime: Long? = streamWithState.stateProgressMillis + + override fun getId(): Long = stream.uid + + enum class ItemVersion { NORMAL, MINI, GRID } + + 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 + } + + override fun initializeViewBinding(view: View) = ListStreamItemBinding.bind(view) + + override fun bind(viewBinding: ListStreamItemBinding, position: Int, payloads: MutableList) { + if (payloads.contains(UPDATE_RELATIVE_TIME)) { + if (itemVersion != ItemVersion.MINI) { + viewBinding.itemAdditionalDetails.text = + getStreamInfoDetailLine(viewBinding.itemAdditionalDetails.context) + } + return + } + + super.bind(viewBinding, position, payloads) + } + + override fun bind(viewBinding: ListStreamItemBinding, position: Int) { + viewBinding.itemVideoTitleView.text = stream.title + viewBinding.itemUploaderView.text = stream.uploader + + val isLiveStream = stream.streamType == LIVE_STREAM || stream.streamType == AUDIO_LIVE_STREAM + + if (stream.duration > 0) { + viewBinding.itemDurationView.text = Localization.getDurationString(stream.duration) + viewBinding.itemDurationView.setBackgroundColor( + ContextCompat.getColor( + viewBinding.itemDurationView.context, + R.color.duration_background_color + ) + ) + viewBinding.itemDurationView.visibility = View.VISIBLE + + if (stateProgressTime != null) { + viewBinding.itemProgressView.visibility = View.VISIBLE + viewBinding.itemProgressView.max = stream.duration.toInt() + viewBinding.itemProgressView.progress = TimeUnit.MILLISECONDS.toSeconds(stateProgressTime).toInt() + } else { + viewBinding.itemProgressView.visibility = View.GONE + } + } else if (isLiveStream) { + viewBinding.itemDurationView.setText(R.string.duration_live) + viewBinding.itemDurationView.setBackgroundColor( + ContextCompat.getColor( + viewBinding.itemDurationView.context, + R.color.live_duration_background_color + ) + ) + viewBinding.itemDurationView.visibility = View.VISIBLE + viewBinding.itemProgressView.visibility = View.GONE + } else { + viewBinding.itemDurationView.visibility = View.GONE + viewBinding.itemProgressView.visibility = View.GONE + } + + ImageLoader.getInstance().displayImage( + stream.thumbnailUrl, viewBinding.itemThumbnailView, + ImageDisplayConstants.DISPLAY_THUMBNAIL_OPTIONS + ) + + if (itemVersion != ItemVersion.MINI) { + viewBinding.itemAdditionalDetails.text = + getStreamInfoDetailLine(viewBinding.itemAdditionalDetails.context) + } + } + + override fun isLongClickable() = when (stream.streamType) { + AUDIO_STREAM, VIDEO_STREAM, LIVE_STREAM, AUDIO_LIVE_STREAM -> true + else -> false + } + + private fun getStreamInfoDetailLine(context: Context): String { + var viewsAndDate = "" + val viewCount = stream.viewCount + if (viewCount != null && viewCount >= 0) { + viewsAndDate = when (stream.streamType) { + AUDIO_LIVE_STREAM -> Localization.listeningCount(context, viewCount) + LIVE_STREAM -> Localization.shortWatchingCount(context, viewCount) + else -> Localization.shortViewCount(context, viewCount) + } + } + val uploadDate = getFormattedRelativeUploadDate(context) + return when { + !TextUtils.isEmpty(uploadDate) -> when { + viewsAndDate.isEmpty() -> uploadDate!! + else -> Localization.concatenateStrings(viewsAndDate, uploadDate) + } + else -> viewsAndDate + } + } + + private fun getFormattedRelativeUploadDate(context: Context): String? { + val uploadDate = stream.uploadDate + return if (uploadDate != null) { + var formattedRelativeTime = Localization.relativeTime(uploadDate) + + if (MainActivity.DEBUG) { + val key = context.getString(R.string.show_original_time_ago_key) + if (PreferenceManager.getDefaultSharedPreferences(context).getBoolean(key, false)) { + formattedRelativeTime += " (" + stream.textualUploadDate + ")" + } + } + + formattedRelativeTime + } else { + stream.textualUploadDate + } + } + + override fun getSpanSize(spanCount: Int, position: Int): Int { + return if (itemVersion == ItemVersion.GRID) 1 else spanCount + } +} 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 5ed7998d2..3638b4c0e 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 @@ -48,9 +48,7 @@ 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.exceptions.ReCaptchaException import org.schabi.newpipe.extractor.stream.StreamInfoItem -import org.schabi.newpipe.ktx.isNetworkRelated import org.schabi.newpipe.local.feed.FeedDatabaseManager import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ProgressEvent @@ -58,7 +56,6 @@ import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.SuccessResul import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent import org.schabi.newpipe.local.subscription.SubscriptionManager import org.schabi.newpipe.util.ExtractorHelper -import java.io.IOException import java.time.OffsetDateTime import java.time.ZoneOffset import java.util.concurrent.TimeUnit @@ -162,7 +159,7 @@ class FeedLoadService : Service() { // Loading & Handling // ///////////////////////////////////////////////////////////////////////// - private class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) { + 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) @@ -209,29 +206,40 @@ class FeedLoadService : Service() { .filter { !cancelSignal.get() } .map { subscriptionEntity -> + var error: Throwable? = null try { 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(Pair(subscriptionEntity.uid, 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 = RequestException(subscriptionEntity.uid, request, e) + val wrapper = RequestException(subscriptionEntity.uid, request, error!!) return@map Notification.createOnError>>(wrapper) } } .sequential() - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext(errorHandlingConsumer) - .observeOn(AndroidSchedulers.mainThread()) .doOnNext(notificationsConsumer) @@ -331,24 +339,6 @@ class FeedLoadService : Service() { } } - private val errorHandlingConsumer: Consumer>>> - get() = Consumer { - if (it.isOnError) { - var error = it.error!! - if (error is RequestException) error = error.cause!! - val cause = error.cause - - when { - error is ReCaptchaException -> throw error - cause is ReCaptchaException -> throw cause - - error is IOException -> throw error - cause is IOException -> throw cause - error.isNetworkRelated -> throw IOException(error) - } - } - } - private val notificationsConsumer: Consumer>>> get() = Consumer { onItemCompleted(it.value?.second?.name) } 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 66f1bda0e..823e56d9e 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,6 +28,7 @@ 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; @@ -42,7 +43,10 @@ import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.extractor.stream.StreamInfoItem; +import org.schabi.newpipe.local.feed.FeedViewModel; import org.schabi.newpipe.player.playqueue.PlayQueueItem; +import org.schabi.newpipe.util.ExtractorHelper; import java.time.OffsetDateTime; import java.time.ZoneOffset; @@ -81,6 +85,68 @@ public class HistoryRecordManager { // Watch History /////////////////////////////////////////////////////// + /** + * 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 + * @param info the item to mark as watched + * @return a Maybe containing the ID of the item if successful + */ + public Maybe markAsWatched(final StreamInfoItem info) { + if (!isStreamHistoryEnabled()) { + return Maybe.empty(); + } + + final OffsetDateTime currentTime = OffsetDateTime.now(ZoneOffset.UTC); + return Maybe.fromCallable(() -> database.runInTransaction(() -> { + final long streamId; + final long duration; + // Duration will not exist if the item was loaded with fast mode, so fetch it if empty + if (info.getDuration() < 0) { + final StreamInfo completeInfo = ExtractorHelper.getStreamInfo( + info.getServiceId(), + info.getUrl(), + false + ) + .subscribeOn(Schedulers.io()) + .blockingGet(); + duration = completeInfo.getDuration(); + streamId = streamTable.upsert(new StreamEntity(completeInfo)); + } else { + duration = info.getDuration(); + streamId = streamTable.upsert(new StreamEntity(info)); + } + + // Update the stream progress to the full duration of the video + final List states = streamStateTable.getState(streamId) + .blockingFirst(); + if (!states.isEmpty()) { + final StreamStateEntity entity = states.get(0); + entity.setProgressMillis(duration * 1000); + streamStateTable.update(entity); + } else { + final StreamStateEntity entity = new StreamStateEntity( + streamId, + duration * 1000 + ); + streamStateTable.insert(entity); + } + + // 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); + } else { + return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime)); + } + })).subscribeOn(Schedulers.io()); + } + public Maybe onViewed(final StreamInfo info) { if (!isStreamHistoryEnabled()) { return Maybe.empty(); @@ -211,11 +277,11 @@ public class HistoryRecordManager { public Maybe loadStreamState(final PlayQueueItem queueItem) { return queueItem.getStream() - .map((info) -> streamTable.upsert(new StreamEntity(info))) + .map(info -> streamTable.upsert(new StreamEntity(info))) .flatMapPublisher(streamStateTable::getState) .firstElement() .flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0))) - .filter(state -> state.isValid((int) queueItem.getDuration())) + .filter(state -> state.isValid(queueItem.getDuration())) .subscribeOn(Schedulers.io()); } @@ -224,18 +290,16 @@ public class HistoryRecordManager { .flatMapPublisher(streamStateTable::getState) .firstElement() .flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0))) - .filter(state -> state.isValid((int) info.getDuration())) + .filter(state -> state.isValid(info.getDuration())) .subscribeOn(Schedulers.io()); } - public Completable saveStreamState(@NonNull final StreamInfo info, final long progressTime) { + public Completable saveStreamState(@NonNull final StreamInfo info, final long progressMillis) { return Completable.fromAction(() -> database.runInTransaction(() -> { final long streamId = streamTable.upsert(new StreamEntity(info)); - final StreamStateEntity state = new StreamStateEntity(streamId, progressTime); - if (state.isValid((int) info.getDuration())) { + final StreamStateEntity state = new StreamStateEntity(streamId, progressMillis); + if (state.isValid(info.getDuration())) { streamStateTable.upsert(state); - } else { - streamStateTable.deleteState(streamId); } })).subscribeOn(Schedulers.io()); } 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 1bece369b..166b9c04e 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 @@ -36,11 +36,10 @@ import org.schabi.newpipe.player.helper.PlayerHolder; 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.KoreUtil; +import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.StreamDialogEntry; -import org.schabi.newpipe.util.ThemeHelper; import java.util.ArrayList; import java.util.Arrays; @@ -54,6 +53,8 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.Disposable; +import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; + public class StatisticsPlaylistFragment extends BaseLocalListFragment, Void> { private final CompositeDisposable disposables = new CompositeDisposable(); @@ -110,7 +111,8 @@ public class StatisticsPlaylistFragment } @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); inflater.inflate(R.menu.menu_history, menu); } @@ -312,14 +314,13 @@ public class StatisticsPlaylistFragment if (sortMode == StatisticSortMode.LAST_PLAYED) { sortMode = StatisticSortMode.MOST_PLAYED; setTitle(getString(R.string.title_most_played)); - headerBinding.sortButtonIcon.setImageResource( - ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_history)); + headerBinding.sortButtonIcon.setImageResource(R.drawable.ic_history); headerBinding.sortButtonText.setText(R.string.title_last_played); } else { sortMode = StatisticSortMode.LAST_PLAYED; setTitle(getString(R.string.title_last_played)); headerBinding.sortButtonIcon.setImageResource( - ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_filter_list)); + R.drawable.ic_filter_list); headerBinding.sortButtonText.setText(R.string.title_most_played); } startLoading(true); @@ -339,7 +340,7 @@ public class StatisticsPlaylistFragment final ArrayList entries = new ArrayList<>(); - if (PlayerHolder.getType() != null) { + if (PlayerHolder.getInstance().getType() != null) { entries.add(StreamDialogEntry.enqueue); } if (infoItem.getStreamType() == StreamType.AUDIO_STREAM) { @@ -358,9 +359,15 @@ public class StatisticsPlaylistFragment StreamDialogEntry.share )); } - if (KoreUtil.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { + entries.add(StreamDialogEntry.open_in_browser); + if (KoreUtils.shouldShowPlayWithKodi(context, infoItem.getServiceId())) { entries.add(StreamDialogEntry.play_with_kodi); } + + if (!isNullOrEmpty(infoItem.getUploaderUrl())) { + entries.add(StreamDialogEntry.show_channel_details); + } + StreamDialogEntry.setEnabledEntries(entries); StreamDialogEntry.start_here_on_background.setCustomAction((fragment, infoItemDuplicate) -> 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 fd6c8d1d1..903f10440 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 @@ -68,11 +68,11 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder { R.color.duration_background_color)); itemDurationView.setVisibility(View.VISIBLE); - if (item.getProgressTime() > 0) { + if (item.getProgressMillis() > 0) { itemProgressView.setVisibility(View.VISIBLE); itemProgressView.setMax((int) item.getStreamEntity().getDuration()); itemProgressView.setProgress((int) TimeUnit.MILLISECONDS - .toSeconds(item.getProgressTime())); + .toSeconds(item.getProgressMillis())); } else { itemProgressView.setVisibility(View.GONE); } @@ -109,14 +109,14 @@ public class LocalPlaylistStreamItemHolder extends LocalItemHolder { } final PlaylistStreamEntry item = (PlaylistStreamEntry) localItem; - if (item.getProgressTime() > 0 && item.getStreamEntity().getDuration() > 0) { + if (item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) { itemProgressView.setMax((int) item.getStreamEntity().getDuration()); if (itemProgressView.getVisibility() == View.VISIBLE) { itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS - .toSeconds(item.getProgressTime())); + .toSeconds(item.getProgressMillis())); } else { itemProgressView.setProgress((int) TimeUnit.MILLISECONDS - .toSeconds(item.getProgressTime())); + .toSeconds(item.getProgressMillis())); ViewUtils.animate(itemProgressView, true, 500); } } else if (itemProgressView.getVisibility() == View.VISIBLE) { 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 7c4e47c36..adf6bd5c2 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 @@ -96,11 +96,11 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder { R.color.duration_background_color)); itemDurationView.setVisibility(View.VISIBLE); - if (item.getProgressTime() > 0) { + if (item.getProgressMillis() > 0) { itemProgressView.setVisibility(View.VISIBLE); itemProgressView.setMax((int) item.getStreamEntity().getDuration()); itemProgressView.setProgress((int) TimeUnit.MILLISECONDS - .toSeconds(item.getProgressTime())); + .toSeconds(item.getProgressMillis())); } else { itemProgressView.setVisibility(View.GONE); } @@ -140,14 +140,14 @@ public class LocalStatisticStreamItemHolder extends LocalItemHolder { } final StreamStatisticsEntry item = (StreamStatisticsEntry) localItem; - if (item.getProgressTime() > 0 && item.getStreamEntity().getDuration() > 0) { + if (item.getProgressMillis() > 0 && item.getStreamEntity().getDuration() > 0) { itemProgressView.setMax((int) item.getStreamEntity().getDuration()); if (itemProgressView.getVisibility() == View.VISIBLE) { itemProgressView.setProgressAnimated((int) TimeUnit.MILLISECONDS - .toSeconds(item.getProgressTime())); + .toSeconds(item.getProgressMillis())); } else { itemProgressView.setProgress((int) TimeUnit.MILLISECONDS - .toSeconds(item.getProgressTime())); + .toSeconds(item.getProgressMillis())); ViewUtils.animate(itemProgressView, true, 500); } } else if (itemProgressView.getVisibility() == View.VISIBLE) { 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 5dbb67cd1..788a4d062 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 @@ -5,6 +5,7 @@ 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.view.LayoutInflater; @@ -13,7 +14,6 @@ import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; -import android.widget.EditText; import android.widget.Toast; import androidx.annotation.NonNull; @@ -32,6 +32,7 @@ import org.schabi.newpipe.database.history.model.StreamHistoryEntry; import org.schabi.newpipe.database.playlist.PlaylistStreamEntry; 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; @@ -44,7 +45,7 @@ import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; -import org.schabi.newpipe.util.KoreUtil; +import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.OnClickGesture; @@ -66,13 +67,14 @@ import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.subjects.PublishSubject; +import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; import static org.schabi.newpipe.ktx.ViewUtils.animate; +import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout; public class LocalPlaylistFragment extends BaseLocalListFragment, Void> { // Save the list 10 seconds after the last change occurred private static final long SAVE_DEBOUNCE_MILLIS = 10000; private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12; - @State protected Long playlistId; @State @@ -248,7 +250,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment - changePlaylistName(nameEdit.getText().toString())); + changePlaylistName(dialogBinding.dialogEditText.getText().toString())); dialogBuilder.show(); } @@ -674,7 +682,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment entries = new ArrayList<>(); - if (PlayerHolder.getType() != null) { + if (PlayerHolder.getInstance().getType() != null) { entries.add(StreamDialogEntry.enqueue); } if (infoItem.getStreamType() == StreamType.AUDIO_STREAM) { @@ -755,7 +764,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment 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 19038be93..83a90213d 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 @@ -1,10 +1,7 @@ package org.schabi.newpipe.local.subscription -import android.content.Context -import androidx.annotation.AttrRes import androidx.annotation.DrawableRes import org.schabi.newpipe.R -import org.schabi.newpipe.util.ThemeHelper enum class FeedGroupIcon( /** @@ -13,51 +10,51 @@ enum class FeedGroupIcon( val id: Int, /** - * The attribute that points to a drawable resource. "R.attr" is used here to support multiple themes. + * The drawable resource. */ - @AttrRes val drawableResourceAttr: Int + @DrawableRes val drawableResource: Int ) { - ALL(0, R.attr.ic_asterisk), - MUSIC(1, R.attr.ic_music_note), - EDUCATION(2, R.attr.ic_school), - FITNESS(3, R.attr.ic_fitness_center), - SPACE(4, R.attr.ic_telescope), - COMPUTER(5, R.attr.ic_computer), - GAMING(6, R.attr.ic_videogame_asset), - SPORTS(7, R.attr.ic_sports), - NEWS(8, R.attr.ic_megaphone), - FAVORITES(9, R.attr.ic_heart), - CAR(10, R.attr.ic_car), - MOTORCYCLE(11, R.attr.ic_motorcycle), - TREND(12, R.attr.ic_trending_up), - MOVIE(13, R.attr.ic_movie), - BACKUP(14, R.attr.ic_backup), - ART(15, R.attr.ic_palette), - PERSON(16, R.attr.ic_person), - PEOPLE(17, R.attr.ic_people), - MONEY(18, R.attr.ic_money), - KIDS(19, R.attr.ic_child_care), - FOOD(20, R.attr.ic_fastfood), - SMILE(21, R.attr.ic_smile), - EXPLORE(22, R.attr.ic_explore), - RESTAURANT(23, R.attr.ic_restaurant), - MIC(24, R.attr.ic_mic), - HEADSET(25, R.attr.ic_headset), - RADIO(26, R.attr.ic_radio), - SHOPPING_CART(27, R.attr.ic_shopping_cart), - WATCH_LATER(28, R.attr.ic_watch_later), - WORK(29, R.attr.ic_work), - HOT(30, R.attr.ic_kiosk_hot), - CHANNEL(31, R.attr.ic_channel), - BOOKMARK(32, R.attr.ic_bookmark), - PETS(33, R.attr.ic_pets), - WORLD(34, R.attr.ic_world), - STAR(35, R.attr.ic_stars), - SUN(36, R.attr.ic_sunny), - RSS(37, R.attr.ic_rss); + ALL(0, R.drawable.ic_asterisk), + MUSIC(1, R.drawable.ic_music_note), + EDUCATION(2, R.drawable.ic_school), + FITNESS(3, R.drawable.ic_fitness_center), + SPACE(4, R.drawable.ic_telescope), + COMPUTER(5, R.drawable.ic_computer), + GAMING(6, R.drawable.ic_videogame_asset), + SPORTS(7, R.drawable.ic_directions_bike), + NEWS(8, R.drawable.ic_megaphone), + FAVORITES(9, R.drawable.ic_favorite), + CAR(10, R.drawable.ic_directions_car), + MOTORCYCLE(11, R.drawable.ic_motorcycle), + TREND(12, R.drawable.ic_trending_up), + MOVIE(13, R.drawable.ic_movie), + BACKUP(14, R.drawable.ic_backup), + ART(15, R.drawable.ic_palette), + PERSON(16, R.drawable.ic_person), + PEOPLE(17, R.drawable.ic_people), + MONEY(18, R.drawable.ic_attach_money), + KIDS(19, R.drawable.ic_child_care), + FOOD(20, R.drawable.ic_fastfood), + SMILE(21, R.drawable.ic_insert_emoticon), + EXPLORE(22, R.drawable.ic_explore), + RESTAURANT(23, R.drawable.ic_restaurant), + MIC(24, R.drawable.ic_mic), + HEADSET(25, R.drawable.ic_headset), + RADIO(26, R.drawable.ic_radio), + SHOPPING_CART(27, R.drawable.ic_shopping_cart), + WATCH_LATER(28, R.drawable.ic_watch_later), + WORK(29, R.drawable.ic_work), + HOT(30, R.drawable.ic_whatshot), + CHANNEL(31, R.drawable.ic_tv), + BOOKMARK(32, R.drawable.ic_bookmark), + PETS(33, R.drawable.ic_pets), + 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); @DrawableRes - fun getDrawableRes(context: Context): Int { - return ThemeHelper.resolveResourceIdFromAttr(context, drawableResourceAttr) + fun getDrawableRes(): Int { + return drawableResource } } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java index 17ae7b1c0..5ab0699eb 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java @@ -1,17 +1,16 @@ package org.schabi.newpipe.local.subscription; -import android.app.AlertDialog; import android.app.Dialog; import android.content.Intent; import android.os.Bundle; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.DialogFragment; import androidx.fragment.app.Fragment; import org.schabi.newpipe.R; -import org.schabi.newpipe.util.ThemeHelper; import icepick.Icepick; import icepick.State; @@ -24,13 +23,9 @@ public class ImportConfirmationDialog extends DialogFragment { public static void show(@NonNull final Fragment fragment, @NonNull final Intent resultServiceIntent) { - if (fragment.getFragmentManager() == null) { - return; - } - final ImportConfirmationDialog confirmationDialog = new ImportConfirmationDialog(); confirmationDialog.setResultServiceIntent(resultServiceIntent); - confirmationDialog.show(fragment.getFragmentManager(), null); + confirmationDialog.show(fragment.getParentFragmentManager(), null); } public void setResultServiceIntent(final Intent resultServiceIntent) { @@ -41,7 +36,7 @@ public class ImportConfirmationDialog extends DialogFragment { @Override public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) { assureCorrectAppLanguage(getContext()); - return new AlertDialog.Builder(getContext(), ThemeHelper.getDialogTheme(getContext())) + return new AlertDialog.Builder(requireContext()) .setMessage(R.string.import_network_expensive_warning) .setCancelable(true) .setNegativeButton(R.string.cancel, null) 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 d60d82cb4..c6f6cc73c 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,15 +1,12 @@ package org.schabi.newpipe.local.subscription import android.app.Activity -import android.app.AlertDialog import android.content.BroadcastReceiver import android.content.Context import android.content.DialogInterface import android.content.Intent import android.content.IntentFilter -import android.content.res.Configuration import android.os.Bundle -import android.os.Environment import android.os.Parcelable import android.view.LayoutInflater import android.view.Menu @@ -17,11 +14,12 @@ import android.view.MenuInflater 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.appcompat.app.AlertDialog import androidx.lifecycle.ViewModelProvider import androidx.localbroadcastmanager.content.LocalBroadcastManager -import androidx.preference.PreferenceManager import androidx.recyclerview.widget.GridLayoutManager -import com.nononsenseapps.filepicker.Utils import com.xwray.groupie.Group import com.xwray.groupie.GroupAdapter import com.xwray.groupie.Item @@ -52,23 +50,20 @@ 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.services.SubscriptionsExportService import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.EXPORT_COMPLETE_ACTION -import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService.KEY_FILE_PATH 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 -import org.schabi.newpipe.util.FilePickerActivityHelper +import org.schabi.newpipe.streams.io.StoredFileHelper import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.OnClickGesture -import org.schabi.newpipe.util.ShareUtils -import org.schabi.newpipe.util.ThemeHelper -import java.io.File +import org.schabi.newpipe.util.ThemeHelper.getGridSpanCount +import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout +import org.schabi.newpipe.util.external_communication.ShareUtils import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -import kotlin.math.floor -import kotlin.math.max class SubscriptionFragment : BaseStateFragment() { private var _binding: FragmentSubscriptionBinding? = null @@ -87,6 +82,11 @@ class SubscriptionFragment : BaseStateFragment() { private lateinit var feedGroupsSortMenuItem: HeaderWithMenuItem private val subscriptionsSection = Section() + private val requestExportLauncher = + registerForActivityResult(StartActivityForResult(), this::requestExportResult) + private val requestImportLauncher = + registerForActivityResult(StartActivityForResult(), this::requestImportResult) + @State @JvmField var itemsListState: Parcelable? = null @@ -110,13 +110,6 @@ class SubscriptionFragment : BaseStateFragment() { setupInitialLayout() } - override fun setUserVisibleHint(isVisibleToUser: Boolean) { - super.setUserVisibleHint(isVisibleToUser) - if (activity != null && isVisibleToUser) { - setTitle(activity.getString(R.string.tab_subscriptions)) - } - } - override fun onAttach(context: Context) { super.onAttach(context) subscriptionManager = SubscriptionManager(requireContext()) @@ -154,11 +147,8 @@ class SubscriptionFragment : BaseStateFragment() { override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { super.onCreateOptionsMenu(menu, inflater) - val supportActionBar = activity.supportActionBar - if (supportActionBar != null) { - supportActionBar.setDisplayShowTitleEnabled(true) - setTitle(getString(R.string.tab_subscriptions)) - } + activity.supportActionBar?.setDisplayShowTitleEnabled(true) + activity.supportActionBar?.setTitle(R.string.tab_subscriptions) } private fun setupBroadcastReceiver() { @@ -189,43 +179,39 @@ class SubscriptionFragment : BaseStateFragment() { } private fun onImportPreviousSelected() { - startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), REQUEST_IMPORT_CODE) + requestImportLauncher.launch(StoredFileHelper.getPicker(activity)) } private fun onExportSelected() { val date = SimpleDateFormat("yyyyMMddHHmm", Locale.ENGLISH).format(Date()) val exportName = "newpipe_subscriptions_$date.json" - val exportFile = File(Environment.getExternalStorageDirectory(), exportName) - startActivityForResult(FilePickerActivityHelper.chooseFileToSave(activity, exportFile.absolutePath), REQUEST_EXPORT_CODE) + requestExportLauncher.launch( + StoredFileHelper.getNewPicker(activity, exportName, "application/json", null) + ) } private fun openReorderDialog() { - FeedGroupReorderDialog().show(requireFragmentManager(), null) + FeedGroupReorderDialog().show(parentFragmentManager, null) } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { - super.onActivityResult(requestCode, resultCode, data) - if (data != null && data.data != null && resultCode == Activity.RESULT_OK) { - if (requestCode == REQUEST_EXPORT_CODE) { - val exportFile = Utils.getFileForUri(data.data!!) - if (!exportFile.parentFile.canWrite() || !exportFile.parentFile.canRead()) { - Toast.makeText(activity, R.string.invalid_directory, Toast.LENGTH_SHORT).show() - } else { - activity.startService( - Intent(activity, SubscriptionsExportService::class.java) - .putExtra(KEY_FILE_PATH, exportFile.absolutePath) - ) - } - } else if (requestCode == REQUEST_IMPORT_CODE) { - val path = Utils.getFileForUri(data.data!!).absolutePath - ImportConfirmationDialog.show( - this, - Intent(activity, SubscriptionsImportService::class.java) - .putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE) - .putExtra(KEY_VALUE, path) - ) - } + fun requestExportResult(result: ActivityResult) { + if (result.data != null && result.resultCode == Activity.RESULT_OK) { + activity.startService( + Intent(activity, SubscriptionsExportService::class.java) + .putExtra(SubscriptionsExportService.KEY_FILE_PATH, result.data?.data) + ) + } + } + + fun requestImportResult(result: ActivityResult) { + if (result.data != null && result.resultCode == Activity.RESULT_OK) { + ImportConfirmationDialog.show( + this, + Intent(activity, SubscriptionsImportService::class.java) + .putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE) + .putExtra(KEY_VALUE, result.data?.data) + ) } } @@ -257,7 +243,7 @@ class SubscriptionFragment : BaseStateFragment() { feedGroupsCarousel = FeedGroupCarouselItem(requireContext(), carouselAdapter) feedGroupsSortMenuItem = HeaderWithMenuItem( getString(R.string.feed_groups_header_title), - ThemeHelper.resolveResourceIdFromAttr(requireContext(), R.attr.ic_sort), + R.drawable.ic_sort, menuItemOnClickListener = ::openReorderDialog ) add(Section(feedGroupsSortMenuItem, listOf(feedGroupsCarousel))) @@ -281,8 +267,7 @@ class SubscriptionFragment : BaseStateFragment() { super.initViews(rootView, savedInstanceState) _binding = FragmentSubscriptionBinding.bind(rootView) - val shouldUseGridLayout = shouldUseGridLayout() - groupAdapter.spanCount = if (shouldUseGridLayout) getGridSpanCount() else 1 + groupAdapter.spanCount = if (shouldUseGridLayout(context)) getGridSpanCount(context) else 1 binding.itemsList.layoutManager = GridLayoutManager(requireContext(), groupAdapter.spanCount).apply { spanSizeLookup = groupAdapter.spanSizeLookup } @@ -294,12 +279,20 @@ class SubscriptionFragment : BaseStateFragment() { } private fun showLongTapDialog(selectedItem: ChannelInfoItem) { - val commands = arrayOf(getString(R.string.share), getString(R.string.unsubscribe)) + val commands = arrayOf( + getString(R.string.share), + getString(R.string.open_in_browser), + getString(R.string.unsubscribe) + ) val actions = DialogInterface.OnClickListener { _, i -> when (i) { - 0 -> ShareUtils.shareText(requireContext(), selectedItem.name, selectedItem.url) - 1 -> deleteChannel(selectedItem) + 0 -> ShareUtils.shareText( + requireContext(), selectedItem.name, selectedItem.url, + selectedItem.thumbnailUrl + ) + 1 -> ShareUtils.openUrlInBrowser(requireContext(), selectedItem.url) + 2 -> deleteChannel(selectedItem) } } @@ -353,7 +346,7 @@ class SubscriptionFragment : BaseStateFragment() { override fun handleResult(result: SubscriptionState) { super.handleResult(result) - val shouldUseGridLayout = shouldUseGridLayout() + val shouldUseGridLayout = shouldUseGridLayout(context) when (result) { is SubscriptionState.LoadedState -> { result.subscriptions.forEach { @@ -414,35 +407,4 @@ class SubscriptionFragment : BaseStateFragment() { super.hideLoading() binding.itemsList.animate(true, 200) } - - // ///////////////////////////////////////////////////////////////////////// - // Grid Mode - // ///////////////////////////////////////////////////////////////////////// - - // TODO: Move these out of this class, as it can be reused - - private fun shouldUseGridLayout(): Boolean { - val listMode = PreferenceManager.getDefaultSharedPreferences(requireContext()) - .getString(getString(R.string.list_view_mode_key), getString(R.string.list_view_mode_value)) - - return when (listMode) { - getString(R.string.list_view_mode_auto_key) -> { - val configuration = resources.configuration - configuration.orientation == Configuration.ORIENTATION_LANDSCAPE && - configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE) - } - getString(R.string.list_view_mode_grid_key) -> true - else -> false - } - } - - private fun getGridSpanCount(): Int { - val minWidth = resources.getDimensionPixelSize(R.dimen.channel_item_grid_min_width) - return max(1, floor(resources.displayMetrics.widthPixels / minWidth.toDouble()).toInt()) - } - - companion object { - private const val REQUEST_EXPORT_CODE = 666 - private const val REQUEST_IMPORT_CODE = 667 - } } 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 f0675da1b..4e667f2b9 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 @@ -12,14 +12,15 @@ import android.widget.Button; import android.widget.EditText; import android.widget.TextView; +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.StringRes; import androidx.appcompat.app.ActionBar; import androidx.core.text.util.LinkifyCompat; -import com.nononsenseapps.filepicker.Utils; - import org.schabi.newpipe.BaseFragment; import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorActivity; @@ -29,8 +30,8 @@ import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor; import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService; +import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.Constants; -import org.schabi.newpipe.util.FilePickerActivityHelper; import org.schabi.newpipe.util.ServiceHelper; import java.util.Collections; @@ -45,8 +46,6 @@ import static org.schabi.newpipe.local.subscription.services.SubscriptionsImport import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE; public class SubscriptionsImportFragment extends BaseFragment { - private static final int REQUEST_IMPORT_FILE_CODE = 666; - @State int currentServiceId = Constants.NO_SERVICE_ID; @@ -64,6 +63,9 @@ public class SubscriptionsImportFragment extends BaseFragment { private EditText inputText; private Button inputButton; + private final ActivityResultLauncher requestImportFileLauncher = + registerForActivityResult(new StartActivityForResult(), this::requestImportFileResult); + public static SubscriptionsImportFragment getInstance(final int serviceId) { final SubscriptionsImportFragment instance = new SubscriptionsImportFragment(); instance.setInitialData(serviceId); @@ -175,23 +177,19 @@ public class SubscriptionsImportFragment extends BaseFragment { } public void onImportFile() { - startActivityForResult(FilePickerActivityHelper.chooseSingleFile(activity), - REQUEST_IMPORT_FILE_CODE); + requestImportFileLauncher.launch(StoredFileHelper.getPicker(activity)); } - @Override - public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { - super.onActivityResult(requestCode, resultCode, data); - if (data == null) { + private void requestImportFileResult(final ActivityResult result) { + if (result.getData() == null) { return; } - if (resultCode == Activity.RESULT_OK && requestCode == REQUEST_IMPORT_FILE_CODE - && data.getData() != null) { - final String path = Utils.getFileForUri(data.getData()).getAbsolutePath(); + if (result.getResultCode() == Activity.RESULT_OK && result.getData().getData() != null) { ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class) - .putExtra(KEY_MODE, INPUT_STREAM_MODE).putExtra(KEY_VALUE, path) + .putExtra(KEY_MODE, INPUT_STREAM_MODE) + .putExtra(KEY_VALUE, result.getData().getData()) .putExtra(Constants.KEY_SERVICE_ID, currentServiceId)); } } 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 5bd13356d..cb0c5fe35 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,6 +1,7 @@ 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 @@ -8,10 +9,12 @@ 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 @@ -123,6 +126,14 @@ 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( @@ -306,7 +317,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable { groupSortOrder = feedGroupEntity?.sortOrder ?: -1 val feedGroupIcon = if (selectedIcon == null) icon else selectedIcon!! - feedGroupCreateBinding.iconPreview.setImageResource(feedGroupIcon.getDrawableRes(requireContext())) + feedGroupCreateBinding.iconPreview.setImageResource(feedGroupIcon.getDrawableRes()) if (feedGroupCreateBinding.groupNameInput.text.isNullOrBlank()) { feedGroupCreateBinding.groupNameInput.setText(name) @@ -375,7 +386,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable { private fun setupIconPicker() { val groupAdapter = GroupAdapter() - groupAdapter.addAll(FeedGroupIcon.values().map { PickerIconItem(requireContext(), it) }) + groupAdapter.addAll(FeedGroupIcon.values().map { PickerIconItem(it) }) feedGroupCreateBinding.iconSelector.apply { layoutManager = GridLayoutManager(requireContext(), 7, RecyclerView.VERTICAL, false) @@ -404,7 +415,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable { if (groupId == NO_GROUP_SELECTED) { val icon = selectedIcon ?: FeedGroupIcon.ALL - feedGroupCreateBinding.iconPreview.setImageResource(icon.getDrawableRes(requireContext())) + feedGroupCreateBinding.iconPreview.setImageResource(icon.getDrawableRes()) } } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt index a9731df8a..7b78b3d95 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupCardItem.kt @@ -25,7 +25,7 @@ data class FeedGroupCardItem( override fun bind(viewBinding: FeedGroupCardItemBinding, position: Int) { viewBinding.title.text = name - viewBinding.icon.setImageResource(icon.getDrawableRes(viewBinding.root.context)) + viewBinding.icon.setImageResource(icon.getDrawableRes()) } override fun initializeViewBinding(view: View) = FeedGroupCardItemBinding.bind(view) diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupReorderItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupReorderItem.kt index 74e481c4f..9a33de54d 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupReorderItem.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/FeedGroupReorderItem.kt @@ -32,7 +32,7 @@ data class FeedGroupReorderItem( override fun bind(viewBinding: FeedGroupReorderItemBinding, position: Int) { viewBinding.groupName.text = name - viewBinding.groupIcon.setImageResource(icon.getDrawableRes(viewBinding.root.context)) + viewBinding.groupIcon.setImageResource(icon.getDrawableRes()) } override fun bind(viewHolder: GroupieViewHolder, position: Int, payloads: MutableList) { 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 index afca7064f..aacfc77ad 100644 --- 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 @@ -86,7 +86,7 @@ class FeedImportExportItem( private fun setupImportFromItems(listHolder: ViewGroup) { val previousBackupItem = addItemView( listHolder.context.getString(R.string.previous_export), - ThemeHelper.resolveResourceIdFromAttr(listHolder.context, R.attr.ic_backup), listHolder + R.drawable.ic_backup, listHolder ) previousBackupItem.setOnClickListener { onImportPreviousSelected() } @@ -115,8 +115,7 @@ class FeedImportExportItem( private fun setupExportToItems(listHolder: ViewGroup) { val previousBackupItem = addItemView( listHolder.context.getString(R.string.file), - ThemeHelper.resolveResourceIdFromAttr(listHolder.context, R.attr.ic_save), - listHolder + R.drawable.ic_save, listHolder ) previousBackupItem.setOnClickListener { onExportSelected() } } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderItem.kt index e04164573..71c1e6116 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderItem.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/HeaderItem.kt @@ -15,7 +15,7 @@ class HeaderItem( override fun bind(viewBinding: HeaderItemBinding, position: Int) { viewBinding.headerTitle.text = title - val listener: OnClickListener? = if (onClickListener != null) OnClickListener { onClickListener.invoke() } else null + val listener = onClickListener?.let { OnClickListener { onClickListener.invoke() } } viewBinding.root.setOnClickListener(listener) } diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt index 11fc4833a..b4232f666 100644 --- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt +++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/PickerIconItem.kt @@ -1,6 +1,5 @@ package org.schabi.newpipe.local.subscription.item -import android.content.Context import android.view.View import androidx.annotation.DrawableRes import com.xwray.groupie.viewbinding.BindableItem @@ -9,11 +8,10 @@ import org.schabi.newpipe.databinding.PickerIconItemBinding import org.schabi.newpipe.local.subscription.FeedGroupIcon class PickerIconItem( - context: Context, val icon: FeedGroupIcon ) : BindableItem() { @DrawableRes - val iconRes: Int = icon.getDrawableRes(context) + val iconRes: Int = icon.getDrawableRes() override fun getLayout(): Int = R.layout.picker_icon_item 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 5dfb1bfe5..063103597 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 @@ -20,7 +20,7 @@ package org.schabi.newpipe.local.subscription.services; import android.content.Intent; -import android.text.TextUtils; +import android.net.Uri; import android.util.Log; import androidx.localbroadcastmanager.content.LocalBroadcastManager; @@ -31,10 +31,11 @@ import org.schabi.newpipe.App; import org.schabi.newpipe.R; import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.extractor.subscription.SubscriptionItem; +import org.schabi.newpipe.streams.io.SharpOutputStream; +import org.schabi.newpipe.streams.io.StoredFileHelper; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; import java.util.ArrayList; import java.util.List; @@ -55,8 +56,8 @@ public class SubscriptionsExportService extends BaseImportExportService { + ".services.SubscriptionsExportService.EXPORT_COMPLETE"; private Subscription subscription; - private File outFile; - private FileOutputStream outputStream; + private StoredFileHelper outFile; + private OutputStream outputStream; @Override public int onStartCommand(final Intent intent, final int flags, final int startId) { @@ -64,18 +65,18 @@ public class SubscriptionsExportService extends BaseImportExportService { return START_NOT_STICKY; } - final String path = intent.getStringExtra(KEY_FILE_PATH); - if (TextUtils.isEmpty(path)) { + final Uri path = intent.getParcelableExtra(KEY_FILE_PATH); + if (path == null) { stopAndReportError(new IllegalStateException( - "Exporting to a file, but the path is empty or null"), + "Exporting to a file, but the path is null"), "Exporting subscriptions"); return START_NOT_STICKY; } try { - outFile = new File(path); - outputStream = new FileOutputStream(outFile); - } catch (final FileNotFoundException e) { + outFile = new StoredFileHelper(this, path, "application/json"); + outputStream = new SharpOutputStream(outFile.getStream()); + } catch (final IOException e) { handleError(e); return START_NOT_STICKY; } @@ -122,8 +123,8 @@ public class SubscriptionsExportService extends BaseImportExportService { .subscribe(getSubscriber()); } - private Subscriber getSubscriber() { - return new Subscriber() { + private Subscriber getSubscriber() { + return new Subscriber() { @Override public void onSubscribe(final Subscription s) { subscription = s; @@ -131,7 +132,7 @@ public class SubscriptionsExportService extends BaseImportExportService { } @Override - public void onNext(final File file) { + public void onNext(final StoredFileHelper file) { if (DEBUG) { Log.d(TAG, "startExport() success: file = " + file); } @@ -153,7 +154,7 @@ public class SubscriptionsExportService extends BaseImportExportService { }; } - private Function, File> exportToFile() { + private Function, StoredFileHelper> exportToFile() { return subscriptionItems -> { ImportExportJsonHelper.writeTo(subscriptionItems, outputStream, eventListener); return outFile; 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 af94934b2..a843ad77c 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 @@ -20,6 +20,7 @@ package org.schabi.newpipe.local.subscription.services; import android.content.Intent; +import android.net.Uri; import android.text.TextUtils; import android.util.Log; @@ -36,12 +37,11 @@ import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.subscription.SubscriptionItem; import org.schabi.newpipe.ktx.ExceptionUtils; +import org.schabi.newpipe.streams.io.SharpInputStream; +import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.ExtractorHelper; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; @@ -55,6 +55,7 @@ import io.reactivex.rxjava3.functions.Function; import io.reactivex.rxjava3.schedulers.Schedulers; import static org.schabi.newpipe.MainActivity.DEBUG; +import static org.schabi.newpipe.streams.io.StoredFileHelper.DEFAULT_MIME; public class SubscriptionsImportService extends BaseImportExportService { public static final int CHANNEL_URL_MODE = 0; @@ -101,17 +102,18 @@ public class SubscriptionsImportService extends BaseImportExportService { if (currentMode == CHANNEL_URL_MODE) { channelUrl = intent.getStringExtra(KEY_VALUE); } else { - final String filePath = intent.getStringExtra(KEY_VALUE); - if (TextUtils.isEmpty(filePath)) { + final Uri uri = intent.getParcelableExtra(KEY_VALUE); + if (uri == null) { stopAndReportError(new IllegalStateException( - "Importing from input stream, but file path is empty or null"), + "Importing from input stream, but file path is null"), "Importing subscriptions"); return START_NOT_STICKY; } try { - inputStream = new FileInputStream(new File(filePath)); - } catch (final FileNotFoundException e) { + inputStream = new SharpInputStream( + new StoredFileHelper(this, uri, DEFAULT_MIME).getStream()); + } catch (final IOException e) { handleError(e); return START_NOT_STICKY; } diff --git a/app/src/main/java/org/schabi/newpipe/player/MainPlayer.java b/app/src/main/java/org/schabi/newpipe/player/MainPlayer.java index 945bc9a04..7a04ec22e 100644 --- a/app/src/main/java/org/schabi/newpipe/player/MainPlayer.java +++ b/app/src/main/java/org/schabi/newpipe/player/MainPlayer.java @@ -178,7 +178,10 @@ public final class MainPlayer extends Service { 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()) { @@ -191,9 +194,14 @@ public final class MainPlayer extends Service { player.stopActivityBinding(); player.removePopupFromView(); player.destroy(); - } + player = null; + } + } + + public void stopService() { NotificationUtil.getInstance().cancelNotificationAndStopForeground(this); + cleanup(); stopSelf(); } diff --git a/app/src/main/java/org/schabi/newpipe/player/NotificationConstants.java b/app/src/main/java/org/schabi/newpipe/player/NotificationConstants.java index cf58c8f76..6c9858d1b 100644 --- a/app/src/main/java/org/schabi/newpipe/player/NotificationConstants.java +++ b/app/src/main/java/org/schabi/newpipe/player/NotificationConstants.java @@ -50,11 +50,11 @@ public final class NotificationConstants { R.drawable.exo_icon_fastforward, R.drawable.exo_icon_previous, R.drawable.exo_icon_next, - R.drawable.ic_pause_white_24dp, - R.drawable.ic_hourglass_top_white_24dp, + R.drawable.ic_pause, + R.drawable.ic_hourglass_top, R.drawable.exo_icon_repeat_all, R.drawable.exo_icon_shuffle_on, - R.drawable.ic_close_white_24dp, + R.drawable.ic_close, }; diff --git a/app/src/main/java/org/schabi/newpipe/player/NotificationUtil.java b/app/src/main/java/org/schabi/newpipe/player/NotificationUtil.java index 43c1b4405..948343be2 100644 --- a/app/src/main/java/org/schabi/newpipe/player/NotificationUtil.java +++ b/app/src/main/java/org/schabi/newpipe/player/NotificationUtil.java @@ -273,14 +273,14 @@ public final class NotificationUtil { || 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_white_24dp_png, + 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_white_24dp_png, + 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 @@ -315,7 +315,7 @@ public final class NotificationUtil { } case NotificationConstants.CLOSE: - return getAction(player, R.drawable.ic_close_white_24dp_png, + return getAction(player, R.drawable.ic_close, R.string.close, ACTION_CLOSE); case NotificationConstants.NOTHING: 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 d757a9268..13b66af80 100644 --- a/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java +++ b/app/src/main/java/org/schabi/newpipe/player/PlayQueueActivity.java @@ -15,6 +15,7 @@ import android.view.ViewGroup; import android.widget.PopupMenu; import android.widget.SeekBar; +import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.LinearLayoutManager; @@ -46,7 +47,7 @@ import java.util.List; import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; -import static org.schabi.newpipe.util.ShareUtils.shareText; +import static org.schabi.newpipe.util.external_communication.ShareUtils.shareText; public final class PlayQueueActivity extends AppCompatActivity implements PlayerEventListener, SeekBar.OnSeekBarChangeListener, @@ -312,7 +313,8 @@ public final class PlayQueueActivity extends AppCompatActivity final MenuItem share = popupMenu.getMenu().add(RECYCLER_ITEM_POPUP_MENU_GROUP_ID, 3, Menu.NONE, R.string.share); share.setOnMenuItemClickListener(menuItem -> { - shareText(getApplicationContext(), item.getTitle(), item.getUrl()); + shareText(getApplicationContext(), item.getTitle(), item.getUrl(), + item.getThumbnailUrl()); return true; }); @@ -456,6 +458,7 @@ public final class PlayQueueActivity extends AppCompatActivity final boolean playbackSkipSilence) { if (player != null) { player.setPlaybackParameters(playbackTempo, playbackPitch, playbackSkipSilence); + onPlaybackParameterChanged(player.getPlaybackParameters()); } } @@ -589,15 +592,15 @@ public final class PlayQueueActivity extends AppCompatActivity switch (state) { case Player.STATE_PAUSED: queueControlBinding.controlPlayPause - .setImageResource(R.drawable.ic_play_arrow_white_24dp); + .setImageResource(R.drawable.ic_play_arrow); break; case Player.STATE_PLAYING: queueControlBinding.controlPlayPause - .setImageResource(R.drawable.ic_pause_white_24dp); + .setImageResource(R.drawable.ic_pause); break; case Player.STATE_COMPLETED: queueControlBinding.controlPlayPause - .setImageResource(R.drawable.ic_replay_white_24dp); + .setImageResource(R.drawable.ic_replay); break; default: break; @@ -639,7 +642,7 @@ public final class PlayQueueActivity extends AppCompatActivity queueControlBinding.controlShuffle.setImageAlpha(shuffleAlpha); } - private void onPlaybackParameterChanged(final PlaybackParameters parameters) { + 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); @@ -670,8 +673,7 @@ public final class PlayQueueActivity extends AppCompatActivity //2) Icon change accordingly to current App Theme // using rootView.getContext() because getApplicationContext() didn't work final Context context = queueControlBinding.getRoot().getContext(); - item.setIcon(ThemeHelper.resolveResourceIdFromAttr(context, - player.isMuted() ? R.attr.ic_volume_off : R.attr.ic_volume_up)); + item.setIcon(player.isMuted() ? R.drawable.ic_volume_off : R.drawable.ic_volume_up); } } } 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 f8e0732b3..d8d8ac14b 100644 --- a/app/src/main/java/org/schabi/newpipe/player/Player.java +++ b/app/src/main/java/org/schabi/newpipe/player/Player.java @@ -26,12 +26,14 @@ import android.provider.Settings; import android.util.DisplayMetrics; import android.util.Log; import android.util.TypedValue; -import android.view.GestureDetector; +import android.view.ContextThemeWrapper; +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; @@ -51,9 +53,12 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.content.res.AppCompatResources; +import androidx.appcompat.widget.AppCompatImageButton; import androidx.core.content.ContextCompat; -import androidx.core.view.DisplayCutoutCompat; +import androidx.core.graphics.Insets; +import androidx.core.view.GestureDetectorCompat; import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.RecyclerView; @@ -74,6 +79,7 @@ import com.google.android.exoplayer2.trackselection.TrackSelectionArray; import com.google.android.exoplayer2.ui.AspectRatioFrameLayout; 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.VideoListener; import com.google.android.material.floatingactionbutton.FloatingActionButton; import com.nostra13.universalimageloader.core.ImageLoader; @@ -88,6 +94,7 @@ import org.schabi.newpipe.databinding.PlayerPopupCloseOverlayBinding; import org.schabi.newpipe.extractor.MediaFormat; 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; @@ -108,6 +115,7 @@ import org.schabi.newpipe.player.playback.CustomTrackSelector; 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; @@ -117,13 +125,15 @@ import org.schabi.newpipe.player.playqueue.PlayQueueItemTouchCallback; import org.schabi.newpipe.player.resolver.AudioPlaybackResolver; import org.schabi.newpipe.player.resolver.MediaSourceTag; import org.schabi.newpipe.player.resolver.VideoPlaybackResolver; +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.ImageDisplayConstants; -import org.schabi.newpipe.util.KoreUtil; import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.SerializedCache; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.KoreUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.views.ExpandableSurfaceView; import java.io.IOException; @@ -263,6 +273,7 @@ public final class Player implements private SimpleExoPlayer simpleExoPlayer; private AudioReactor audioReactor; private MediaSessionManager mediaSessionManager; + @Nullable private SurfaceHolderCallback surfaceHolderCallback; @NonNull private final CustomTrackSelector trackSelector; @NonNull private final LoadController loadController; @@ -349,7 +360,7 @@ public final class Player implements private static final float MAX_GESTURE_LENGTH = 0.75f; private int maxGestureLength; // scaled - private GestureDetector gestureDetector; + private GestureDetectorCompat gestureDetector; /*////////////////////////////////////////////////////////////////////////// // Listeners and disposables @@ -372,12 +383,14 @@ public final class Player implements @NonNull private final SharedPreferences prefs; @NonNull private final HistoryRecordManager recordManager; + @NonNull private final SeekbarPreviewThumbnailHolder seekbarPreviewThumbnailHolder = + new SeekbarPreviewThumbnailHolder(); /*////////////////////////////////////////////////////////////////////////// // Constructor //////////////////////////////////////////////////////////////////////////*/ - //region + //region Constructor public Player(@NonNull final MainPlayer service) { this.service = service; @@ -424,7 +437,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Setup and initialization //////////////////////////////////////////////////////////////////////////*/ - //region + //region Setup and initialization public void setupFromView(@NonNull final PlayerBinding playerBinding) { initViews(playerBinding); @@ -446,9 +459,12 @@ public final class Player implements binding.playbackSeekBar.getProgressDrawable() .setColorFilter(new PorterDuffColorFilter(Color.RED, PorterDuff.Mode.MULTIPLY)); - qualityPopupMenu = new PopupMenu(context, binding.qualityTextView); + final ContextThemeWrapper themeWrapper = new ContextThemeWrapper(getContext(), + R.style.DarkPopupMenu); + + qualityPopupMenu = new PopupMenu(themeWrapper, binding.qualityTextView); playbackSpeedPopupMenu = new PopupMenu(context, binding.playbackSpeed); - captionPopupMenu = new PopupMenu(context, binding.captionTextView); + captionPopupMenu = new PopupMenu(themeWrapper, binding.captionTextView); binding.progressBarLoadingPanel.getIndeterminateDrawable() .setColorFilter(new PorterDuffColorFilter(Color.WHITE, PorterDuff.Mode.MULTIPLY)); @@ -482,16 +498,22 @@ public final class Player implements registerBroadcastReceiver(); // Setup video view - simpleExoPlayer.setVideoSurfaceView(binding.surfaceView); + setupVideoSurface(); simpleExoPlayer.addVideoListener(this); // Setup subtitle view simpleExoPlayer.addTextOutput(binding.subtitleView); - // Setup audio session with onboard equalizer - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + // 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() .setTunnelingAudioSessionId(C.generateAudioSessionIdV21(context))); + } else if (DEBUG) { + Log.d(TAG, "[" + Util.DEVICE_DEBUG_INFO + "] does not support media tunneling"); } } @@ -504,7 +526,7 @@ public final class Player implements binding.playbackLiveSync.setOnClickListener(this); final PlayerGestureListener listener = new PlayerGestureListener(this, service); - gestureDetector = new GestureDetector(context, listener); + gestureDetector = new GestureDetectorCompat(context, listener); binding.getRoot().setOnTouchListener(listener); binding.queueButton.setOnClickListener(this); @@ -519,6 +541,7 @@ public final class Player implements 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); @@ -538,10 +561,9 @@ public final class Player implements binding.getRoot().addOnLayoutChangeListener(this::onLayoutChange); ViewCompat.setOnApplyWindowInsetsListener(binding.itemsListPanel, (view, windowInsets) -> { - final DisplayCutoutCompat cutout = windowInsets.getDisplayCutout(); - if (cutout != null) { - view.setPadding(cutout.getSafeInsetLeft(), cutout.getSafeInsetTop(), - cutout.getSafeInsetRight(), cutout.getSafeInsetBottom()); + 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; }); @@ -563,7 +585,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Playback initialization via intent //////////////////////////////////////////////////////////////////////////*/ - //region + //region Playback initialization via intent public void handleIntent(@NonNull final Intent intent) { // fail fast if no play queue was provided @@ -624,10 +646,10 @@ public final class Player implements && newQueue.getItem().getUrl().equals(playQueue.getItem().getUrl()) && newQueue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) { // Player can have state = IDLE when playback is stopped or failed - // and we should retry() in this case + // and we should retry in this case if (simpleExoPlayer.getPlaybackState() == com.google.android.exoplayer2.Player.STATE_IDLE) { - simpleExoPlayer.retry(); + simpleExoPlayer.prepare(); } simpleExoPlayer.seekTo(playQueue.getIndex(), newQueue.getItem().getRecoveryPosition()); simpleExoPlayer.setPlayWhenReady(playWhenReady); @@ -638,10 +660,10 @@ public final class Player implements && !playQueue.isDisposed()) { // Do not re-init the same PlayQueue. Save time // Player can have state = IDLE when playback is stopped or failed - // and we should retry() in this case + // and we should retry in this case if (simpleExoPlayer.getPlaybackState() == com.google.android.exoplayer2.Player.STATE_IDLE) { - simpleExoPlayer.retry(); + simpleExoPlayer.prepare(); } simpleExoPlayer.setPlayWhenReady(playWhenReady); @@ -657,7 +679,11 @@ public final class Player implements //.doFinally() .subscribe( state -> { - newQueue.setRecovery(newQueue.getIndex(), state.getProgressTime()); + if (!state.isFinished(newQueue.getItem().getDuration())) { + // resume playback only if the stream was not played to the end + newQueue.setRecovery(newQueue.getIndex(), + state.getProgressMillis()); + } initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch, playbackSkipSilence, playWhenReady, isMuted); }, @@ -706,7 +732,8 @@ public final class Player implements // Android TV: without it focus will frame the whole player binding.playPauseButton.requestFocus(); - if (simpleExoPlayer.getPlayWhenReady()) { + // Note: This is for automatically playing (when "Resume playback" is off), see #6179 + if (getPlayWhenReady()) { play(); } else { pause(); @@ -747,14 +774,18 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Destroy and recovery //////////////////////////////////////////////////////////////////////////*/ - //region + //region Destroy and recovery private void destroyPlayer() { if (DEBUG) { Log.d(TAG, "destroyPlayer() called"); } + + cleanupVideoSurface(); + if (!exoPlayerIsNull()) { simpleExoPlayer.removeListener(this); + simpleExoPlayer.removeVideoListener(this); simpleExoPlayer.stop(); simpleExoPlayer.release(); } @@ -838,7 +869,7 @@ public final class Player implements Log.d(TAG, "onPlaybackShutdown() called"); } // destroys the service, which in turn will destroy the player - service.onDestroy(); + service.stopService(); } public void smoothStopPlayer() { @@ -852,7 +883,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Player type specific setup //////////////////////////////////////////////////////////////////////////*/ - //region + //region Player type specific setup private void initVideoPlayer() { // restore last resize mode @@ -914,7 +945,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Elements visibility and size: popup and main players have different look //////////////////////////////////////////////////////////////////////////*/ - //region + //region Elements visibility and size: popup and main players have different look /** * This method ensures that popup and main players have different look. @@ -957,7 +988,7 @@ public final class Player implements = LinearLayout.LayoutParams.MATCH_PARENT; binding.secondaryControls.setVisibility(View.INVISIBLE); binding.moreOptionsButton.setImageDrawable(AppCompatResources.getDrawable(context, - R.drawable.ic_expand_more_white_24dp)); + R.drawable.ic_expand_more)); binding.share.setVisibility(View.VISIBLE); binding.openInBrowser.setVisibility(View.VISIBLE); binding.switchMute.setVisibility(View.VISIBLE); @@ -1018,7 +1049,7 @@ public final class Player implements // show kodi button if it supports the current service and it is enabled in settings binding.playWithKodi.setVisibility(videoPlayerSelected() && playQueue != null && playQueue.getItem() != null - && KoreUtil.shouldShowPlayWithKodi(context, playQueue.getItem().getServiceId()) + && KoreUtils.shouldShowPlayWithKodi(context, playQueue.getItem().getServiceId()) ? View.VISIBLE : View.GONE); } //endregion @@ -1028,7 +1059,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Broadcast receiver //////////////////////////////////////////////////////////////////////////*/ - //region + //region Broadcast receiver private void setupBroadcastReceiver() { if (DEBUG) { @@ -1078,7 +1109,7 @@ public final class Player implements pause(); break; case ACTION_CLOSE: - service.onDestroy(); + service.stopService(); break; case ACTION_PLAY_PAUSE: playPause(); @@ -1180,7 +1211,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Thumbnail loading //////////////////////////////////////////////////////////////////////////*/ - //region + //region Thumbnail loading private void initThumbnail(final String url) { if (DEBUG) { @@ -1327,7 +1358,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Popup player utils //////////////////////////////////////////////////////////////////////////*/ - //region + //region Popup player utils /** * Check if {@link #popupLayoutParams}' position is within a arbitrary boundary @@ -1479,7 +1510,7 @@ public final class Player implements Objects.requireNonNull(windowManager) .removeView(closeOverlayBinding.getRoot()); closeOverlayBinding = null; - service.onDestroy(); + service.stopService(); } }).start(); } @@ -1502,7 +1533,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Playback parameters //////////////////////////////////////////////////////////////////////////*/ - //region + //region Playback parameters public float getPlaybackSpeed() { return getPlaybackParameters().speed; @@ -1555,7 +1586,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Progress loop and updates //////////////////////////////////////////////////////////////////////////*/ - //region + //region Progress loop and updates private void onUpdateProgress(final int currentProgress, final int duration, @@ -1592,6 +1623,10 @@ public final class Player implements segmentAdapter.selectSegmentAt(getNearestStreamSegmentPosition(currentProgress)); } + if (isQueueVisible) { + updateQueueTime(currentProgress); + } + final boolean showThumbnail = prefs.getBoolean( context.getString(R.string.show_thumbnail_key), true); // setMetadata only updates the metadata when any of the metadata keys are null @@ -1615,9 +1650,22 @@ public final class Player implements 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 + && currentItem.getStreamType() != StreamType.AUDIO_LIVE_STREAM + && currentItem.getStreamType() != StreamType.LIVE_STREAM) { + // convert seconds to milliseconds + duration = (int) (currentItem.getDuration() * 1000); + } else { + duration = (int) simpleExoPlayer.getDuration(); + } onUpdateProgress( Math.max((int) simpleExoPlayer.getCurrentPosition(), 0), - (int) simpleExoPlayer.getDuration(), + duration, simpleExoPlayer.getBufferedPercentage() ); } @@ -1633,12 +1681,67 @@ public final class Player implements @Override // seekbar listener public void onProgressChanged(final SeekBar seekBar, final int progress, final boolean fromUser) { - if (DEBUG && 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 + "]"); } - if (fromUser) { - binding.currentDisplaySeek.setText(getTimeString(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); } } @@ -1653,12 +1756,14 @@ public final class Player implements saveWasPlaying(); if (isPlaying()) { - simpleExoPlayer.setPlayWhenReady(false); + 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 @@ -1669,11 +1774,12 @@ public final class Player implements seekTo(seekBar.getProgress()); if (wasPlaying || simpleExoPlayer.getDuration() == seekBar.getProgress()) { - simpleExoPlayer.setPlayWhenReady(true); + 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); @@ -1687,7 +1793,7 @@ public final class Player implements } public void saveWasPlaying() { - this.wasPlaying = simpleExoPlayer.getPlayWhenReady(); + this.wasPlaying = getPlayWhenReady(); } //endregion @@ -1696,7 +1802,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Controls showing / hiding //////////////////////////////////////////////////////////////////////////*/ - //region + //region Controls showing / hiding public boolean isControlsVisible() { return binding != null && binding.playbackControlRoot.getVisibility() == View.VISIBLE; @@ -1866,7 +1972,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Playback states //////////////////////////////////////////////////////////////////////////*/ - //region + //region Playback states @Override // exoplayer listener public void onPlayerStateChanged(final boolean playWhenReady, final int playbackState) { @@ -1903,16 +2009,14 @@ public final class Player implements break; case com.google.android.exoplayer2.Player.STATE_ENDED: // 4 changeState(STATE_COMPLETED); - if (currentMetadata != null) { - resetStreamProgressState(currentMetadata.getMetadata()); - } + saveStreamProgressStateCompleted(); isPrepared = false; break; } } @Override // exoplayer listener - public void onLoadingChanged(final boolean isLoading) { + public void onIsLoadingChanged(final boolean isLoading) { if (DEBUG) { Log.d(TAG, "ExoPlayer - onLoadingChanged() called with: " + "isLoading = [" + isLoading + "]"); @@ -1956,7 +2060,8 @@ public final class Player implements if (currentState == STATE_BLOCKED) { changeState(STATE_BUFFERING); } - simpleExoPlayer.prepare(mediaSource); + simpleExoPlayer.setMediaSource(mediaSource); + simpleExoPlayer.prepare(); } public void changeState(final int state) { @@ -2020,7 +2125,7 @@ public final class Player implements animate(binding.loadingPanel, true, 0); animate(binding.surfaceForeground, true, 100); - binding.playPauseButton.setImageResource(R.drawable.ic_play_arrow_white_24dp); + binding.playPauseButton.setImageResource(R.drawable.ic_play_arrow); animatePlayButtons(false, 100); binding.getRoot().setKeepScreenOn(false); @@ -2049,7 +2154,7 @@ public final class Player implements animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0, () -> { - binding.playPauseButton.setImageResource(R.drawable.ic_pause_white_24dp); + binding.playPauseButton.setImageResource(R.drawable.ic_pause); animatePlayButtons(true, 200); if (!isQueueVisible) { binding.playPauseButton.requestFocus(); @@ -2068,6 +2173,7 @@ public final class Player implements Log.d(TAG, "onBuffering() called"); } binding.loadingPanel.setBackgroundColor(Color.TRANSPARENT); + binding.loadingPanel.setVisibility(View.VISIBLE); binding.getRoot().setKeepScreenOn(true); @@ -2090,7 +2196,7 @@ public final class Player implements animate(binding.playPauseButton, false, 80, AnimationType.SCALE_AND_ALPHA, 0, () -> { - binding.playPauseButton.setImageResource(R.drawable.ic_play_arrow_white_24dp); + binding.playPauseButton.setImageResource(R.drawable.ic_play_arrow); animatePlayButtons(true, 200); if (!isQueueVisible) { binding.playPauseButton.requestFocus(); @@ -2124,12 +2230,15 @@ public final class Player implements private void onCompleted() { if (DEBUG) { - Log.d(TAG, "onCompleted() called"); + Log.d(TAG, "onCompleted() called" + (playQueue == null ? ". playQueue is null" : "")); + } + if (playQueue == null) { + return; } animate(binding.playPauseButton, false, 0, AnimationType.SCALE_AND_ALPHA, 0, () -> { - binding.playPauseButton.setImageResource(R.drawable.ic_replay_white_24dp); + binding.playPauseButton.setImageResource(R.drawable.ic_replay); animatePlayButtons(true, DEFAULT_CONTROLS_DURATION); }); @@ -2184,7 +2293,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Repeat and shuffle //////////////////////////////////////////////////////////////////////////*/ - //region + //region Repeat and shuffle public void onRepeatClicked() { if (DEBUG) { @@ -2221,7 +2330,7 @@ public final class Player implements Log.d(TAG, "ExoPlayer - onRepeatModeChanged() called with: " + "repeatMode = [" + repeatMode + "]"); } - setRepeatModeButton(binding.repeatButton, repeatMode); + setRepeatModeButton(((AppCompatImageButton) binding.repeatButton), repeatMode); onShuffleOrRepeatModeChanged(); } @@ -2249,7 +2358,7 @@ public final class Player implements NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); } - private void setRepeatModeButton(final ImageButton imageButton, final int repeatMode) { + private void setRepeatModeButton(final AppCompatImageButton imageButton, final int repeatMode) { switch (repeatMode) { case REPEAT_MODE_OFF: imageButton.setImageResource(R.drawable.exo_controls_repeat_off); @@ -2273,7 +2382,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Mute / Unmute //////////////////////////////////////////////////////////////////////////*/ - //region + //region Mute / Unmute public void onMuteUnmuteButtonClicked() { if (DEBUG) { @@ -2290,7 +2399,7 @@ public final class Player implements private void setMuteButton(final ImageButton button, final boolean isMuted) { button.setImageDrawable(AppCompatResources.getDrawable(context, isMuted - ? R.drawable.ic_volume_off_white_24dp : R.drawable.ic_volume_up_white_24dp)); + ? R.drawable.ic_volume_off : R.drawable.ic_volume_up)); } //endregion @@ -2299,7 +2408,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // ExoPlayer listeners (that didn't fit in other categories) //////////////////////////////////////////////////////////////////////////*/ - //region + //region ExoPlayer listeners (that didn't fit in other categories) @Override public void onTimelineChanged(@NonNull final Timeline timeline, final int reason) { @@ -2355,10 +2464,16 @@ public final class Player implements break; } case DISCONTINUITY_REASON_SEEK: + if (DEBUG) { + Log.d(TAG, "ExoPlayer - onSeekProcessed() called"); + } + if (isPrepared) { + saveStreamProgressState(); + } case DISCONTINUITY_REASON_SEEK_ADJUSTMENT: case DISCONTINUITY_REASON_INTERNAL: if (playQueue.getIndex() != newWindowIndex) { - resetStreamProgressState(playQueue.getItem()); + saveStreamProgressStateCompleted(); // current stream has ended playQueue.setIndex(newWindowIndex); } break; @@ -2381,7 +2496,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Errors //////////////////////////////////////////////////////////////////////////*/ - //region + //region Errors /** * Process exceptions produced by {@link com.google.android.exoplayer2.ExoPlayer ExoPlayer}. *

There are multiple types of errors:

@@ -2419,10 +2534,8 @@ public final class Player implements setRecovery(); reloadPlayQueueManager(); break; - case ExoPlaybackException.TYPE_OUT_OF_MEMORY: case ExoPlaybackException.TYPE_REMOTE: case ExoPlaybackException.TYPE_RENDERER: - case ExoPlaybackException.TYPE_TIMEOUT: default: showUnrecoverableError(error); onPlaybackShutdown(); @@ -2484,7 +2597,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Playback position and seek //////////////////////////////////////////////////////////////////////////*/ - //region + //region Playback position and seek @Override // own playback listener (this is a getter) public boolean isApproachingPlaybackEdge(final long timeToEndMillis) { @@ -2627,16 +2740,6 @@ public final class Player implements simpleExoPlayer.seekToDefaultPosition(); } } - - @Override // exoplayer override - public void onSeekProcessed() { - if (DEBUG) { - Log.d(TAG, "ExoPlayer - onSeekProcessed() called"); - } - if (isPrepared) { - saveStreamProgressState(); - } - } //endregion @@ -2644,7 +2747,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Player actions (play, pause, previous, fast-forward, ...) //////////////////////////////////////////////////////////////////////////*/ - //region + //region Player actions (play, pause, previous, fast-forward, ...) public void play() { if (DEBUG) { @@ -2664,7 +2767,7 @@ public final class Player implements } } - simpleExoPlayer.setPlayWhenReady(true); + simpleExoPlayer.play(); saveStreamProgressState(); } @@ -2677,7 +2780,7 @@ public final class Player implements } audioReactor.abandonAudioFocus(); - simpleExoPlayer.setPlayWhenReady(false); + simpleExoPlayer.pause(); saveStreamProgressState(); } @@ -2686,7 +2789,7 @@ public final class Player implements Log.d(TAG, "onPlayPause() called"); } - if (isPlaying()) { + if (getPlayWhenReady()) { pause(); } else { play(); @@ -2734,7 +2837,7 @@ public final class Player implements } seekBy(retrieveSeekDurationFromPreferences(this)); triggerProgressUpdate(); - showAndAnimateControl(R.drawable.ic_fast_forward_white_24dp, true); + showAndAnimateControl(R.drawable.ic_fast_forward, true); } public void fastRewind() { @@ -2743,7 +2846,7 @@ public final class Player implements } seekBy(-retrieveSeekDurationFromPreferences(this)); triggerProgressUpdate(); - showAndAnimateControl(R.drawable.ic_fast_rewind_white_24dp, true); + showAndAnimateControl(R.drawable.ic_fast_rewind, true); } //endregion @@ -2752,7 +2855,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // StreamInfo history: views and progress //////////////////////////////////////////////////////////////////////////*/ - //region + //region StreamInfo history: views and progress private void registerStreamViewed() { if (currentMetadata != null) { @@ -2761,61 +2864,47 @@ public final class Player implements } } - private void saveStreamProgressState(final StreamInfo info, final long progress) { - if (info == null) { + private void saveStreamProgressState(final long progressMillis) { + if (currentMetadata == null + || !prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) { return; } if (DEBUG) { - Log.d(TAG, "saveStreamProgressState() called"); + Log.d(TAG, "saveStreamProgressState() called with: progressMillis=" + progressMillis + + ", currentMetadata=[" + currentMetadata.getMetadata().getName() + "]"); } - if (prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) { - final Disposable stateSaver = recordManager.saveStreamState(info, progress) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError((e) -> { - if (DEBUG) { - e.printStackTrace(); - } - }) - .onErrorComplete() - .subscribe(); - databaseUpdateDisposable.add(stateSaver); - } - } - private void resetStreamProgressState(final PlayQueueItem queueItem) { - if (queueItem == null) { - return; - } - if (prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) { - final Disposable stateSaver = queueItem.getStream() - .flatMapCompletable(info -> recordManager.saveStreamState(info, 0)) - .observeOn(AndroidSchedulers.mainThread()) - .doOnError((e) -> { - if (DEBUG) { - e.printStackTrace(); - } - }) - .onErrorComplete() - .subscribe(); - databaseUpdateDisposable.add(stateSaver); - } - } - - private void resetStreamProgressState(final StreamInfo info) { - saveStreamProgressState(info, 0); + databaseUpdateDisposable + .add(recordManager.saveStreamState(currentMetadata.getMetadata(), progressMillis) + .observeOn(AndroidSchedulers.mainThread()) + .doOnError((e) -> { + if (DEBUG) { + e.printStackTrace(); + } + }) + .onErrorComplete() + .subscribe()); } public void saveStreamProgressState() { - if (exoPlayerIsNull() || currentMetadata == null) { + if (exoPlayerIsNull() || currentMetadata == null || playQueue == null + || playQueue.getIndex() != simpleExoPlayer.getCurrentWindowIndex()) { + // Make sure play queue and current window index are equal, to prevent saving state for + // the wrong stream on discontinuity (e.g. when the stream just changed but the + // playQueue index and currentMetadata still haven't updated) return; } - final StreamInfo currentInfo = currentMetadata.getMetadata(); - if (playQueue != null) { - // Save current position. It will help to restore this position once a user - // wants to play prev or next stream from the queue - playQueue.setRecovery(playQueue.getIndex(), simpleExoPlayer.getContentPosition()); + // Save current position. It will help to restore this position once a user + // wants to play prev or next stream from the queue + playQueue.setRecovery(playQueue.getIndex(), simpleExoPlayer.getContentPosition()); + saveStreamProgressState(simpleExoPlayer.getCurrentPosition()); + } + + public void saveStreamProgressStateCompleted() { + if (currentMetadata != null) { + // current stream has ended, so the progress is its duration (+1 to overcome rounding) + saveStreamProgressState((currentMetadata.getMetadata().getDuration() + 1) * 1000); } - saveStreamProgressState(currentInfo, simpleExoPlayer.getCurrentPosition()); } //endregion @@ -2824,7 +2913,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Metadata //////////////////////////////////////////////////////////////////////////*/ - //region + //region Metadata private void onMetadataChanged(@NonNull final MediaSourceTag tag) { final StreamInfo info = tag.getMetadata(); @@ -2840,6 +2929,10 @@ public final class Player implements binding.titleTextView.setText(tag.getMetadata().getName()); binding.channelTextView.setText(tag.getMetadata().getUploaderName()); + this.seekbarPreviewThumbnailHolder.resetFrom( + this.getContext(), + tag.getMetadata().getPreviewFrames()); + NotificationUtil.getInstance().createNotificationIfNeededAndUpdate(this, false); notifyMetadataUpdateToListeners(); @@ -2890,6 +2983,18 @@ public final class Player implements : currentMetadata.getMetadata().getUrl(); } + @NonNull + private String getVideoUrlAtCurrentTime() { + final int timeSeconds = binding.playbackSeekBar.getProgress() / 1000; + String videoUrl = getVideoUrl(); + if (!isLive() && timeSeconds >= 0 && currentMetadata != null + && currentMetadata.getMetadata().getServiceId() == YouTube.getServiceId()) { + // Timestamp doesn't make sense in a live stream so drop it + videoUrl += ("&t=" + timeSeconds); + } + return videoUrl; + } + @NonNull public String getVideoTitle() { return currentMetadata == null @@ -2917,7 +3022,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Play queue, segments and streams //////////////////////////////////////////////////////////////////////////*/ - //region + //region Play queue, segments and streams private void maybeAutoQueueNextStream(@NonNull final MediaSourceTag metadata) { if (playQueue == null || playQueue.getIndex() != playQueue.size() - 1 @@ -2965,6 +3070,7 @@ public final class Player implements buildQueue(); binding.itemsListHeaderTitle.setVisibility(View.GONE); + binding.itemsListHeaderDuration.setVisibility(View.VISIBLE); binding.shuffleButton.setVisibility(View.VISIBLE); binding.repeatButton.setVisibility(View.VISIBLE); @@ -2974,6 +3080,8 @@ public final class Player implements AnimationType.SLIDE_AND_ALPHA); binding.itemsList.scrollToPosition(playQueue.getIndex()); + + updateQueueTime((int) simpleExoPlayer.getCurrentPosition()); } private void buildQueue() { @@ -2999,6 +3107,7 @@ public final class Player implements buildSegments(); binding.itemsListHeaderTitle.setVisibility(View.VISIBLE); + binding.itemsListHeaderDuration.setVisibility(View.GONE); binding.shuffleButton.setVisibility(View.GONE); binding.repeatButton.setVisibility(View.GONE); @@ -3196,6 +3305,32 @@ public final class Player implements 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 @@ -3203,7 +3338,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Popup menus ("popup" means that they pop up, not that they belong to the popup player) //////////////////////////////////////////////////////////////////////////*/ - //region + //region Popup menus ("popup" means that they pop up, not that they belong to the popup player) private void buildQualityMenu() { if (qualityPopupMenu == null) { @@ -3406,7 +3541,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Captions (text tracks) //////////////////////////////////////////////////////////////////////////*/ - //region + //region Captions (text tracks) private void setupSubtitleView() { final float captionScale = PlayerHelper.getCaptionScale(context); @@ -3485,7 +3620,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Click listeners //////////////////////////////////////////////////////////////////////////*/ - //region + //region Click listeners @Override public void onClick(final View v) { @@ -3523,7 +3658,8 @@ public final class Player implements } else if (v.getId() == binding.moreOptionsButton.getId()) { onMoreOptionsClicked(); } else if (v.getId() == binding.share.getId()) { - onShareClicked(); + ShareUtils.shareText(context, getVideoTitle(), getVideoUrlAtCurrentTime(), + currentItem.getThumbnailUrl()); } else if (v.getId() == binding.playWithKodi.getId()) { onPlayWithKodiClicked(); } else if (v.getId() == binding.openInBrowser.getId()) { @@ -3572,6 +3708,8 @@ public final class Player implements fragmentListener.onMoreOptionsLongClicked(); hideControls(0, 0); hideSystemUIIfNeeded(); + } else if (v.getId() == binding.share.getId()) { + ShareUtils.copyToClipboard(context, getVideoUrlAtCurrentTime()); } return true; } @@ -3643,19 +3781,6 @@ public final class Player implements showControls(DEFAULT_CONTROLS_DURATION); } - private void onShareClicked() { - // share video at the current time (youtube.com/watch?v=ID&t=SECONDS) - // Timestamp doesn't make sense in a live stream so drop it - - final int ts = binding.playbackSeekBar.getProgress() / 1000; - String videoUrl = getVideoUrl(); - if (!isLive() && ts >= 0 && currentMetadata != null - && currentMetadata.getMetadata().getServiceId() == YouTube.getServiceId()) { - videoUrl += ("&t=" + ts); - } - ShareUtils.shareText(context, getVideoTitle(), videoUrl); - } - private void onPlayWithKodiClicked() { if (currentMetadata != null) { pause(); @@ -3665,7 +3790,7 @@ public final class Player implements if (DEBUG) { Log.i(TAG, "Failed to start kore", e); } - KoreUtil.showInstallKoreDialog(getParentActivity()); + KoreUtils.showInstallKoreDialog(getParentActivity()); } } } @@ -3683,7 +3808,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Video size, resize, orientation, fullscreen //////////////////////////////////////////////////////////////////////////*/ - //region + //region Video size, resize, orientation, fullscreen private void setupScreenRotationButton() { binding.screenRotationButton.setVisibility(videoPlayerSelected() @@ -3691,8 +3816,8 @@ public final class Player implements || DeviceUtils.isTablet(context)) ? View.VISIBLE : View.GONE); binding.screenRotationButton.setImageDrawable(AppCompatResources.getDrawable(context, - isFullscreen ? R.drawable.ic_fullscreen_exit_white_24dp - : R.drawable.ic_fullscreen_white_24dp)); + isFullscreen ? R.drawable.ic_fullscreen_exit + : R.drawable.ic_fullscreen)); } private void setResizeMode(@AspectRatioFrameLayout.ResizeMode final int resizeMode) { @@ -3790,7 +3915,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Gestures //////////////////////////////////////////////////////////////////////////*/ - //region + //region Gestures @SuppressWarnings("checkstyle:ParameterNumber") private void onLayoutChange(final View view, final int l, final int t, final int r, final int b, @@ -3854,7 +3979,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Activity / fragment binding //////////////////////////////////////////////////////////////////////////*/ - //region + //region Activity / fragment binding public void setFragmentListener(final PlayerServiceEventListener listener) { fragmentListener = listener; @@ -3993,7 +4118,7 @@ public final class Player implements /*////////////////////////////////////////////////////////////////////////// // Getters //////////////////////////////////////////////////////////////////////////*/ - //region + //region Getters public int getCurrentState() { return currentState; @@ -4012,6 +4137,10 @@ public final class Player implements return !exoPlayerIsNull() && simpleExoPlayer.isPlaying(); } + public boolean getPlayWhenReady() { + return !exoPlayerIsNull() && simpleExoPlayer.getPlayWhenReady(); + } + private boolean isLoading() { return !exoPlayerIsNull() && simpleExoPlayer.isLoading(); } @@ -4070,7 +4199,7 @@ public final class Player implements return audioReactor; } - public GestureDetector getGestureDetector() { + public GestureDetectorCompat getGestureDetector() { return gestureDetector; } @@ -4168,6 +4297,40 @@ public final class Player implements public PlayQueueAdapter getPlayQueueAdapter() { return playQueueAdapter; } + //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(); + // initially set the surface manually otherwise + // onRenderedFirstFrame() will not be called + simpleExoPlayer.setVideoSurface(surface); + } else { + simpleExoPlayer.setVideoSurfaceView(binding.surfaceView); + } + } + + private void cleanupVideoSurface() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // >=API23 + if (surfaceHolderCallback != null) { + if (binding != null) { + binding.surfaceView.getHolder().removeCallback(surfaceHolderCallback); + } + surfaceHolderCallback.release(); + surfaceHolderCallback = null; + } + } + } //endregion } diff --git a/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt b/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt index 989c78c57..29ae7c5c3 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt +++ b/app/src/main/java/org/schabi/newpipe/player/event/BasePlayerGestureListener.kt @@ -229,8 +229,10 @@ abstract class BasePlayerGestureListener( // because the soft input is visible (the draggable area is currently resized). player.updateScreenSize() player.checkPopupPositionBounds() - initialPopupX = player.popupLayoutParams!!.x - initialPopupY = player.popupLayoutParams!!.y + player.popupLayoutParams?.let { + initialPopupX = it.x + initialPopupY = it.y + } return super.onDown(e) } @@ -466,7 +468,7 @@ abstract class BasePlayerGestureListener( // /////////////////////////////////////////////////////////////////// private fun getDisplayPortion(e: MotionEvent): DisplayPortion { - return if (player.playerType == MainPlayer.PlayerType.POPUP) { + 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 diff --git a/app/src/main/java/org/schabi/newpipe/player/event/CustomBottomSheetBehavior.java b/app/src/main/java/org/schabi/newpipe/player/event/CustomBottomSheetBehavior.java index 61023875c..a5de56e75 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/CustomBottomSheetBehavior.java +++ b/app/src/main/java/org/schabi/newpipe/player/event/CustomBottomSheetBehavior.java @@ -26,7 +26,7 @@ public class CustomBottomSheetBehavior extends BottomSheetBehavior Rect globalRect = new Rect(); private boolean skippingInterception = false; private final List skipInterceptionOfElements = Arrays.asList( - R.id.detail_content_root_layout, R.id.relatedStreamsLayout, + 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); 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 index a0b2e7eba..998324c9c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/event/PlayerGestureListener.java +++ b/app/src/main/java/org/schabi/newpipe/player/event/PlayerGestureListener.java @@ -147,10 +147,10 @@ public class PlayerGestureListener player.getVolumeImageView().setImageDrawable( AppCompatResources.getDrawable(service, currentProgressPercent <= 0 - ? R.drawable.ic_volume_off_white_24dp - : currentProgressPercent < 0.25 ? R.drawable.ic_volume_mute_white_24dp - : currentProgressPercent < 0.75 ? R.drawable.ic_volume_down_white_24dp - : R.drawable.ic_volume_up_white_24dp) + ? 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) { @@ -189,10 +189,10 @@ public class PlayerGestureListener player.getBrightnessImageView().setImageDrawable( AppCompatResources.getDrawable(service, currentProgressPercent < 0.25 - ? R.drawable.ic_brightness_low_white_24dp + ? R.drawable.ic_brightness_low : currentProgressPercent < 0.75 - ? R.drawable.ic_brightness_medium_white_24dp - : R.drawable.ic_brightness_high_white_24dp) + ? R.drawable.ic_brightness_medium + : R.drawable.ic_brightness_high) ); if (player.getBrightnessRelativeLayout().getVisibility() != View.VISIBLE) { diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java index 13ee24e16..45b593328 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java @@ -103,13 +103,13 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, An animateAudio(DUCK_AUDIO_TO, 1.0f); if (PlayerHelper.isResumeAfterAudioFocusGain(context)) { - player.setPlayWhenReady(true); + player.play(); } } private void onAudioFocusLoss() { Log.d(TAG, "onAudioFocusLoss() called"); - player.setPlayWhenReady(false); + player.pause(); } private void onAudioFocusLossCanDuck() { diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java index ba9a2f1ec..e4ae27750 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/LoadController.java @@ -32,8 +32,9 @@ public class LoadController implements LoadControl { final DefaultLoadControl.Builder builder = new DefaultLoadControl.Builder(); builder.setBufferDurationsMs(minimumPlaybackBufferMs, optimalPlaybackBufferMs, - initialPlaybackBufferMs, initialPlaybackBufferMs); - internalLoadControl = builder.createDefaultLoadControl(); + initialPlaybackBufferMs, + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS); + internalLoadControl = builder.build(); } /*////////////////////////////////////////////////////////////////////////// @@ -47,9 +48,9 @@ public class LoadController implements LoadControl { } @Override - public void onTracksSelected(final Renderer[] renderers, final TrackGroupArray trackGroupArray, - final TrackSelectionArray trackSelectionArray) { - internalLoadControl.onTracksSelected(renderers, trackGroupArray, trackSelectionArray); + public void onTracksSelected(final Renderer[] renderers, final TrackGroupArray trackGroups, + final TrackSelectionArray trackSelections) { + internalLoadControl.onTracksSelected(renderers, trackGroups, trackSelections); } @Override 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 index b0c641433..c7f1f9c8c 100644 --- a/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java +++ b/app/src/main/java/org/schabi/newpipe/player/helper/MediaSessionManager.java @@ -36,8 +36,6 @@ public class MediaSessionManager { @NonNull final Player player, @NonNull final MediaSessionCallback callback) { mediaSession = new MediaSessionCompat(context, TAG); - mediaSession.setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS - | MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS); mediaSession.setActive(true); mediaSession.setPlaybackState(new PlaybackStateCompat.Builder() 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 4324fcd0a..d60a14381 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 @@ -180,10 +180,10 @@ public final class PlayerHelper { * if a candidate next video's url already exists in the existing items. *

*

- * The first item in {@link StreamInfo#getRelatedStreams()} is checked first. + * The first item in {@link StreamInfo#getRelatedItems()} is checked first. * If it is non-null and is not part of the existing items, it will be used as the next stream. - * Otherwise, a random item with non-repeating url will be selected - * from the {@link StreamInfo#getRelatedStreams()}. + * Otherwise, a random stream with non-repeating url will be selected + * from the {@link StreamInfo#getRelatedItems()}. Non-stream items are ignored. *

* * @param info currently playing stream @@ -198,7 +198,7 @@ public final class PlayerHelper { urls.add(item.getUrl()); } - final List relatedItems = info.getRelatedStreams(); + final List relatedItems = info.getRelatedItems(); if (Utils.isNullOrEmpty(relatedItems)) { return null; } @@ -297,7 +297,7 @@ public final class PlayerHelper { } public static long getPreferredFileSize() { - return 512 * 1024L; + return 2 * 1024 * 1024L; // ExoPlayer CacheDataSink.MIN_RECOMMENDED_FRAGMENT_SIZE } /** 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 da1238c81..68de8ce9f 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 @@ -8,6 +8,7 @@ import android.os.IBinder; import android.util.Log; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; import com.google.android.exoplayer2.ExoPlaybackException; import com.google.android.exoplayer2.PlaybackParameters; @@ -22,18 +23,27 @@ import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener; import org.schabi.newpipe.player.playqueue.PlayQueue; public final class PlayerHolder { + private PlayerHolder() { } - private static final boolean DEBUG = MainActivity.DEBUG; - private static final String TAG = "PlayerHolder"; + private static PlayerHolder instance; + public static synchronized PlayerHolder getInstance() { + if (PlayerHolder.instance == null) { + PlayerHolder.instance = new PlayerHolder(); + } + return PlayerHolder.instance; + } - private static PlayerServiceExtendedEventListener listener; + private final boolean DEBUG = MainActivity.DEBUG; + private final String TAG = PlayerHolder.class.getSimpleName(); - private static ServiceConnection serviceConnection; - public static boolean bound; - private static MainPlayer playerService; - private static Player player; + private PlayerServiceExtendedEventListener listener; + + private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection(); + public boolean bound; + private MainPlayer playerService; + private Player player; /** * Returns the current {@link MainPlayer.PlayerType} of the {@link MainPlayer} service, @@ -42,26 +52,31 @@ public final class PlayerHolder { * @return Current PlayerType */ @Nullable - public static MainPlayer.PlayerType getType() { + public MainPlayer.PlayerType getType() { if (player == null) { return null; } return player.getPlayerType(); } - public static boolean isPlaying() { + public boolean isPlaying() { if (player == null) { return false; } return player.isPlaying(); } - public static boolean isPlayerOpen() { + public boolean isPlayerOpen() { return player != null; } - public static void setListener(final PlayerServiceExtendedEventListener newListener) { + public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) { listener = newListener; + + if (listener == null) { + return; + } + // Force reload data from service if (player != null) { listener.onServiceConnected(player, playerService, false); @@ -69,14 +84,15 @@ public final class PlayerHolder { } } - public static void removeListener() { - listener = null; + // helper to handle context in common place as using the same + // context to bind/unbind a service is crucial + private Context getCommonContext() { + return App.getApp(); } - - public static void startService(final Context context, - final boolean playAfterConnect, - final PlayerServiceExtendedEventListener newListener) { + public void startService(final boolean playAfterConnect, + final PlayerServiceExtendedEventListener newListener) { + final Context context = getCommonContext(); setListener(newListener); if (bound) { return; @@ -85,58 +101,65 @@ public final class PlayerHolder { // and NullPointerExceptions inside the service because the service will be // bound twice. Prevent it with unbinding first unbind(context); - context.startService(new Intent(context, MainPlayer.class)); - serviceConnection = getServiceConnection(context, playAfterConnect); + ContextCompat.startForegroundService(context, new Intent(context, MainPlayer.class)); + serviceConnection.doPlayAfterConnect(playAfterConnect); bind(context); } - public static void stopService(final Context context) { + public void stopService() { + final Context context = getCommonContext(); unbind(context); context.stopService(new Intent(context, MainPlayer.class)); } - private static ServiceConnection getServiceConnection(final Context context, - final boolean playAfterConnect) { - return new ServiceConnection() { - @Override - public void onServiceDisconnected(final ComponentName compName) { - if (DEBUG) { - Log.d(TAG, "Player service is disconnected"); - } + class PlayerServiceConnection implements ServiceConnection { - unbind(context); + private boolean playAfterConnect = false; + + public void doPlayAfterConnect(final boolean playAfterConnection) { + this.playAfterConnect = playAfterConnection; + } + + @Override + public void onServiceDisconnected(final ComponentName compName) { + if (DEBUG) { + Log.d(TAG, "Player service is disconnected"); } - @Override - public void onServiceConnected(final ComponentName compName, final IBinder service) { - if (DEBUG) { - Log.d(TAG, "Player service is connected"); - } - final MainPlayer.LocalBinder localBinder = (MainPlayer.LocalBinder) service; + final Context context = getCommonContext(); + unbind(context); + } - playerService = localBinder.getService(); - player = localBinder.getPlayer(); - if (listener != null) { - listener.onServiceConnected(player, playerService, playAfterConnect); - } - startPlayerListener(); + @Override + public void onServiceConnected(final ComponentName compName, final IBinder service) { + if (DEBUG) { + Log.d(TAG, "Player service is connected"); } - }; - } + final MainPlayer.LocalBinder localBinder = (MainPlayer.LocalBinder) service; - private static void bind(final Context context) { + playerService = localBinder.getService(); + player = localBinder.getPlayer(); + if (listener != null) { + listener.onServiceConnected(player, playerService, playAfterConnect); + } + startPlayerListener(); + } + }; + + private void bind(final Context context) { if (DEBUG) { Log.d(TAG, "bind() called"); } final Intent serviceIntent = new Intent(context, MainPlayer.class); - bound = context.bindService(serviceIntent, serviceConnection, Context.BIND_AUTO_CREATE); + bound = context.bindService(serviceIntent, serviceConnection, + Context.BIND_AUTO_CREATE); if (!bound) { context.unbindService(serviceConnection); } } - private static void unbind(final Context context) { + private void unbind(final Context context) { if (DEBUG) { Log.d(TAG, "unbind() called"); } @@ -153,21 +176,19 @@ public final class PlayerHolder { } } - - private static void startPlayerListener() { + private void startPlayerListener() { if (player != null) { - player.setFragmentListener(INNER_LISTENER); + player.setFragmentListener(internalListener); } } - private static void stopPlayerListener() { + private void stopPlayerListener() { if (player != null) { - player.removeFragmentListener(INNER_LISTENER); + player.removeFragmentListener(internalListener); } } - - private static final PlayerServiceEventListener INNER_LISTENER = + private final PlayerServiceEventListener internalListener = new PlayerServiceEventListener() { @Override public void onFullscreenStateChanged(final boolean fullscreen) { @@ -242,7 +263,7 @@ public final class PlayerHolder { if (listener != null) { listener.onServiceStopped(); } - unbind(App.getApp()); + unbind(getCommonContext()); } }; } 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 new file mode 100644 index 000000000..0814092fa --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/playback/SurfaceHolderCallback.java @@ -0,0 +1,62 @@ +package org.schabi.newpipe.player.playback; + +import android.content.Context; +import android.view.SurfaceHolder; + +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.video.DummySurface; + +/** + * Prevent error message: 'Unrecoverable player error occurred' + * In case of rotation some users see this kind of an error which is preventable + * having a Callback that handles the lifecycle of the surface. + *

+ * How?: In case we are no longer able to write to the surface eg. through rotation/putting in + * background we set set a DummySurface. Although it it works on API >= 23 only. + * Result: we get a little video interruption (audio is still fine) but we won't get the + * 'Unrecoverable player error occurred' error message. + *

+ * This implementation is based on: + * 'ExoPlayer stuck in buffering after re-adding the surface view a few time #2703' + *

+ * -> exoplayer fix suggestion link + * https://github.com/google/ExoPlayer/issues/2703#issuecomment-300599981 + */ +public final class SurfaceHolderCallback implements SurfaceHolder.Callback { + + private final Context context; + private final SimpleExoPlayer player; + private DummySurface dummySurface; + + public SurfaceHolderCallback(final Context context, final SimpleExoPlayer player) { + this.context = context; + this.player = player; + } + + @Override + public void surfaceCreated(final SurfaceHolder holder) { + player.setVideoSurface(holder.getSurface()); + } + + @Override + public void surfaceChanged(final SurfaceHolder holder, + final int format, + final int width, + final int height) { + } + + @Override + public void surfaceDestroyed(final SurfaceHolder holder) { + if (dummySurface == null) { + dummySurface = DummySurface.newInstanceV17(context, false); + } + player.setVideoSurface(dummySurface); + } + + public void release() { + if (dummySurface != null) { + dummySurface.release(); + dummySurface = null; + } + } +} 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 6131d8565..014c13339 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 @@ -40,29 +40,25 @@ import io.reactivex.rxjava3.subjects.BehaviorSubject; */ public abstract class PlayQueue implements Serializable { public static final boolean DEBUG = MainActivity.DEBUG; - - private ArrayList backup; - private ArrayList streams; - @NonNull private final AtomicInteger queueIndex; - private final ArrayList history; + private final List history = new ArrayList<>(); + + private List backup; + private List streams; private transient BehaviorSubject eventBroadcast; private transient Flowable broadcastReceiver; - - private transient boolean disposed; + private transient boolean disposed = false; PlayQueue(final int index, final List startWith) { - streams = new ArrayList<>(); - streams.addAll(startWith); - history = new ArrayList<>(); + streams = new ArrayList<>(startWith); + if (streams.size() > index) { history.add(streams.get(index)); } queueIndex = new AtomicInteger(index); - disposed = false; } /*////////////////////////////////////////////////////////////////////////// @@ -137,18 +133,36 @@ public abstract class PlayQueue implements Serializable { public synchronized void setIndex(final int index) { final int oldIndex = getIndex(); - int newIndex = index; + final int newIndex; + if (index < 0) { newIndex = 0; + } else if (index < streams.size()) { + // Regular assignment for index in bounds + newIndex = index; + } else if (streams.isEmpty()) { + // Out of bounds from here on + // Need to check if stream is empty to prevent arithmetic error and negative index + newIndex = 0; + } else if (isComplete()) { + // Circular indexing + newIndex = index % streams.size(); + } else { + // Index of last element + newIndex = streams.size() - 1; } - if (index >= streams.size()) { - newIndex = isComplete() ? index % streams.size() : streams.size() - 1; - } + + queueIndex.set(newIndex); + if (oldIndex != newIndex) { history.add(streams.get(newIndex)); } - queueIndex.set(newIndex); + /* + TODO: Documentation states that a SelectEvent will only be emitted if the new index is... + different from the old one but this is emitted regardless? Not sure what this what it does + exactly so I won't touch it + */ broadcast(new SelectEvent(oldIndex, newIndex)); } @@ -180,8 +194,6 @@ public abstract class PlayQueue implements Serializable { * @return the index of the given item */ public int indexOf(@NonNull final PlayQueueItem item) { - // referential equality, can't think of a better way to do this - // todo: better than this return streams.indexOf(item); } @@ -410,34 +422,42 @@ public abstract class PlayQueue implements Serializable { } /** - * Shuffles the current play queue. + * Shuffles the current play queue *

- * This method first backs up the existing play queue and item being played. - * Then a newly shuffled play queue will be generated along with currently - * playing item placed at the beginning of the queue. + * This method first backs up the existing play queue and item being played. Then a newly + * shuffled play queue will be generated along with currently playing item placed at the + * beginning of the queue. This item will also be added to the history. *

*

- * Will emit a {@link ReorderEvent} in any context. + * Will emit a {@link ReorderEvent} if shuffled. *

+ * + * @implNote Does nothing if the queue has a size <= 2 (the currently playing video must stay on + * top, so shuffling a size-2 list does nothing) */ public synchronized void shuffle() { + // Can't shuffle an list that's empty or only has one element + if (size() <= 2) { + return; + } + // Create a backup if it doesn't already exist if (backup == null) { backup = new ArrayList<>(streams); } - final int originIndex = getIndex(); - final PlayQueueItem current = getItem(); + + final int originalIndex = getIndex(); + final PlayQueueItem currentItem = getItem(); + Collections.shuffle(streams); - final int newIndex = streams.indexOf(current); - if (newIndex != -1) { - streams.add(0, streams.remove(newIndex)); - } + // Move currentItem to the head of the queue + streams.remove(currentItem); + streams.add(0, currentItem); queueIndex.set(0); - if (streams.size() > 0) { - history.add(streams.get(0)); - } - broadcast(new ReorderEvent(originIndex, queueIndex.get())); + history.add(currentItem); + + broadcast(new ReorderEvent(originalIndex, 0)); } /** @@ -457,7 +477,6 @@ public abstract class PlayQueue implements Serializable { final int originIndex = getIndex(); final PlayQueueItem current = getItem(); - streams.clear(); streams = backup; backup = null; @@ -500,22 +519,19 @@ public abstract class PlayQueue implements Serializable { * we don't have to do anything with new queue. * 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) - || getStreams().size() != ((PlayQueue) obj).getStreams().size()) { + if (!(obj instanceof PlayQueue)) { return false; } - final PlayQueue other = (PlayQueue) obj; - for (int i = 0; i < getStreams().size(); i++) { - if (!getItem(i).getUrl().equals(other.getItem(i).getUrl())) { - return false; - } - } + return streams.equals(other.streams); + } - return true; + @Override + public int hashCode() { + return streams.hashCode(); } public boolean isDisposed() { diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java index 462b9eb53..dd95fb4d5 100644 --- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java +++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueAdapter.java @@ -182,8 +182,10 @@ public class PlayQueueAdapter extends RecyclerView.Adapter { switch (type) { case C.TYPE_SS: return dataSource.getLiveSsMediaSourceFactory().setTag(metadata) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); case C.TYPE_DASH: return dataSource.getLiveDashMediaSourceFactory().setTag(metadata) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); case C.TYPE_HLS: return dataSource.getLiveHlsMediaSourceFactory().setTag(metadata) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); default: throw new IllegalStateException("Unsupported type: " + type); } @@ -68,16 +69,16 @@ public interface PlaybackResolver extends Resolver { switch (type) { case C.TYPE_SS: return dataSource.getLiveSsMediaSourceFactory().setTag(metadata) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); case C.TYPE_DASH: return dataSource.getDashMediaSourceFactory().setTag(metadata) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); case C.TYPE_HLS: return dataSource.getHlsMediaSourceFactory().setTag(metadata) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); case C.TYPE_OTHER: return dataSource.getExtractorMediaSourceFactory(cacheKey).setTag(metadata) - .createMediaSource(uri); + .createMediaSource(MediaItem.fromUri(uri)); default: throw new IllegalStateException("Unsupported type: " + type); } 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 a2b3a1d3d..245a85e71 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 @@ -6,7 +6,7 @@ import android.net.Uri; import androidx.annotation.NonNull; import androidx.annotation.Nullable; -import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.MediaItem; import com.google.android.exoplayer2.source.MediaSource; import com.google.android.exoplayer2.source.MergingMediaSource; @@ -22,7 +22,6 @@ import org.schabi.newpipe.util.ListHelper; import java.util.ArrayList; import java.util.List; -import static com.google.android.exoplayer2.C.SELECTION_FLAG_AUTOSELECT; import static com.google.android.exoplayer2.C.TIME_UNSET; public class VideoPlaybackResolver implements PlaybackResolver { @@ -101,12 +100,12 @@ public class VideoPlaybackResolver implements PlaybackResolver { if (mimeType == null) { continue; } - - final Format textFormat = Format.createTextSampleFormat(null, mimeType, - SELECTION_FLAG_AUTOSELECT, - PlayerHelper.captionLanguageOf(context, subtitle)); final MediaSource textSource = dataSource.getSampleMediaSourceFactory() - .createMediaSource(Uri.parse(subtitle.getUrl()), textFormat, TIME_UNSET); + .createMediaSource( + new MediaItem.Subtitle(Uri.parse(subtitle.getUrl()), + mimeType, + PlayerHelper.captionLanguageOf(context, subtitle)), + TIME_UNSET); mediaSources.add(textSource); } } 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 new file mode 100644 index 000000000..54d11da83 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHelper.java @@ -0,0 +1,108 @@ +package org.schabi.newpipe.player.seekbarpreview; + +import android.content.Context; +import android.graphics.Bitmap; +import android.util.Log; +import android.view.View; +import android.widget.ImageView; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +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; +import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType.HIGH_QUALITY; +import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType.LOW_QUALITY; +import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType.NONE; + +/** + * Helper for the seekbar preview. + */ +public final class SeekbarPreviewThumbnailHelper { + + // This has to be <= 23 chars on devices running Android 7 or lower (API <= 25) + // or it fails with an IllegalArgumentException + // https://stackoverflow.com/a/54744028 + public static final String TAG = "SeekbarPrevThumbHelper"; + + private SeekbarPreviewThumbnailHelper() { + // No impl pls + } + + @Retention(SOURCE) + @IntDef({HIGH_QUALITY, LOW_QUALITY, + NONE}) + public @interface SeekbarPreviewThumbnailType { + int HIGH_QUALITY = 0; + int LOW_QUALITY = 1; + int NONE = 2; + } + + //////////////////////////////////////////////////////////////////////////// + // Settings Resolution + /////////////////////////////////////////////////////////////////////////// + + @SeekbarPreviewThumbnailType + public static int getSeekbarPreviewThumbnailType(@NonNull final Context context) { + final String type = PreferenceManager.getDefaultSharedPreferences(context).getString( + context.getString(R.string.seekbar_preview_thumbnail_key), ""); + if (type.equals(context.getString(R.string.seekbar_preview_thumbnail_none))) { + return NONE; + } else if (type.equals(context.getString(R.string.seekbar_preview_thumbnail_low_quality))) { + return LOW_QUALITY; + } else { + return HIGH_QUALITY; // default + } + } + + public static void tryResizeAndSetSeekbarPreviewThumbnail( + @NonNull final Context context, + @NonNull final Optional optPreviewThumbnail, + @NonNull final ImageView currentSeekbarPreviewThumbnail, + @NonNull final IntSupplier baseViewWidthSupplier) { + + if (!optPreviewThumbnail.isPresent()) { + 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 float scaleFactor = (float) newWidth / srcWidth; + final int newHeight = (int) (srcBitmap.getHeight() * scaleFactor); + + currentSeekbarPreviewThumbnail.setImageBitmap( + Bitmap.createScaledBitmap(srcBitmap, newWidth, newHeight, true)); + } catch (final Exception ex) { + Log.e(TAG, "Failed to resize and set seekbar preview thumbnail", ex); + currentSeekbarPreviewThumbnail.setVisibility(View.GONE); + } finally { + srcBitmap.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 new file mode 100644 index 000000000..30c5ce910 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java @@ -0,0 +1,252 @@ +package org.schabi.newpipe.player.seekbarpreview; + +import android.content.Context; +import android.graphics.Bitmap; +import android.util.Log; + +import androidx.annotation.NonNull; + +import com.google.common.base.Stopwatch; +import com.nostra13.universalimageloader.core.ImageLoader; + +import org.schabi.newpipe.extractor.stream.Frameset; +import org.schabi.newpipe.util.ImageDisplayConstants; + +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.concurrent.TimeUnit; +import java.util.function.Supplier; + +import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType; + +public class SeekbarPreviewThumbnailHolder { + + // This has to be <= 23 chars on devices running Android 7 or lower (API <= 25) + // or it fails with an IllegalArgumentException + // https://stackoverflow.com/a/54744028 + public static final String TAG = "SeekbarPrevThumbHolder"; + + // Key = Position of the picture in milliseconds + // Supplier = Supplies the bitmap for that position + private final Map> seekbarPreviewData = new ConcurrentHashMap<>(); + + // 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); + + final UUID updateRequestIdentifier = UUID.randomUUID(); + this.currentUpdateRequestIdentifier = updateRequestIdentifier; + + final ExecutorService executorService = Executors.newSingleThreadExecutor(); + executorService.submit(() -> { + try { + resetFromAsync(seekbarPreviewType, framesets, updateRequestIdentifier); + } catch (final Exception ex) { + Log.e(TAG, "Failed to execute async", ex); + } + }); + // ensure that the executorService stops/destroys it's threads + // after the task is finished + executorService.shutdown(); + } + + private void resetFromAsync( + final int seekbarPreviewType, + final List framesets, + final UUID updateRequestIdentifier) { + + Log.d(TAG, "Clearing seekbarPreviewData"); + seekbarPreviewData.clear(); + + if (seekbarPreviewType == SeekbarPreviewThumbnailType.NONE) { + Log.d(TAG, "Not processing seekbarPreviewData due to settings"); + return; + } + + final Frameset frameset = getFrameSetForType(framesets, seekbarPreviewType); + if (frameset == null) { + Log.d(TAG, "No frameset was found to fill seekbarPreviewData"); + return; + } + + Log.d(TAG, "Frameset quality info: " + + "[width=" + frameset.getFrameWidth() + + ", heigh=" + frameset.getFrameHeight() + "]"); + + // Abort method execution if we are not the latest request + if (!isRequestIdentifierCurrent(updateRequestIdentifier)) { + return; + } + + generateDataFrom(frameset, updateRequestIdentifier); + } + + 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() + .max(Comparator.comparingInt(fs -> fs.getFrameHeight() * fs.getFrameWidth())) + .orElse(null); + } else { + Log.d(TAG, "Strategy for seekbarPreviewData: low quality"); + return framesets.stream() + .min(Comparator.comparingInt(fs -> fs.getFrameHeight() * fs.getFrameWidth())) + .orElse(null); + } + } + + 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(); + + // Process each url in the frameset + for (final String url : frameset.getUrls()) { + // get the bitmap + final Bitmap srcBitMap = getBitMapFrom(url); + + // The data is not added directly to "seekbarPreviewData" due to + // concurrency and checks for "updateRequestIdentifier" + final Map> generatedDataForUrl = new HashMap<>(); + + // The bitmap consists of several images, which we process here + // foreach frame in the returned bitmap + for (int i = 0; i < frameCountPerUrl; i++) { + // Frames outside the video length are skipped + if (pos > frameset.getTotalCount()) { + break; + } + + // Get the bounds where the frame is found + final int[] bounds = frameset.getFrameBoundsAt(currentPosMs); + generatedDataForUrl.put(currentPosMs, () -> { + // It can happen, that the original bitmap could not be downloaded + // In such a case - we don't want a NullPointer - simply return null + if (srcBitMap == null) { + return null; + } + + // Cut out the corresponding bitmap form the "srcBitMap" + return Bitmap.createBitmap(srcBitMap, bounds[1], bounds[2], + frameset.getFrameWidth(), frameset.getFrameHeight()); + }); + + currentPosMs += frameset.getDurationPerFrame(); + pos++; + } + + // Check if we are still the latest request + // If not abort method execution + if (isRequestIdentifierCurrent(updateRequestIdentifier)) { + seekbarPreviewData.putAll(generatedDataForUrl); + } else { + Log.d(TAG, "Aborted of generation of seekbarPreviewData"); + break; + } + } + + if (sw != null) { + Log.d(TAG, "Generation of seekbarPreviewData took " + sw.stop().toString()); + } + } + + private Bitmap getBitMapFrom(final String url) { + if (url == null) { + Log.w(TAG, "url is null; This should never happen"); + return null; + } + + final Stopwatch sw = Log.isLoggable(TAG, Log.DEBUG) ? Stopwatch.createStarted() : null; + try { + final SyncImageLoadingListener syncImageLoadingListener = + new SyncImageLoadingListener(); + + Log.d(TAG, "Downloading bitmap for seekbarPreview from '" + url + "'"); + + // Ensure that everything is running + ImageLoader.getInstance().resume(); + // Load the image + // Impl-Note: + // Ensure that your are not running on the main-Thread this will otherwise hang + ImageLoader.getInstance().loadImage( + url, + ImageDisplayConstants.DISPLAY_SEEKBAR_PREVIEW_OPTIONS, + syncImageLoadingListener); + + // Get the bitmap within the timeout + final Bitmap bitmap = + syncImageLoadingListener.waitForBitmapOrThrow(30, TimeUnit.SECONDS); + + if (sw != null) { + Log.d(TAG, + "Download of bitmap for seekbarPreview from '" + url + + "' took " + sw.stop().toString()); + } + + return bitmap; + } catch (final Exception ex) { + Log.w(TAG, + "Failed to get bitmap for seekbarPreview from url='" + url + + "' in time", + ex); + return null; + } + } + + private boolean isRequestIdentifierCurrent(final UUID requestIdentifier) { + 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 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(); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SyncImageLoadingListener.java b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SyncImageLoadingListener.java new file mode 100644 index 000000000..46c278bf2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SyncImageLoadingListener.java @@ -0,0 +1,87 @@ +package org.schabi.newpipe.player.seekbarpreview; + +import android.graphics.Bitmap; +import android.view.View; + +import com.nostra13.universalimageloader.core.assist.FailReason; +import com.nostra13.universalimageloader.core.listener.SimpleImageLoadingListener; + +import java.util.concurrent.CancellationException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Listener for synchronously downloading of an image/bitmap. + */ +public class SyncImageLoadingListener extends SimpleImageLoadingListener { + + private final CountDownLatch countDownLatch = new CountDownLatch(1); + + private Bitmap bitmap; + private boolean cancelled = false; + private FailReason failReason = null; + + @SuppressWarnings("checkstyle:HiddenField") + @Override + public void onLoadingFailed( + final String imageUri, + final View view, + final FailReason failReason) { + + this.failReason = failReason; + countDownLatch.countDown(); + } + + @Override + public void onLoadingComplete( + final String imageUri, + final View view, + final Bitmap loadedImage) { + + bitmap = loadedImage; + countDownLatch.countDown(); + } + + @Override + public void onLoadingCancelled(final String imageUri, final View view) { + cancelled = true; + countDownLatch.countDown(); + } + + public Bitmap waitForBitmapOrThrow(final long timeout, final TimeUnit timeUnit) + throws InterruptedException, TimeoutException { + + // Wait for the download to finish + if (!countDownLatch.await(timeout, timeUnit)) { + throw new TimeoutException("Couldn't get the image in time"); + } + + if (isCancelled()) { + throw new CancellationException("Download of image was cancelled"); + } + + if (getFailReason() != null) { + throw new RuntimeException("Failed to download image" + getFailReason().getType(), + getFailReason().getCause()); + } + + if (getBitmap() == null) { + throw new NullPointerException("Bitmap is null"); + } + + return getBitmap(); + } + + public Bitmap getBitmap() { + return bitmap; + } + + public boolean isCancelled() { + return cancelled; + } + + public FailReason getFailReason() { + return failReason; + } +} 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 e2ac2c20d..1e1b03b4f 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/AppearanceSettingsFragment.java @@ -7,12 +7,12 @@ import android.os.Bundle; import android.provider.Settings; import android.widget.Toast; -import androidx.annotation.Nullable; import androidx.core.app.ActivityCompat; import androidx.preference.Preference; import org.schabi.newpipe.R; import org.schabi.newpipe.util.Constants; +import org.schabi.newpipe.util.ThemeHelper; public class AppearanceSettingsFragment extends BasePreferenceFragment { private static final boolean CAPTIONING_SETTINGS_ACCESSIBLE = @@ -21,8 +21,8 @@ public class AppearanceSettingsFragment extends BasePreferenceFragment { private String captionSettingsKey; @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResource(R.xml.appearance_settings); final String themeKey = getString(R.string.theme_key); // the key of the active theme when settings were opened (or recreated after theme change) @@ -58,11 +58,6 @@ public class AppearanceSettingsFragment extends BasePreferenceFragment { } } - @Override - public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResource(R.xml.appearance_settings); - } - @Override public boolean onPreferenceTreeClick(final Preference preference) { if (preference.getKey().equals(captionSettingsKey) && CAPTIONING_SETTINGS_ACCESSIBLE) { @@ -89,6 +84,8 @@ public class AppearanceSettingsFragment extends BasePreferenceFragment { defaultPreferences.edit().putBoolean(Constants.KEY_THEME_CHANGE, true).apply(); defaultPreferences.edit().putString(themeKey, newValue.toString()).apply(); + ThemeHelper.setDayNightMode(getContext(), newValue.toString()); + if (!newValue.equals(beginningThemeKey) && getActivity() != null) { // if it's not the current theme ActivityCompat.recreate(getActivity()); diff --git a/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java index b64543d27..8b2bd9c9a 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java @@ -6,12 +6,16 @@ import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; import androidx.preference.PreferenceManager; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.util.ThemeHelper; +import java.util.Objects; + public abstract class BasePreferenceFragment extends PreferenceFragmentCompat { protected final String TAG = getClass().getSimpleName() + "@" + Integer.toHexString(hashCode()); protected final boolean DEBUG = MainActivity.DEBUG; @@ -37,4 +41,11 @@ public abstract class BasePreferenceFragment extends PreferenceFragmentCompat { super.onResume(); ThemeHelper.setTitleToAppCompatActivity(getActivity(), getPreferenceScreen().getTitle()); } + + @NonNull + public final Preference requirePreference(@StringRes final int resId) { + final Preference preference = findPreference(getString(resId)); + Objects.requireNonNull(preference); + return preference; + } } 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 dbe05bbd2..f1e19af94 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java @@ -1,21 +1,22 @@ package org.schabi.newpipe.settings; 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.util.Log; import android.widget.Toast; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; +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 com.nononsenseapps.filepicker.Utils; import com.nostra13.universalimageloader.core.ImageLoader; import org.schabi.newpipe.DownloaderImpl; @@ -26,35 +27,70 @@ import org.schabi.newpipe.error.ReCaptchaActivity; import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.localization.Localization; -import org.schabi.newpipe.util.FilePickerActivityHelper; +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; +import static org.schabi.newpipe.extractor.utils.Utils.isBlank; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; public class ContentSettingsFragment extends BasePreferenceFragment { - private static final int REQUEST_IMPORT_PATH = 8945; - private static final int REQUEST_EXPORT_PATH = 30945; + 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 thumbnailLoadToggleKey; 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 onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); + 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); thumbnailLoadToggleKey = getString(R.string.download_thumbnail_key); youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled); + addPreferencesFromResource(R.xml.content_settings); + + final Preference importDataPreference = requirePreference(R.string.import_data); + importDataPreference.setOnPreferenceClickListener((Preference p) -> { + requestImportPathLauncher.launch( + StoredFileHelper.getPicker(requireContext(), getImportExportDataUri())); + return true; + }); + + final Preference exportDataPreference = requirePreference(R.string.export_data); + exportDataPreference.setOnPreferenceClickListener((final Preference p) -> { + + requestExportPathLauncher.launch( + StoredFileHelper.getNewPicker(requireContext(), + "NewPipeData-" + exportDateFormat.format(new Date()) + ".zip", + ZIP_MIME_TYPE, getImportExportDataUri())); + return true; + }); + initialSelectedLocalization = org.schabi.newpipe.util.Localization .getPreferredLocalization(requireContext()); initialSelectedContentCountry = org.schabi.newpipe.util.Localization @@ -62,8 +98,7 @@ public class ContentSettingsFragment extends BasePreferenceFragment { initialLanguage = PreferenceManager .getDefaultSharedPreferences(requireContext()).getString("app_language_key", "en"); - final Preference clearCookiePref = findPreference(getString(R.string.clear_cookie_key)); - + final Preference clearCookiePref = requirePreference(R.string.clear_cookie_key); clearCookiePref.setOnPreferenceClickListener(preference -> { defaultPreferences.edit() .putString(getString(R.string.recaptcha_cookies_key), "").apply(); @@ -103,37 +138,6 @@ public class ContentSettingsFragment extends BasePreferenceFragment { return super.onPreferenceTreeClick(preference); } - @Override - public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - final File homeDir = ContextCompat.getDataDir(requireContext()); - manager = new ContentSettingsManager(new NewPipeFileLocator(homeDir)); - manager.deleteSettingsFile(); - - addPreferencesFromResource(R.xml.content_settings); - - final Preference importDataPreference = findPreference(getString(R.string.import_data)); - importDataPreference.setOnPreferenceClickListener(p -> { - final Intent i = new Intent(getActivity(), FilePickerActivityHelper.class) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, false) - .putExtra(FilePickerActivityHelper.EXTRA_MODE, - FilePickerActivityHelper.MODE_FILE); - startActivityForResult(i, REQUEST_IMPORT_PATH); - return true; - }); - - final Preference exportDataPreference = findPreference(getString(R.string.export_data)); - exportDataPreference.setOnPreferenceClickListener(p -> { - final Intent i = new Intent(getActivity(), FilePickerActivityHelper.class) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) - .putExtra(FilePickerActivityHelper.EXTRA_MODE, - FilePickerActivityHelper.MODE_DIR); - startActivityForResult(i, REQUEST_EXPORT_PATH); - return true; - }); - } - @Override public void onDestroy() { super.onDestroy(); @@ -155,93 +159,117 @@ public class ContentSettingsFragment extends BasePreferenceFragment { } } - @Override - public void onActivityResult(final int requestCode, final int resultCode, - @NonNull final Intent data) { + private void requestExportPathResult(final ActivityResult result) { assureCorrectAppLanguage(getContext()); - super.onActivityResult(requestCode, resultCode, data); - if (DEBUG) { - Log.d(TAG, "onActivityResult() called with: " - + "requestCode = [" + requestCode + "], " - + "resultCode = [" + resultCode + "], " - + "data = [" + data + "]"); - } + if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) { + // will be saved only on success + final Uri lastExportDataUri = result.getData().getData(); - if ((requestCode == REQUEST_IMPORT_PATH || requestCode == REQUEST_EXPORT_PATH) - && resultCode == Activity.RESULT_OK && data.getData() != null) { - final String path = Utils.getFileForUri(data.getData()).getAbsolutePath(); - if (requestCode == REQUEST_EXPORT_PATH) { - final SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US); - exportDatabase(path + "/NewPipeData-" + sdf.format(new Date()) + ".zip"); - } else { - final AlertDialog.Builder builder = new AlertDialog.Builder(getActivity()); - builder.setMessage(R.string.override_current_data) - .setPositiveButton(getString(R.string.finish), - (d, id) -> importDatabase(path)) - .setNegativeButton(android.R.string.cancel, - (d, id) -> d.cancel()); - builder.create().show(); - } + final StoredFileHelper file + = new StoredFileHelper(getContext(), result.getData().getData(), ZIP_MIME_TYPE); + + exportDatabase(file, lastExportDataUri); } } - private void exportDatabase(final String path) { + 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.finish, (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, path); + .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) { ErrorActivity.reportUiErrorInSnackbar(this, "Exporting database", e); } } - private void importDatabase(final String filePath) { + private void importDatabase(final StoredFileHelper file, final Uri importDataUri) { // check if file is supported - if (!ZipHelper.isValidZipFile(filePath)) { + if (!ZipHelper.isValidZipFile(file)) { Toast.makeText(getContext(), R.string.no_valid_zip_file, Toast.LENGTH_SHORT) - .show(); + .show(); return; } try { if (!manager.ensureDbDirectoryExists()) { - throw new Exception("Could not create databases dir"); + throw new IOException("Could not create databases dir"); } - if (!manager.extractDb(filePath)) { + 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(filePath)) { - final AlertDialog.Builder alert = new AlertDialog.Builder(getContext()); + // 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(android.R.string.no, (dialog, which) -> { dialog.dismiss(); - // restart app to properly load db - System.exit(0); + finishImport(importDataUri); }); alert.setPositiveButton(getString(R.string.finish), (dialog, which) -> { dialog.dismiss(); manager.loadSharedPreferences(PreferenceManager - .getDefaultSharedPreferences(requireContext())); - // restart app to properly load db - System.exit(0); + .getDefaultSharedPreferences(requireContext())); + finishImport(importDataUri); }); alert.show(); } else { - // restart app to properly load db - System.exit(0); + finishImport(importDataUri); } } catch (final Exception e) { ErrorActivity.reportUiErrorInSnackbar(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 1730a230e..cb4c14796 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsManager.kt +++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsManager.kt @@ -1,6 +1,8 @@ package org.schabi.newpipe.settings import android.content.SharedPreferences +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 @@ -17,8 +19,9 @@ class ContentSettingsManager(private val fileLocator: NewPipeFileLocator) { * It also creates the file. */ @Throws(Exception::class) - fun exportDatabase(preferences: SharedPreferences, outputPath: String) { - ZipOutputStream(BufferedOutputStream(FileOutputStream(outputPath))) + fun exportDatabase(preferences: SharedPreferences, file: StoredFileHelper) { + file.create() + ZipOutputStream(BufferedOutputStream(SharpOutputStream(file.stream))) .use { outZip -> ZipHelper.addFileToZip(outZip, fileLocator.db.path, "newpipe.db") @@ -48,8 +51,8 @@ class ContentSettingsManager(private val fileLocator: NewPipeFileLocator) { return fileLocator.dbDir.exists() || fileLocator.dbDir.mkdir() } - fun extractDb(filePath: String): Boolean { - val success = ZipHelper.extractFileFromZip(filePath, fileLocator.db.path, "newpipe.db") + fun extractDb(file: StoredFileHelper): Boolean { + val success = ZipHelper.extractFileFromZip(file, fileLocator.db.path, "newpipe.db") if (success) { fileLocator.dbJournal.delete() fileLocator.dbWal.delete() @@ -59,9 +62,8 @@ class ContentSettingsManager(private val fileLocator: NewPipeFileLocator) { return success } - fun extractSettings(filePath: String): Boolean { - return ZipHelper - .extractFileFromZip(filePath, fileLocator.settings.path, "newpipe.settings") + fun extractSettings(file: StoredFileHelper): Boolean { + return ZipHelper.extractFileFromZip(file, fileLocator.settings.path, "newpipe.settings") } fun loadSharedPreferences(preferences: SharedPreferences) { 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 8742f0937..b4af0e43b 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/DownloadSettingsFragment.java @@ -1,7 +1,6 @@ package org.schabi.newpipe.settings; import android.app.Activity; -import android.app.AlertDialog; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; @@ -9,15 +8,20 @@ import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.util.Log; -import android.widget.Toast; -import androidx.annotation.Nullable; +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; +import androidx.annotation.NonNull; import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; import androidx.preference.Preference; +import androidx.preference.SwitchPreferenceCompat; import com.nononsenseapps.filepicker.Utils; import org.schabi.newpipe.R; +import org.schabi.newpipe.streams.io.StoredDirectoryHelper; import org.schabi.newpipe.util.FilePickerActivityHelper; import java.io.File; @@ -27,14 +31,10 @@ import java.net.URI; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; -import us.shandian.giga.io.StoredDirectoryHelper; - import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; public class DownloadSettingsFragment extends BasePreferenceFragment { public static final boolean IGNORE_RELEASE_ON_OLD_PATH = true; - private static final int REQUEST_DOWNLOAD_VIDEO_PATH = 0x1235; - private static final int REQUEST_DOWNLOAD_AUDIO_PATH = 0x1236; private String downloadPathVideoPreference; private String downloadPathAudioPreference; private String storageUseSafPreference; @@ -44,10 +44,16 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { private Preference prefStorageAsk; private Context ctx; + private final ActivityResultLauncher requestDownloadVideoPathLauncher = + registerForActivityResult( + new StartActivityForResult(), this::requestDownloadVideoPathResult); + private final ActivityResultLauncher requestDownloadAudioPathLauncher = + registerForActivityResult( + new StartActivityForResult(), this::requestDownloadAudioPathResult); @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResource(R.xml.download_settings); downloadPathVideoPreference = getString(R.string.download_path_video_key); downloadPathAudioPreference = getString(R.string.download_path_audio_key); @@ -58,13 +64,23 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { prefPathAudio = findPreference(downloadPathAudioPreference); 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) { + 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); + } + prefStorageAsk.setSummary(R.string.downloads_storage_ask_summary_no_saf_notice); + } + updatePreferencesSummary(); updatePathPickers(!defaultPreferences.getBoolean(downloadStorageAsk, false)); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - prefStorageAsk.setSummary(R.string.downloads_storage_ask_summary); - } - if (hasInvalidPath(downloadPathVideoPreference) || hasInvalidPath(downloadPathAudioPreference)) { updatePreferencesSummary(); @@ -77,12 +93,7 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { } @Override - public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResource(R.xml.download_settings); - } - - @Override - public void onAttach(final Context context) { + public void onAttach(@NonNull final Context context) { super.onAttach(context); ctx = context; } @@ -180,65 +191,51 @@ public class DownloadSettingsFragment extends BasePreferenceFragment { } final String key = preference.getKey(); - final int request; if (key.equals(storageUseSafPreference)) { - Toast.makeText(getContext(), R.string.download_choose_new_path, - Toast.LENGTH_LONG).show(); + if (!NewPipeSettings.useStorageAccessFramework(ctx)) { + NewPipeSettings.saveDefaultVideoDownloadDirectory(ctx); + NewPipeSettings.saveDefaultAudioDownloadDirectory(ctx); + } else { + defaultPreferences.edit().putString(downloadPathVideoPreference, null) + .putString(downloadPathAudioPreference, null).apply(); + } + updatePreferencesSummary(); return true; } else if (key.equals(downloadPathVideoPreference)) { - request = REQUEST_DOWNLOAD_VIDEO_PATH; + launchDirectoryPicker(requestDownloadVideoPathLauncher); } else if (key.equals(downloadPathAudioPreference)) { - request = REQUEST_DOWNLOAD_AUDIO_PATH; + launchDirectoryPicker(requestDownloadAudioPathLauncher); } else { return super.onPreferenceTreeClick(preference); } - final Intent i; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP - && NewPipeSettings.useStorageAccessFramework(ctx)) { - i = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) - .putExtra("android.content.extra.SHOW_ADVANCED", true) - .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION - | StoredDirectoryHelper.PERMISSION_FLAGS); - } else { - i = new Intent(getActivity(), FilePickerActivityHelper.class) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) - .putExtra(FilePickerActivityHelper.EXTRA_MODE, - FilePickerActivityHelper.MODE_DIR); - } - - startActivityForResult(i, request); - return true; } - @Override - public void onActivityResult(final int requestCode, final int resultCode, final Intent data) { + private void launchDirectoryPicker(final ActivityResultLauncher launcher) { + launcher.launch(StoredDirectoryHelper.getPicker(ctx)); + } + + private void requestDownloadVideoPathResult(final ActivityResult result) { + requestDownloadPathResult(result, downloadPathVideoPreference); + } + + private void requestDownloadAudioPathResult(final ActivityResult result) { + requestDownloadPathResult(result, downloadPathAudioPreference); + } + + private void requestDownloadPathResult(final ActivityResult result, final String key) { assureCorrectAppLanguage(getContext()); - super.onActivityResult(requestCode, resultCode, data); - if (DEBUG) { - Log.d(TAG, "onActivityResult() called with: " - + "requestCode = [" + requestCode + "], " - + "resultCode = [" + resultCode + "], data = [" + data + "]" - ); - } - if (resultCode != Activity.RESULT_OK) { + if (result.getResultCode() != Activity.RESULT_OK) { return; } - final String key; - if (requestCode == REQUEST_DOWNLOAD_VIDEO_PATH) { - key = downloadPathVideoPreference; - } else if (requestCode == REQUEST_DOWNLOAD_AUDIO_PATH) { - key = downloadPathAudioPreference; - } else { - return; + Uri uri = null; + if (result.getData() != null) { + uri = result.getData().getData(); } - - Uri uri = data.getData(); if (uri == null) { showMessageDialog(R.string.general_error, R.string.invalid_directory); return; 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 89fabbdde..cb6ce263d 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/HistorySettingsFragment.java @@ -5,7 +5,6 @@ import android.os.Bundle; import android.widget.Toast; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; import androidx.preference.Preference; @@ -29,8 +28,9 @@ public class HistorySettingsFragment extends BasePreferenceFragment { private CompositeDisposable disposables; @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResource(R.xml.history_settings); + cacheWipeKey = getString(R.string.metadata_cache_wipe_key); viewsHistoryClearKey = getString(R.string.clear_views_history_key); playbackStatesClearKey = getString(R.string.clear_playback_states_key); @@ -39,11 +39,6 @@ public class HistorySettingsFragment extends BasePreferenceFragment { disposables = new CompositeDisposable(); } - @Override - public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResource(R.xml.history_settings); - } - @Override public boolean onPreferenceTreeClick(final Preference preference) { if (preference.getKey().equals(cacheWipeKey)) { 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 01f51b0b3..33f00ec1a 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java +++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java @@ -2,16 +2,20 @@ package org.schabi.newpipe.settings; import android.content.Context; import android.content.SharedPreferences; +import android.os.Build; import android.os.Environment; import androidx.annotation.NonNull; import androidx.preference.PreferenceManager; 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. * @@ -65,32 +69,36 @@ public final class NewPipeSettings { PreferenceManager.setDefaultValues(context, R.xml.update_settings, true); PreferenceManager.setDefaultValues(context, R.xml.debug_settings, true); - getVideoDownloadFolder(context); - getAudioDownloadFolder(context); + saveDefaultVideoDownloadDirectory(context); + saveDefaultAudioDownloadDirectory(context); SettingMigrations.initMigrations(context, isFirstRun); } - private static void getVideoDownloadFolder(final Context context) { - getDir(context, R.string.download_path_video_key, Environment.DIRECTORY_MOVIES); + static void saveDefaultVideoDownloadDirectory(final Context context) { + saveDefaultDirectory(context, R.string.download_path_video_key, + Environment.DIRECTORY_MOVIES); } - private static void getAudioDownloadFolder(final Context context) { - getDir(context, R.string.download_path_audio_key, Environment.DIRECTORY_MUSIC); + static void saveDefaultAudioDownloadDirectory(final Context context) { + saveDefaultDirectory(context, R.string.download_path_audio_key, + Environment.DIRECTORY_MUSIC); } - private static void getDir(final Context context, final int keyID, - final String defaultDirectoryName) { - final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - final String key = context.getString(keyID); - final String downloadPath = prefs.getString(key, null); - if ((downloadPath != null) && (!downloadPath.isEmpty())) { - return; + private static void saveDefaultDirectory(final Context context, final int keyID, + final String defaultDirectoryName) { + if (!useStorageAccessFramework(context)) { + final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + final String key = context.getString(keyID); + final String downloadPath = prefs.getString(key, null); + if (!isNullOrEmpty(downloadPath)) { + return; + } + + final SharedPreferences.Editor spEditor = prefs.edit(); + spEditor.putString(key, getNewPipeChildFolderPathForDir(getDir(defaultDirectoryName))); + spEditor.apply(); } - - final SharedPreferences.Editor spEditor = prefs.edit(); - spEditor.putString(key, getNewPipeChildFolderPathForDir(getDir(defaultDirectoryName))); - spEditor.apply(); } @NonNull @@ -103,10 +111,17 @@ 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()) { + return false; + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return true; + } + final String key = context.getString(R.string.storage_use_saf); final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); - return prefs.getBoolean(key, false); + return prefs.getBoolean(key, true); } - } 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 bd3cbf79d..207d1ffc6 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java @@ -12,7 +12,6 @@ import android.view.MenuItem; import android.view.MotionEvent; import android.view.View; import android.view.ViewGroup; -import android.widget.EditText; import android.widget.ImageView; import android.widget.ProgressBar; import android.widget.RadioButton; @@ -35,6 +34,7 @@ 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.extractor.services.peertube.PeertubeInstance; import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.PeertubeHelper; @@ -139,16 +139,15 @@ public class PeertubeInstanceListFragment extends Fragment { //////////////////////////////////////////////////////////////////////////*/ @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); final MenuItem restoreItem = menu .add(Menu.NONE, MENU_ITEM_RESTORE_ID, Menu.NONE, R.string.restore_defaults); restoreItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); - - final int restoreIcon = ThemeHelper - .resolveResourceIdFromAttr(requireContext(), R.attr.ic_restore_defaults); - restoreItem.setIcon(AppCompatResources.getDrawable(requireContext(), restoreIcon)); + restoreItem.setIcon(AppCompatResources.getDrawable(requireContext(), + R.drawable.ic_settings_backup_restore)); } @Override @@ -188,7 +187,7 @@ public class PeertubeInstanceListFragment extends Fragment { } private void restoreDefaults() { - new AlertDialog.Builder(requireContext(), ThemeHelper.getDialogTheme(requireContext())) + new AlertDialog.Builder(requireContext()) .setTitle(R.string.restore_defaults) .setMessage(R.string.restore_defaults_confirmation) .setNegativeButton(R.string.cancel, null) @@ -208,20 +207,22 @@ public class PeertubeInstanceListFragment extends Fragment { } private void showAddItemDialog(final Context c) { - final EditText urlET = new EditText(c); - urlET.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_URI); - urlET.setHint(R.string.peertube_instance_add_help); - final AlertDialog dialog = new AlertDialog.Builder(c) + final DialogEditTextBinding 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) + .setView(dialogBinding.getRoot()) .setNegativeButton(R.string.cancel, null) .setPositiveButton(R.string.finish, (dialog1, which) -> { - final String url = urlET.getText().toString(); + final String url = dialogBinding.dialogEditText.getText().toString(); addInstance(url); }) - .create(); - dialog.setView(urlET, 50, 0, 50, 0); - dialog.show(); + .show(); } private void addInstance(final String url) { @@ -281,7 +282,7 @@ public class PeertubeInstanceListFragment extends Fragment { return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.START | ItemTouchHelper.END) { @Override - public int interpolateOutOfBoundsScroll(final RecyclerView recyclerView, + public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView, final int viewSize, final int viewSizeOutOfBounds, final int totalSize, @@ -294,9 +295,9 @@ public class PeertubeInstanceListFragment extends Fragment { } @Override - public boolean onMove(final RecyclerView recyclerView, - final RecyclerView.ViewHolder source, - final RecyclerView.ViewHolder target) { + public boolean onMove(@NonNull final RecyclerView recyclerView, + @NonNull final RecyclerView.ViewHolder source, + @NonNull final RecyclerView.ViewHolder target) { if (source.getItemViewType() != target.getItemViewType() || instanceListAdapter == null) { return false; @@ -319,7 +320,8 @@ public class PeertubeInstanceListFragment extends Fragment { } @Override - public void onSwiped(final RecyclerView.ViewHolder viewHolder, final int swipeDir) { + public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, + final int swipeDir) { final int position = viewHolder.getAdapterPosition(); // do not allow swiping the selected instance if (instanceList.get(position).getUrl().equals(selectedInstance.getUrl())) { diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java index 5c20b752c..9d8736076 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SelectKioskFragment.java @@ -8,6 +8,7 @@ import android.view.ViewGroup; import android.widget.ImageView; import android.widget.TextView; +import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.content.res.AppCompatResources; import androidx.fragment.app.DialogFragment; @@ -92,7 +93,7 @@ public class SelectKioskFragment extends DialogFragment { //////////////////////////////////////////////////////////////////////////*/ @Override - public void onCancel(final DialogInterface dialogInterface) { + public void onCancel(@NonNull final DialogInterface dialogInterface) { super.onCancel(dialogInterface); if (onCancelListener != null) { onCancelListener.onCancel(); @@ -138,6 +139,7 @@ public class SelectKioskFragment extends DialogFragment { return kioskList.size(); } + @NonNull public SelectKioskItemHolder onCreateViewHolder(final ViewGroup parent, final int type) { final View item = LayoutInflater.from(parent.getContext()) .inflate(R.layout.select_kiosk_item, parent, false); 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 c59746428..2d5fedec0 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java @@ -2,6 +2,7 @@ package org.schabi.newpipe.settings; import android.content.Context; import android.content.SharedPreferences; +import android.os.Build; import android.util.Log; import androidx.preference.PreferenceManager; @@ -10,6 +11,7 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.error.ErrorActivity; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; +import org.schabi.newpipe.util.DeviceUtils; import static org.schabi.newpipe.MainActivity.DEBUG; @@ -18,7 +20,7 @@ public final class SettingMigrations { /** * Version number for preferences. Must be incremented every time a migration is necessary. */ - public static final int VERSION = 2; + public static final int VERSION = 3; private static SharedPreferences sp; public static final Migration MIGRATION_0_1 = new Migration(0, 1) { @@ -54,6 +56,22 @@ public final class SettingMigrations { } }; + public static final Migration MIGRATION_2_3 = new Migration(2, 3) { + @Override + protected void migrate(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 + // 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(); + } + }; + /** * List of all implemented migrations. *

@@ -62,7 +80,8 @@ public final class SettingMigrations { */ private static final Migration[] SETTING_MIGRATIONS = { MIGRATION_0_1, - MIGRATION_1_2 + MIGRATION_1_2, + MIGRATION_2_3 }; 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 68908fc92..02e2538c5 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java +++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java @@ -1,6 +1,5 @@ package org.schabi.newpipe.settings; -import android.content.Context; import android.os.Bundle; import android.view.Menu; import android.view.MenuItem; @@ -41,11 +40,6 @@ import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; public class SettingsActivity extends AppCompatActivity implements BasePreferenceFragment.OnPreferenceStartFragmentCallback { - - public static void initSettings(final Context context) { - NewPipeSettings.initSettings(context); - } - @Override protected void onCreate(final Bundle savedInstanceBundle) { setTheme(ThemeHelper.getSettingsThemeStyle(this)); 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 f25b25df2..0ca15e245 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java @@ -2,7 +2,6 @@ package org.schabi.newpipe.settings; import android.os.Bundle; -import androidx.annotation.Nullable; import androidx.preference.Preference; import org.schabi.newpipe.R; @@ -16,15 +15,10 @@ public class UpdateSettingsFragment extends BasePreferenceFragment { }; @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResource(R.xml.update_settings); final String updateToggleKey = getString(R.string.update_app_key); findPreference(updateToggleKey).setOnPreferenceChangeListener(updatePreferenceChange); } - - @Override - public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResource(R.xml.update_settings); - } } 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 5eca99822..c0d274fe0 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java +++ b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java @@ -8,7 +8,6 @@ import android.provider.Settings; import android.text.format.DateUtils; import android.widget.Toast; -import androidx.annotation.Nullable; import androidx.preference.ListPreference; import com.google.android.material.snackbar.Snackbar; @@ -23,8 +22,8 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment { private SharedPreferences.OnSharedPreferenceChangeListener listener; @Override - public void onCreate(@Nullable final Bundle savedInstanceState) { - super.onCreate(savedInstanceState); + public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { + addPreferencesFromResource(R.xml.video_audio_settings); updateSeekOptions(); @@ -104,11 +103,6 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment { } } - @Override - public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) { - addPreferencesFromResource(R.xml.video_audio_settings); - } - @Override public void onResume() { super.onResume(); 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 e50c858ca..045e574be 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 @@ -17,6 +17,7 @@ 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; @@ -41,9 +42,8 @@ public class NotificationActionsPreference extends Preference { } - private NotificationSlot[] notificationSlots; - - private List compactSlots; + @Nullable private NotificationSlot[] notificationSlots = null; + @Nullable private List compactSlots = null; //////////////////////////////////////////////////////////////////////////// // Lifecycle @@ -85,19 +85,22 @@ public class NotificationActionsPreference extends Preference { //////////////////////////////////////////////////////////////////////////// private void saveChanges() { - final SharedPreferences.Editor editor = getSharedPreferences().edit(); + if (compactSlots != null && notificationSlots != null) { + final SharedPreferences.Editor editor = getSharedPreferences().edit(); - for (int i = 0; i < 3; i++) { - editor.putInt(getContext().getString(NotificationConstants.SLOT_COMPACT_PREF_KEYS[i]), - (i < compactSlots.size() ? compactSlots.get(i) : -1)); + for (int i = 0; i < 3; i++) { + editor.putInt(getContext().getString( + NotificationConstants.SLOT_COMPACT_PREF_KEYS[i]), + (i < compactSlots.size() ? compactSlots.get(i) : -1)); + } + + for (int i = 0; i < 5; i++) { + editor.putInt(getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]), + notificationSlots[i].selectedAction); + } + + editor.apply(); } - - for (int i = 0; i < 5; i++) { - editor.putInt(getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]), - notificationSlots[i].selectedAction); - } - - editor.apply(); } diff --git a/app/src/main/java/org/schabi/newpipe/settings/tabs/AddTabDialog.java b/app/src/main/java/org/schabi/newpipe/settings/tabs/AddTabDialog.java index 52e50fbba..e2e833fee 100644 --- a/app/src/main/java/org/schabi/newpipe/settings/tabs/AddTabDialog.java +++ b/app/src/main/java/org/schabi/newpipe/settings/tabs/AddTabDialog.java @@ -1,6 +1,5 @@ package org.schabi.newpipe.settings.tabs; -import android.app.AlertDialog; import android.content.Context; import android.content.DialogInterface; import android.view.LayoutInflater; @@ -11,10 +10,10 @@ import android.widget.TextView; import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; import androidx.appcompat.widget.AppCompatImageView; import org.schabi.newpipe.R; -import org.schabi.newpipe.util.ThemeHelper; public final class AddTabDialog { private final AlertDialog dialog; @@ -60,7 +59,7 @@ public final class AddTabDialog { private DialogListAdapter(final Context context, final ChooseTabListItem[] items) { this.inflater = LayoutInflater.from(context); this.items = items; - this.fallbackIcon = ThemeHelper.resolveResourceIdFromAttr(context, R.attr.ic_kiosk_hot); + this.fallbackIcon = R.drawable.ic_whatshot; } @Override 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 572741d03..6e50765ba 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 @@ -106,16 +106,15 @@ public class ChooseTabsFragment extends Fragment { //////////////////////////////////////////////////////////////////////////*/ @Override - public void onCreateOptionsMenu(final Menu menu, final MenuInflater inflater) { + public void onCreateOptionsMenu(@NonNull final Menu menu, + @NonNull final MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); final MenuItem restoreItem = menu.add(Menu.NONE, MENU_ITEM_RESTORE_ID, Menu.NONE, R.string.restore_defaults); restoreItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS); - - final int restoreIcon = ThemeHelper.resolveResourceIdFromAttr(requireContext(), - R.attr.ic_restore_defaults); - restoreItem.setIcon(AppCompatResources.getDrawable(requireContext(), restoreIcon)); + restoreItem.setIcon(AppCompatResources.getDrawable(requireContext(), + R.drawable.ic_settings_backup_restore)); } @Override @@ -142,7 +141,7 @@ public class ChooseTabsFragment extends Fragment { } private void restoreDefaults() { - new AlertDialog.Builder(requireContext(), ThemeHelper.getDialogTheme(requireContext())) + new AlertDialog.Builder(requireContext()) .setTitle(R.string.restore_defaults) .setMessage(R.string.restore_defaults_confirmation) .setNegativeButton(R.string.cancel, null) @@ -194,13 +193,13 @@ public class ChooseTabsFragment extends Fragment { final SelectKioskFragment selectKioskFragment = new SelectKioskFragment(); selectKioskFragment.setOnSelectedListener((serviceId, kioskId, kioskName) -> addTab(new Tab.KioskTab(serviceId, kioskId))); - selectKioskFragment.show(requireFragmentManager(), "select_kiosk"); + selectKioskFragment.show(getParentFragmentManager(), "select_kiosk"); return; case CHANNEL: final SelectChannelFragment selectChannelFragment = new SelectChannelFragment(); selectChannelFragment.setOnSelectedListener((serviceId, url, name) -> addTab(new Tab.ChannelTab(serviceId, url, name))); - selectChannelFragment.show(requireFragmentManager(), "select_channel"); + selectChannelFragment.show(getParentFragmentManager(), "select_channel"); return; case PLAYLIST: final SelectPlaylistFragment selectPlaylistFragment = new SelectPlaylistFragment(); @@ -217,7 +216,7 @@ public class ChooseTabsFragment extends Fragment { addTab(new Tab.PlaylistTab(serviceId, url, name)); } }); - selectPlaylistFragment.show(requireFragmentManager(), "select_playlist"); + selectPlaylistFragment.show(getParentFragmentManager(), "select_playlist"); return; default: addTab(type.getTab()); @@ -241,7 +240,7 @@ public class ChooseTabsFragment extends Fragment { case KIOSK: returnList.add(new ChooseTabListItem(tab.getTabId(), getString(R.string.kiosk_page_summary), - ThemeHelper.resolveResourceIdFromAttr(context, R.attr.ic_kiosk_hot))); + R.drawable.ic_whatshot)); break; case CHANNEL: returnList.add(new ChooseTabListItem(tab.getTabId(), @@ -252,8 +251,7 @@ public class ChooseTabsFragment extends Fragment { if (!tabList.contains(tab)) { returnList.add(new ChooseTabListItem(tab.getTabId(), getString(R.string.default_kiosk_page_summary), - ThemeHelper.resolveResourceIdFromAttr(context, - R.attr.ic_kiosk_hot))); + R.drawable.ic_whatshot)); } break; case PLAYLIST: @@ -280,7 +278,7 @@ public class ChooseTabsFragment extends Fragment { return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN, ItemTouchHelper.START | ItemTouchHelper.END) { @Override - public int interpolateOutOfBoundsScroll(final RecyclerView recyclerView, + public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView, final int viewSize, final int viewSizeOutOfBounds, final int totalSize, @@ -293,9 +291,9 @@ public class ChooseTabsFragment extends Fragment { } @Override - public boolean onMove(final RecyclerView recyclerView, - final RecyclerView.ViewHolder source, - final RecyclerView.ViewHolder target) { + public boolean onMove(@NonNull final RecyclerView recyclerView, + @NonNull final RecyclerView.ViewHolder source, + @NonNull final RecyclerView.ViewHolder target) { if (source.getItemViewType() != target.getItemViewType() || selectedTabsAdapter == null) { return false; @@ -318,7 +316,8 @@ public class ChooseTabsFragment extends Fragment { } @Override - public void onSwiped(final RecyclerView.ViewHolder viewHolder, final int swipeDir) { + public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder, + final int swipeDir) { final int position = viewHolder.getAdapterPosition(); tabList.remove(position); selectedTabsAdapter.notifyItemRemoved(position); 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 0ffda2261..a148255b3 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 @@ -30,7 +30,6 @@ import org.schabi.newpipe.local.playlist.LocalPlaylistFragment; import org.schabi.newpipe.local.subscription.SubscriptionFragment; import org.schabi.newpipe.util.KioskTranslator; import org.schabi.newpipe.util.ServiceHelper; -import org.schabi.newpipe.util.ThemeHelper; import java.util.Objects; @@ -113,12 +112,16 @@ public abstract class Tab { @Override public boolean equals(final Object obj) { - if (obj == this) { - return true; + if (!(obj instanceof Tab)) { + return false; } + final Tab other = (Tab) obj; + return getTabId() == other.getTabId(); + } - return obj instanceof Tab && obj.getClass() == this.getClass() - && ((Tab) obj).getTabId() == this.getTabId(); + @Override + public int hashCode() { + return Objects.hashCode(getTabId()); } /*////////////////////////////////////////////////////////////////////////// @@ -188,7 +191,7 @@ public abstract class Tab { @DrawableRes @Override public int getTabIconRes(final Context context) { - return ThemeHelper.resolveResourceIdFromAttr(context, R.attr.ic_blank_page); + return R.drawable.ic_crop_portrait; } @Override @@ -213,7 +216,7 @@ public abstract class Tab { @DrawableRes @Override public int getTabIconRes(final Context context) { - return ThemeHelper.resolveResourceIdFromAttr(context, R.attr.ic_channel); + return R.drawable.ic_tv; } @Override @@ -239,7 +242,7 @@ public abstract class Tab { @DrawableRes @Override public int getTabIconRes(final Context context) { - return ThemeHelper.resolveResourceIdFromAttr(context, R.attr.ic_rss); + return R.drawable.ic_rss_feed; } @Override @@ -264,7 +267,7 @@ public abstract class Tab { @DrawableRes @Override public int getTabIconRes(final Context context) { - return ThemeHelper.resolveResourceIdFromAttr(context, R.attr.ic_bookmark); + return R.drawable.ic_bookmark; } @Override @@ -289,7 +292,7 @@ public abstract class Tab { @DrawableRes @Override public int getTabIconRes(final Context context) { - return ThemeHelper.resolveResourceIdFromAttr(context, R.attr.ic_history); + return R.drawable.ic_history; } @Override @@ -359,8 +362,18 @@ public abstract class Tab { @Override public boolean equals(final Object obj) { - return super.equals(obj) && kioskServiceId == ((KioskTab) obj).kioskServiceId - && Objects.equals(kioskId, ((KioskTab) obj).kioskId); + if (!(obj instanceof KioskTab)) { + return false; + } + final KioskTab other = (KioskTab) obj; + return super.equals(obj) + && kioskServiceId == other.kioskServiceId + && kioskId.equals(other.kioskId); + } + + @Override + public int hashCode() { + return Objects.hash(getTabId(), kioskServiceId, kioskId); } public int getKioskServiceId() { @@ -409,7 +422,7 @@ public abstract class Tab { @DrawableRes @Override public int getTabIconRes(final Context context) { - return ThemeHelper.resolveResourceIdFromAttr(context, R.attr.ic_channel); + return R.drawable.ic_tv; } @Override @@ -433,9 +446,19 @@ public abstract class Tab { @Override public boolean equals(final Object obj) { - return super.equals(obj) && channelServiceId == ((ChannelTab) obj).channelServiceId - && Objects.equals(channelUrl, ((ChannelTab) obj).channelUrl) - && Objects.equals(channelName, ((ChannelTab) obj).channelName); + if (!(obj instanceof ChannelTab)) { + return false; + } + final ChannelTab other = (ChannelTab) obj; + return super.equals(obj) + && channelServiceId == other.channelServiceId + && channelUrl.equals(other.channelName) + && channelName.equals(other.channelName); + } + + @Override + public int hashCode() { + return Objects.hash(getTabId(), channelServiceId, channelUrl, channelName); } public int getChannelServiceId() { @@ -541,7 +564,7 @@ public abstract class Tab { @DrawableRes @Override public int getTabIconRes(final Context context) { - return ThemeHelper.resolveResourceIdFromAttr(context, R.attr.ic_bookmark); + return R.drawable.ic_bookmark; } @Override @@ -577,15 +600,30 @@ public abstract class Tab { @Override public boolean equals(final Object obj) { - if (!(super.equals(obj) - && Objects.equals(playlistType, ((PlaylistTab) obj).playlistType) - && Objects.equals(playlistName, ((PlaylistTab) obj).playlistName))) { - return false; // base objects are different + if (!(obj instanceof PlaylistTab)) { + return false; } - return (playlistId == ((PlaylistTab) obj).playlistId) // local - || (playlistServiceId == ((PlaylistTab) obj).playlistServiceId // remote - && Objects.equals(playlistUrl, ((PlaylistTab) obj).playlistUrl)); + final PlaylistTab other = (PlaylistTab) obj; + + return super.equals(obj) + && playlistServiceId == other.playlistServiceId // Remote + && playlistId == other.playlistId // Local + && playlistUrl.equals(other.playlistUrl) + && playlistName.equals(other.playlistName) + && playlistType == other.playlistType; + } + + @Override + public int hashCode() { + return Objects.hash( + getTabId(), + playlistServiceId, + playlistId, + playlistUrl, + playlistName, + playlistType + ); } public int getPlaylistServiceId() { 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 55792d099..ebae3812c 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java +++ b/app/src/main/java/org/schabi/newpipe/streams/WebMWriter.java @@ -712,7 +712,7 @@ public class WebMWriter implements Closeable { return 0; } - // TODO: in the adove code, find and select the shortest track for the desired kind + // TODO: in the above code, find and select the shortest track for the desired kind for (i = 0; i < infoTracks.length; i++) { if (kind == infoTracks[i].trackType) { return i; diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/SharpInputStream.java b/app/src/main/java/org/schabi/newpipe/streams/io/SharpInputStream.java new file mode 100644 index 000000000..956e9865c --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/io/SharpInputStream.java @@ -0,0 +1,52 @@ +package org.schabi.newpipe.streams.io; + +import androidx.annotation.NonNull; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Simply wraps a readable {@link SharpStream} allowing it to be used with built-in Java stuff that + * supports {@link InputStream}. + */ +public class SharpInputStream extends InputStream { + private final SharpStream stream; + + public SharpInputStream(final SharpStream stream) throws IOException { + if (!stream.canRead()) { + throw new IOException("SharpStream is not readable"); + } + this.stream = stream; + } + + @Override + public int read() throws IOException { + return stream.read(); + } + + @Override + public int read(@NonNull final byte[] b) throws IOException { + return stream.read(b); + } + + @Override + public int read(@NonNull final byte[] b, final int off, final int len) throws IOException { + return stream.read(b, off, len); + } + + @Override + public long skip(final long n) throws IOException { + return stream.skip(n); + } + + @Override + public int available() { + final long res = stream.available(); + return res > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) res; + } + + @Override + public void close() { + stream.close(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/SharpOutputStream.java b/app/src/main/java/org/schabi/newpipe/streams/io/SharpOutputStream.java new file mode 100644 index 000000000..76e394312 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/io/SharpOutputStream.java @@ -0,0 +1,46 @@ +package org.schabi.newpipe.streams.io; + +import androidx.annotation.NonNull; + +import java.io.IOException; +import java.io.OutputStream; + +/** + * Simply wraps a writable {@link SharpStream} allowing it to be used with built-in Java stuff that + * supports {@link OutputStream}. + */ +public class SharpOutputStream extends OutputStream { + private final SharpStream stream; + + public SharpOutputStream(final SharpStream stream) throws IOException { + if (!stream.canWrite()) { + throw new IOException("SharpStream is not writable"); + } + this.stream = stream; + } + + @Override + public void write(final int b) throws IOException { + stream.write((byte) b); + } + + @Override + public void write(@NonNull final byte[] b) throws IOException { + stream.write(b); + } + + @Override + public void write(@NonNull final byte[] b, final int off, final int len) throws IOException { + stream.write(b, off, len); + } + + @Override + public void flush() throws IOException { + stream.flush(); + } + + @Override + public void close() { + stream.close(); + } +} diff --git a/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java b/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java index 46ec68d9e..849c7c051 100644 --- a/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java +++ b/app/src/main/java/org/schabi/newpipe/streams/io/SharpStream.java @@ -1,12 +1,20 @@ package org.schabi.newpipe.streams.io; import java.io.Closeable; +import java.io.Flushable; import java.io.IOException; /** - * Based on C#'s Stream class. + * Based on C#'s Stream class. SharpStream is a wrapper around the 2 different APIs for SAF + * ({@link us.shandian.giga.io.FileStreamSAF}) and non-SAF ({@link us.shandian.giga.io.FileStream}). + * It has both input and output like in C#, while in Java those are usually different classes. + * {@link SharpInputStream} and {@link SharpOutputStream} are simple classes that wrap + * {@link SharpStream} and extend respectively {@link java.io.InputStream} and + * {@link java.io.OutputStream}, since unfortunately a class can only extend one class, so that a + * sharp stream can be used with built-in Java stuff that supports {@link java.io.InputStream} + * or {@link java.io.OutputStream}. */ -public abstract class SharpStream implements Closeable { +public abstract class SharpStream implements Closeable, Flushable { public abstract int read() throws IOException; public abstract int read(byte[] buffer) throws IOException; diff --git a/app/src/main/java/us/shandian/giga/io/StoredDirectoryHelper.java b/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java similarity index 50% rename from app/src/main/java/us/shandian/giga/io/StoredDirectoryHelper.java rename to app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java index 5edc5f3ed..feca89f02 100644 --- a/app/src/main/java/us/shandian/giga/io/StoredDirectoryHelper.java +++ b/app/src/main/java/org/schabi/newpipe/streams/io/StoredDirectoryHelper.java @@ -1,6 +1,5 @@ -package us.shandian.giga.io; +package org.schabi.newpipe.streams.io; -import android.annotation.TargetApi; import android.content.ContentResolver; import android.content.Context; import android.content.Intent; @@ -13,6 +12,9 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; 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; @@ -21,10 +23,11 @@ import java.util.Collections; 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; public class StoredDirectoryHelper { - public final static int PERMISSION_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + public static final int PERMISSION_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; private File ioTree; private DocumentFile docTree; @@ -33,7 +36,8 @@ public class StoredDirectoryHelper { private final String tag; - public StoredDirectoryHelper(@NonNull Context context, @NonNull Uri path, String tag) throws IOException { + public StoredDirectoryHelper(@NonNull final Context context, @NonNull final Uri path, + final String tag) throws IOException { this.tag = tag; if (ContentResolver.SCHEME_FILE.equalsIgnoreCase(path.getScheme())) { @@ -45,51 +49,49 @@ public class StoredDirectoryHelper { try { this.context.getContentResolver().takePersistableUriPermission(path, PERMISSION_FLAGS); - } catch (Exception e) { + } catch (final Exception e) { throw new IOException(e); } - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) + 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) + if (this.docTree == null) { throw new IOException("Failed to create the tree from Uri"); + } } - @TargetApi(Build.VERSION_CODES.KITKAT) - public StoredDirectoryHelper(@NonNull URI location, String tag) { - ioTree = new File(location); - this.tag = tag; - } - - public StoredFileHelper createFile(String filename, String mime) { + public StoredFileHelper createFile(final String filename, final String mime) { return createFile(filename, mime, false); } - public StoredFileHelper createUniqueFile(String name, String mime) { - ArrayList matches = new ArrayList<>(); - String[] filename = splitFilename(name); - String lcFilename = filename[0].toLowerCase(); + public StoredFileHelper createUniqueFile(final String name, final String mime) { + final ArrayList matches = new ArrayList<>(); + final String[] filename = splitFilename(name); + final String lcFilename = filename[0].toLowerCase(); if (docTree == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - for (File file : ioTree.listFiles()) + for (final File file : ioTree.listFiles()) { addIfStartWith(matches, lcFilename, file.getName()); + } } else { // warning: SAF file listing is very slow - Uri docTreeChildren = DocumentsContract.buildChildDocumentsUriUsingTree( - docTree.getUri(), DocumentsContract.getDocumentId(docTree.getUri()) - ); + final Uri docTreeChildren = DocumentsContract.buildChildDocumentsUriUsingTree( + docTree.getUri(), DocumentsContract.getDocumentId(docTree.getUri())); - String[] projection = {COLUMN_DISPLAY_NAME}; - String selection = "(LOWER(" + COLUMN_DISPLAY_NAME + ") LIKE ?%"; - ContentResolver cr = context.getContentResolver(); + final String[] projection = new String[]{COLUMN_DISPLAY_NAME}; + final String selection = "(LOWER(" + COLUMN_DISPLAY_NAME + ") LIKE ?%"; + final ContentResolver cr = context.getContentResolver(); - try (Cursor cursor = cr.query(docTreeChildren, projection, selection, new String[]{lcFilename}, null)) { + try (Cursor cursor = cr.query(docTreeChildren, projection, selection, + new String[]{lcFilename}, null)) { if (cursor != null) { - while (cursor.moveToNext()) + while (cursor.moveToNext()) { addIfStartWith(matches, lcFilename, cursor.getString(0)); + } } } } @@ -99,7 +101,7 @@ public class StoredDirectoryHelper { } else { // check if the filename is in use String lcName = name.toLowerCase(); - for (String testName : matches) { + for (final String testName : matches) { if (testName.equals(lcName)) { lcName = null; break; @@ -107,28 +109,34 @@ public class StoredDirectoryHelper { } // check if not in use - if (lcName != null) return createFile(name, mime, true); + 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); + } } - return createFile(String.valueOf(System.currentTimeMillis()).concat(filename[1]), mime, false); + return createFile(String.valueOf(System.currentTimeMillis()).concat(filename[1]), mime, + false); } - private StoredFileHelper createFile(String filename, String mime, boolean safe) { - StoredFileHelper storage; + private StoredFileHelper createFile(final String filename, final String mime, + final boolean safe) { + final StoredFileHelper storage; try { - if (docTree == null) + if (docTree == null) { storage = new StoredFileHelper(ioTree, filename, mime); - else + } else { storage = new StoredFileHelper(context, docTree, filename, mime, safe); - } catch (IOException e) { + } + } catch (final IOException e) { return null; } @@ -146,7 +154,7 @@ public class StoredDirectoryHelper { } /** - * Indicates whatever if is possible access using the {@code java.io} API + * Indicates whether it's using the {@code java.io} API. * * @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework */ @@ -169,7 +177,9 @@ public class StoredDirectoryHelper { return ioTree.exists() || ioTree.mkdirs(); } - if (docTree.exists()) return true; + if (docTree.exists()) { + return true; + } try { DocumentFile parent; @@ -177,14 +187,18 @@ public class StoredDirectoryHelper { while (true) { parent = docTree.getParentFile(); - if (parent == null || child == null) break; - if (parent.exists()) return true; + if (parent == null || child == null) { + break; + } + if (parent.exists()) { + return true; + } parent.createDirectory(child); - child = parent.getName();// for the next iteration + child = parent.getName(); // for the next iteration } - } catch (Exception e) { + } catch (final Exception ignored) { // no more parent directories or unsupported by the storage provider } @@ -195,13 +209,13 @@ public class StoredDirectoryHelper { return tag; } - public Uri findFile(String filename) { + public Uri findFile(final String filename) { if (docTree == null) { - File res = new File(ioTree, filename); + final File res = new File(ioTree, filename); return res.exists() ? Uri.fromFile(res) : null; } - DocumentFile res = findFileSAFHelper(context, docTree, filename); + final DocumentFile res = findFileSAFHelper(context, docTree, filename); return res == null ? null : res.getUri(); } @@ -209,82 +223,115 @@ public class StoredDirectoryHelper { return docTree == null ? ioTree.canWrite() : docTree.canWrite(); } + /** + * @return {@code false} if the storage is direct, or the SAF storage is valid; {@code true} if + * SAF access to this SAF storage is denied (e.g. the user clicked on {@code Android settings -> + * Apps & notifications -> NewPipe -> Storage & cache -> Clear access}); + */ + public boolean isInvalidSafStorage() { + return docTree != null && docTree.getName() == null; + } + @NonNull @Override public String toString() { return (docTree == null ? Uri.fromFile(ioTree) : docTree.getUri()).toString(); } - //////////////////// // Utils /////////////////// - private static void addIfStartWith(ArrayList list, @NonNull String base, String str) { - if (str == null || str.isEmpty()) return; - str = str.toLowerCase(); - if (str.startsWith(base)) list.add(str); + private static void addIfStartWith(final ArrayList list, @NonNull final String base, + final String str) { + if (isNullOrEmpty(str)) { + return; + } + final String lowerStr = str.toLowerCase(); + if (lowerStr.startsWith(base)) { + list.add(lowerStr); + } } - private static String[] splitFilename(@NonNull String filename) { - int dotIndex = filename.lastIndexOf('.'); + private static String[] splitFilename(@NonNull final String filename) { + final int dotIndex = filename.lastIndexOf('.'); - if (dotIndex < 0 || (dotIndex == filename.length() - 1)) + if (dotIndex < 0 || (dotIndex == filename.length() - 1)) { return new String[]{filename, ""}; + } return new String[]{filename.substring(0, dotIndex), filename.substring(dotIndex)}; } - private static String makeFileName(String name, int idx, String ext) { + private static String makeFileName(final String name, final int idx, final String ext) { return name.concat(" (").concat(String.valueOf(idx)).concat(")").concat(ext); } /** - * Fast (but not enough) file/directory finder under the storage access framework + * Fast (but not enough) file/directory finder under the storage access framework. * * @param context The context * @param tree Directory where search * @param filename Target filename * @return A {@link DocumentFile} contain the reference, otherwise, null */ - static DocumentFile findFileSAFHelper(@Nullable Context context, DocumentFile tree, String filename) { + static DocumentFile findFileSAFHelper(@Nullable final Context context, final DocumentFile tree, + final String filename) { if (context == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { - return tree.findFile(filename);// warning: this is very slow + return tree.findFile(filename); // warning: this is very slow } - if (!tree.canRead()) return null;// missing read permission + if (!tree.canRead()) { + return null; // missing read permission + } final int name = 0; final int documentId = 1; // LOWER() SQL function is not supported - String selection = COLUMN_DISPLAY_NAME + " = ?"; - //String selection = COLUMN_DISPLAY_NAME + " LIKE ?%"; + final String selection = COLUMN_DISPLAY_NAME + " = ?"; + //final String selection = COLUMN_DISPLAY_NAME + " LIKE ?%"; - Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree( - tree.getUri(), DocumentsContract.getDocumentId(tree.getUri()) - ); - String[] projection = {COLUMN_DISPLAY_NAME, COLUMN_DOCUMENT_ID}; - ContentResolver contentResolver = context.getContentResolver(); + final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(tree.getUri(), + DocumentsContract.getDocumentId(tree.getUri())); + final String[] projection = {COLUMN_DISPLAY_NAME, COLUMN_DOCUMENT_ID}; + final ContentResolver contentResolver = context.getContentResolver(); - filename = filename.toLowerCase(); + final String lowerFilename = filename.toLowerCase(); - try (Cursor cursor = contentResolver.query(childrenUri, projection, selection, new String[]{filename}, null)) { - if (cursor == null) return null; + try (Cursor cursor = contentResolver.query(childrenUri, projection, selection, + new String[]{lowerFilename}, null)) { + if (cursor == null) { + return null; + } while (cursor.moveToNext()) { - if (cursor.isNull(name) || !cursor.getString(name).toLowerCase().startsWith(filename)) + if (cursor.isNull(name) + || !cursor.getString(name).toLowerCase().startsWith(lowerFilename)) { continue; + } - return DocumentFile.fromSingleUri( - context, DocumentsContract.buildDocumentUriUsingTree( - tree.getUri(), cursor.getString(documentId) - ) - ); + return DocumentFile.fromSingleUri(context, + DocumentsContract.buildDocumentUriUsingTree(tree.getUri(), + cursor.getString(documentId))); } } return null; } + public static Intent getPicker(final Context ctx) { + if (NewPipeSettings.useStorageAccessFramework(ctx)) { + return new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) + .putExtra("android.content.extra.SHOW_ADVANCED", true) + .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | StoredDirectoryHelper.PERMISSION_FLAGS); + } else { + return new Intent(ctx, FilePickerActivityHelper.class) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) + .putExtra(FilePickerActivityHelper.EXTRA_MODE, + FilePickerActivityHelper.MODE_DIR); + } + } } 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 new file mode 100644 index 000000000..dd379b730 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/streams/io/StoredFileHelper.java @@ -0,0 +1,565 @@ +package org.schabi.newpipe.streams.io; + +import android.annotation.TargetApi; +import android.content.ContentResolver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Environment; +import android.provider.DocumentsContract; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.documentfile.provider.DocumentFile; + +import com.nononsenseapps.filepicker.Utils; + +import org.schabi.newpipe.MainActivity; +import org.schabi.newpipe.settings.NewPipeSettings; +import org.schabi.newpipe.util.FilePickerActivityHelper; + +import java.io.File; +import java.io.IOException; +import java.io.Serializable; +import java.net.URI; + +import us.shandian.giga.io.FileStream; +import us.shandian.giga.io.FileStreamSAF; + +public class StoredFileHelper implements Serializable { + private static final boolean DEBUG = MainActivity.DEBUG; + private static final String TAG = StoredFileHelper.class.getSimpleName(); + + private static final long serialVersionUID = 0L; + public static final String DEFAULT_MIME = "application/octet-stream"; + + private transient DocumentFile docFile; + private transient DocumentFile docTree; + private transient File ioFile; + private transient Context context; + + protected String source; + private String sourceTree; + + protected String tag; + + private String srcName; + private String srcType; + + public StoredFileHelper(final Context context, final Uri uri, final String mime) { + if (FilePickerActivityHelper.isOwnFileUri(context, uri)) { + ioFile = Utils.getFileForUri(uri); + source = Uri.fromFile(ioFile).toString(); + } else { + docFile = DocumentFile.fromSingleUri(context, uri); + source = uri.toString(); + } + + this.context = context; + this.srcType = mime; + } + + public StoredFileHelper(@Nullable final Uri parent, final String filename, final String mime, + final String tag) { + this.source = null; // this instance will be "invalid" see invalidate()/isInvalid() methods + + this.srcName = filename; + this.srcType = mime == null ? DEFAULT_MIME : mime; + if (parent != null) { + this.sourceTree = parent.toString(); + } + + 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 { + this.docTree = tree; + this.context = context; + + final DocumentFile res; + + if (safe) { + // no conflicts (the filename is not in use) + res = this.docTree.createFile(mime, filename); + if (res == null) { + throw new IOException("Cannot create the file"); + } + } else { + res = createSAF(context, mime, filename); + } + + this.docFile = res; + + this.source = docFile.getUri().toString(); + this.sourceTree = docTree.getUri().toString(); + + this.srcName = this.docFile.getName(); + this.srcType = this.docFile.getType(); + } + + StoredFileHelper(final File location, final String filename, final String mime) + throws IOException { + this.ioFile = new File(location, 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"); + } + } + + this.source = Uri.fromFile(this.ioFile).toString(); + this.sourceTree = Uri.fromFile(location).toString(); + + this.srcName = ioFile.getName(); + this.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; + this.source = path.toString(); + + if (path.getScheme() == null + || path.getScheme().equalsIgnoreCase(ContentResolver.SCHEME_FILE)) { + this.ioFile = new File(URI.create(this.source)); + } else { + final DocumentFile file = DocumentFile.fromSingleUri(context, path); + + if (file == null) { + throw new RuntimeException("SAF not available"); + } + + this.context = context; + + if (file.getName() == null) { + this.source = null; + return; + } else { + this.docFile = file; + takePermissionSAF(); + } + } + + if (parent != null) { + if (!ContentResolver.SCHEME_FILE.equals(parent.getScheme())) { + this.docTree = DocumentFile.fromTreeUri(context, parent); + } + + this.sourceTree = parent.toString(); + } + + this.srcName = getName(); + this.srcType = getType(); + } + + + public static StoredFileHelper deserialize(@NonNull final StoredFileHelper storage, + final Context context) throws IOException { + final Uri treeUri = storage.sourceTree == null ? null : Uri.parse(storage.sourceTree); + + if (storage.isInvalid()) { + return new StoredFileHelper(treeUri, storage.srcName, storage.srcType, storage.tag); + } + + final StoredFileHelper instance = new StoredFileHelper(context, treeUri, + Uri.parse(storage.source), storage.tag); + + // under SAF, if the target document is deleted, conserve the filename and mime + if (instance.srcName == null) { + instance.srcName = storage.srcName; + } + if (instance.srcType == null) { + instance.srcType = storage.srcType; + } + + return instance; + } + + public SharpStream getStream() throws IOException { + assertValid(); + + if (docFile == null) { + return new FileStream(ioFile); + } else { + return new FileStreamSAF(context.getContentResolver(), docFile.getUri()); + } + } + + /** + * Indicates whether it's using the {@code java.io} API. + * + * @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework + */ + public boolean isDirect() { + assertValid(); + + return docFile == null; + } + + public boolean isInvalid() { + return source == null; + } + + public Uri getUri() { + assertValid(); + + return docFile == null ? Uri.fromFile(ioFile) : docFile.getUri(); + } + + public Uri getParentUri() { + assertValid(); + + return sourceTree == null ? null : Uri.parse(sourceTree); + } + + public void truncate() throws IOException { + assertValid(); + + try (SharpStream fs = getStream()) { + fs.setLength(0); + } + } + + public boolean delete() { + if (source == null) { + return true; + } + if (docFile == null) { + return ioFile.delete(); + } + + final boolean res = docFile.delete(); + + try { + final int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; + context.getContentResolver().releasePersistableUriPermission(docFile.getUri(), flags); + } catch (final Exception ex) { + // nothing to do + } + + return res; + } + + public long length() { + assertValid(); + + return docFile == null ? ioFile.length() : docFile.length(); + } + + public boolean canWrite() { + if (source == null) { + return false; + } + return docFile == null ? ioFile.canWrite() : docFile.canWrite(); + } + + public String getName() { + if (source == null) { + return srcName; + } else if (docFile == null) { + return ioFile.getName(); + } + + final String name = docFile.getName(); + return name == null ? srcName : name; + } + + public String getType() { + if (source == null || docFile == null) { + return srcType; + } + + final String type = docFile.getType(); + return type == null ? srcType : type; + } + + public String getTag() { + return tag; + } + + public boolean existsAsFile() { + if (source == null || (docFile == null && ioFile == 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) + "]"); + } + return false; + } + + // WARNING: DocumentFile.exists() and DocumentFile.isFile() methods are slow + // docFile.isVirtual() means it is non-physical? + return docFile == null + ? (ioFile.exists() && ioFile.isFile()) + : (docFile.exists() && docFile.isFile()); + } + + public boolean create() { + assertValid(); + final boolean result; + + if (docFile == null) { + try { + result = ioFile.createNewFile(); + } catch (final IOException e) { + return false; + } + } else if (docTree == null) { + result = false; + } else { + if (!docTree.canRead() || !docTree.canWrite()) { + return false; + } + try { + docFile = createSAF(context, srcType, srcName); + if (docFile.getName() == null) { + return false; + } + result = true; + } catch (final IOException e) { + return false; + } + } + + if (result) { + source = (docFile == null ? Uri.fromFile(ioFile) : docFile.getUri()).toString(); + srcName = getName(); + srcType = getType(); + } + + return result; + } + + public void invalidate() { + if (source == null) { + return; + } + + srcName = getName(); + srcType = getType(); + + source = null; + + docTree = null; + docFile = null; + ioFile = null; + context = null; + } + + public boolean equals(final StoredFileHelper storage) { + if (this == storage) { + return true; + } + + // note: do not compare tags, files can have the same parent folder + //if (stringMismatch(this.tag, storage.tag)) return false; + + if (stringMismatch(getLowerCase(this.sourceTree), getLowerCase(this.sourceTree))) { + return false; + } + + if (this.isInvalid() || storage.isInvalid()) { + if (this.srcName == null || storage.srcName == null || this.srcType == null + || storage.srcType == null) { + return false; + } + + return this.srcName.equalsIgnoreCase(storage.srcName) + && this.srcType.equalsIgnoreCase(storage.srcType); + } + + if (this.isDirect() != storage.isDirect()) { + return false; + } + + if (this.isDirect()) { + return this.ioFile.getPath().equalsIgnoreCase(storage.ioFile.getPath()); + } + + return DocumentsContract.getDocumentId(this.docFile.getUri()) + .equalsIgnoreCase(DocumentsContract.getDocumentId(storage.docFile.getUri())); + } + + @NonNull + @Override + public String toString() { + if (source == null) { + return "[Invalid state] name=" + srcName + " type=" + srcType + " tag=" + tag; + } else { + return "sourceFile=" + source + " treeSource=" + (sourceTree == null ? "" : sourceTree) + + " tag=" + tag; + } + } + + + private void assertValid() { + if (source == null) { + throw new IllegalStateException("In invalid state"); + } + } + + private void takePermissionSAF() throws IOException { + try { + context.getContentResolver().takePersistableUriPermission(docFile.getUri(), + StoredDirectoryHelper.PERMISSION_FLAGS); + } catch (final Exception e) { + if (docFile.getName() == null) { + throw new IOException(e); + } + } + } + + @NonNull + private DocumentFile createSAF(@Nullable final Context ctx, final String mime, + final String filename) throws IOException { + DocumentFile res = StoredDirectoryHelper.findFileSAFHelper(ctx, docTree, filename); + + if (res != null && res.exists() && res.isDirectory()) { + if (!res.delete()) { + throw new IOException("Directory with the same name found but cannot delete"); + } + res = null; + } + + if (res == null) { + res = this.docTree.createFile(srcType == null ? DEFAULT_MIME : mime, filename); + if (res == null) { + throw new IOException("Cannot create the file"); + } + } + + return res; + } + + private String getLowerCase(final String str) { + return str == null ? null : str.toLowerCase(); + } + + private boolean stringMismatch(final String str1, final String str2) { + if (str1 == null && str2 == null) { + return false; + } + if ((str1 == null) != (str2 == null)) { + return true; + } + + return !str1.equals(str2); + } + + public static Intent getPicker(@NonNull final Context ctx) { + if (NewPipeSettings.useStorageAccessFramework(ctx)) { + return new Intent(Intent.ACTION_OPEN_DOCUMENT) + .putExtra("android.content.extra.SHOW_ADVANCED", true) + .setType("*/*") + .addCategory(Intent.CATEGORY_OPENABLE) + .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | StoredDirectoryHelper.PERMISSION_FLAGS); + } else { + return new Intent(ctx, FilePickerActivityHelper.class) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) + .putExtra(FilePickerActivityHelper.EXTRA_SINGLE_CLICK, true) + .putExtra(FilePickerActivityHelper.EXTRA_MODE, + FilePickerActivityHelper.MODE_FILE); + } + } + + public static Intent getPicker(@NonNull final Context ctx, @Nullable final Uri initialPath) { + return applyInitialPathToPickerIntent(ctx, getPicker(ctx), initialPath, null); + } + + public static Intent getNewPicker(@NonNull final Context ctx, + @Nullable final String filename, + @NonNull final String mimeType, + @Nullable final Uri initialPath) { + final Intent i; + if (NewPipeSettings.useStorageAccessFramework(ctx)) { + i = new Intent(Intent.ACTION_CREATE_DOCUMENT) + .putExtra("android.content.extra.SHOW_ADVANCED", true) + .setType(mimeType) + .addCategory(Intent.CATEGORY_OPENABLE) + .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION + | StoredDirectoryHelper.PERMISSION_FLAGS); + if (filename != null) { + i.putExtra(Intent.EXTRA_TITLE, filename); + } + } else { + i = new Intent(ctx, FilePickerActivityHelper.class) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) + .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_EXISTING_FILE, true) + .putExtra(FilePickerActivityHelper.EXTRA_MODE, + FilePickerActivityHelper.MODE_NEW_FILE); + } + return applyInitialPathToPickerIntent(ctx, i, initialPath, filename); + } + + private static Intent applyInitialPathToPickerIntent(@NonNull final Context ctx, + @NonNull final Intent intent, + @Nullable final Uri initialPath, + @Nullable final String filename) { + + if (NewPipeSettings.useStorageAccessFramework(ctx)) { + if (initialPath == null) { + return intent; // nothing to do, no initial path provided + } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, initialPath); + } else { + return intent; // can't set initial path on API < 26 + } + + } else { + if (initialPath == null && filename == null) { + return intent; // nothing to do, no initial path and no file name provided + } + + File file; + if (initialPath == null) { + // The only way to set the previewed filename in non-SAF FilePicker is to set a + // starting path ending with that filename. So when the initialPath is null but + // filename isn't just default to the external storage directory. + file = Environment.getExternalStorageDirectory(); + } else { + try { + file = Utils.getFileForUri(initialPath); + } catch (final Throwable ignored) { + // getFileForUri() can't decode paths to 'storage', fallback to this + file = new File(initialPath.toString()); + } + } + + // remove any filename at the end of the path (get the parent directory in that case) + if (!file.exists() || !file.isDirectory()) { + file = file.getParentFile(); + if (file == null || !file.exists()) { + // default to the external storage directory in case of an invalid path + file = Environment.getExternalStorageDirectory(); + } + // else: file is surely a directory + } + + if (filename != null) { + // append a filename so that the non-SAF FilePicker shows it as the preview + file = new File(file, filename); + } + + return intent + .putExtra(FilePickerActivityHelper.EXTRA_START_PATH, file.getAbsolutePath()); + } + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java b/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java index d26116139..7c87e664b 100644 --- a/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java +++ b/app/src/main/java/org/schabi/newpipe/util/CommentTextOnTouchListener.java @@ -1,6 +1,5 @@ package org.schabi.newpipe.util; -import android.content.Context; import android.text.Layout; import android.text.Selection; import android.text.Spannable; @@ -11,27 +10,14 @@ import android.view.MotionEvent; import android.view.View; import android.widget.TextView; -import org.schabi.newpipe.extractor.NewPipe; -import org.schabi.newpipe.extractor.StreamingService; -import org.schabi.newpipe.extractor.exceptions.ExtractionException; -import org.schabi.newpipe.extractor.exceptions.ParsingException; -import org.schabi.newpipe.extractor.linkhandler.LinkHandlerFactory; -import org.schabi.newpipe.extractor.stream.StreamInfo; -import org.schabi.newpipe.player.playqueue.PlayQueue; -import org.schabi.newpipe.player.playqueue.SinglePlayQueue; +import org.schabi.newpipe.util.external_communication.ShareUtils; +import org.schabi.newpipe.util.external_communication.InternalUrlsHandler; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.schedulers.Schedulers; +import io.reactivex.rxjava3.disposables.CompositeDisposable; public class CommentTextOnTouchListener implements View.OnTouchListener { public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener(); - private static final Pattern TIMESTAMP_PATTERN = Pattern.compile("(.*)#timestamp=(\\d+)"); - @Override public boolean onTouch(final View v, final MotionEvent event) { if (!(v instanceof TextView)) { @@ -64,13 +50,12 @@ public class CommentTextOnTouchListener implements View.OnTouchListener { if (link.length != 0) { if (action == MotionEvent.ACTION_UP) { - boolean handled = false; if (link[0] instanceof URLSpan) { - handled = handleUrl(v.getContext(), (URLSpan) link[0]); - } - if (!handled) { - ShareUtils.openUrlInBrowser(v.getContext(), - ((URLSpan) link[0]).getURL(), false); + 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, @@ -83,52 +68,4 @@ public class CommentTextOnTouchListener implements View.OnTouchListener { } return false; } - - private boolean handleUrl(final Context context, final URLSpan urlSpan) { - String url = urlSpan.getURL(); - int seconds = -1; - final Matcher matcher = TIMESTAMP_PATTERN.matcher(url); - if (matcher.matches()) { - url = matcher.group(1); - seconds = Integer.parseInt(matcher.group(2)); - } - final StreamingService service; - final StreamingService.LinkType linkType; - try { - service = NewPipe.getServiceByUrl(url); - linkType = service.getLinkTypeByUrl(url); - } catch (final ExtractionException e) { - return false; - } - if (linkType == StreamingService.LinkType.NONE) { - return false; - } - if (linkType == StreamingService.LinkType.STREAM && seconds != -1) { - return playOnPopup(context, url, service, seconds); - } else { - NavigationHelper.openRouterActivity(context, url); - return true; - } - } - - private boolean playOnPopup(final Context context, final String url, - final StreamingService service, final int seconds) { - final LinkHandlerFactory factory = service.getStreamLHFactory(); - final String cleanUrl; - try { - cleanUrl = factory.getUrl(factory.getId(url)); - } catch (final ParsingException e) { - return false; - } - final Single single - = ExtractorHelper.getStreamInfo(service.getServiceId(), cleanUrl, false); - single.subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe(info -> { - final PlayQueue playQueue - = new SinglePlayQueue((StreamInfo) info, seconds * 1000); - NavigationHelper.playOnPopupPlayer(context, playQueue, false); - }); - return 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 52069fd0e..8d918c162 100644 --- a/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java +++ b/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java @@ -12,17 +12,40 @@ import android.view.KeyEvent; import androidx.annotation.Dimension; import androidx.annotation.NonNull; import androidx.core.content.ContextCompat; +import androidx.preference.PreferenceManager; import org.schabi.newpipe.App; +import org.schabi.newpipe.R; public final class DeviceUtils { private static final String AMAZON_FEATURE_FIRE_TV = "amazon.hardware.fire_tv"; private static Boolean isTV = null; + private static Boolean isFireTV = null; + + /* + * Devices that do not support media tunneling + */ + // 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 + private static final boolean CVT_MT5886_EU_1G = Build.VERSION.SDK_INT == 24 + && Build.DEVICE.equals("cvt_mt5886_eu_1g"); private DeviceUtils() { } + public static boolean isFireTv() { + if (isFireTV != null) { + return isFireTV; + } + + isFireTV = + App.getApp().getPackageManager().hasSystemFeature(AMAZON_FEATURE_FIRE_TV); + return isFireTV; + } + public static boolean isTv(final Context context) { if (isTV != null) { return isTV; @@ -33,7 +56,7 @@ public final class DeviceUtils { // from doc: https://developer.android.com/training/tv/start/hardware.html#runtime-check boolean isTv = ContextCompat.getSystemService(context, UiModeManager.class) .getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION - || pm.hasSystemFeature(AMAZON_FEATURE_FIRE_TV) + || isFireTv() || pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION); // from https://stackoverflow.com/a/58932366 @@ -55,10 +78,18 @@ public final class DeviceUtils { } public static boolean isTablet(@NonNull final Context context) { - return (context - .getResources() - .getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) - >= Configuration.SCREENLAYOUT_SIZE_LARGE; + final String tabletModeSetting = PreferenceManager.getDefaultSharedPreferences(context) + .getString(context.getString(R.string.tablet_mode_key), ""); + + if (tabletModeSetting.equals(context.getString(R.string.tablet_mode_on_key))) { + return true; + } else if (tabletModeSetting.equals(context.getString(R.string.tablet_mode_off_key))) { + return false; + } + + // else automatically determine whether we are in a tablet or not + return (context.getResources().getConfiguration().screenLayout + & Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE; } public static boolean isConfirmKey(final int keyCode) { @@ -88,4 +119,15 @@ public final class DeviceUtils { sp, 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; + } } 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 af7cafc15..af94e3366 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ExtractorHelper.java @@ -30,6 +30,7 @@ import androidx.preference.PreferenceManager; import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.R; +import org.schabi.newpipe.util.external_communication.TextLinkifier; import org.schabi.newpipe.extractor.Info; import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.ListExtractor.InfoItemsPage; @@ -54,7 +55,7 @@ import java.util.List; import io.reactivex.rxjava3.core.Maybe; import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.Disposable; +import io.reactivex.rxjava3.disposables.CompositeDisposable; import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty; @@ -268,18 +269,19 @@ public final class ExtractorHelper { * @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 - * @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed + * @param disposables disposables created by the method are added here and their lifecycle + * should be handled by the calling class */ - public static Disposable showMetaInfoInTextView(@Nullable final List metaInfos, - final TextView metaInfoTextView, - final View metaInfoSeparator) { + public static void showMetaInfoInTextView(@Nullable final List metaInfos, + final TextView metaInfoTextView, + final View metaInfoSeparator, + final CompositeDisposable disposables) { final Context context = metaInfoTextView.getContext(); if (metaInfos == null || metaInfos.isEmpty() || !PreferenceManager.getDefaultSharedPreferences(context).getBoolean( context.getString(R.string.show_meta_info_key), true)) { metaInfoTextView.setVisibility(View.GONE); metaInfoSeparator.setVisibility(View.GONE); - return Disposable.empty(); } else { final StringBuilder stringBuilder = new StringBuilder(); @@ -310,8 +312,8 @@ public final class ExtractorHelper { } metaInfoSeparator.setVisibility(View.VISIBLE); - return TextLinkifier.createLinksFromHtmlBlock(context, stringBuilder.toString(), - metaInfoTextView, HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING); + TextLinkifier.createLinksFromHtmlBlock(metaInfoTextView, stringBuilder.toString(), + HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, disposables); } } 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 6ede163a3..20d8ce30c 100644 --- a/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/FilePickerActivityHelper.java @@ -1,7 +1,6 @@ package org.schabi.newpipe.util; import android.content.Context; -import android.content.Intent; import android.net.Uri; import android.os.Bundle; import android.os.Environment; @@ -28,25 +27,6 @@ import java.io.File; public class FilePickerActivityHelper extends com.nononsenseapps.filepicker.FilePickerActivity { private CustomFilePickerFragment currentFragment; - public static Intent chooseSingleFile(@NonNull final Context context) { - return new Intent(context, FilePickerActivityHelper.class) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, false) - .putExtra(FilePickerActivityHelper.EXTRA_SINGLE_CLICK, true) - .putExtra(FilePickerActivityHelper.EXTRA_MODE, FilePickerActivityHelper.MODE_FILE); - } - - public static Intent chooseFileToSave(@NonNull final Context context, - @Nullable final String startPath) { - return new Intent(context, FilePickerActivityHelper.class) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_MULTIPLE, false) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_CREATE_DIR, true) - .putExtra(FilePickerActivityHelper.EXTRA_ALLOW_EXISTING_FILE, true) - .putExtra(FilePickerActivityHelper.EXTRA_START_PATH, startPath) - .putExtra(FilePickerActivityHelper.EXTRA_MODE, - FilePickerActivityHelper.MODE_NEW_FILE); - } - public static boolean isOwnFileUri(@NonNull final Context context, @NonNull final Uri uri) { if (uri.getAuthority() == null) { return false; diff --git a/app/src/main/java/org/schabi/newpipe/util/ImageDisplayConstants.java b/app/src/main/java/org/schabi/newpipe/util/ImageDisplayConstants.java index 37ebd636a..62e80275e 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ImageDisplayConstants.java +++ b/app/src/main/java/org/schabi/newpipe/util/ImageDisplayConstants.java @@ -56,5 +56,10 @@ public final class ImageDisplayConstants { .showImageOnFail(R.drawable.dummy_thumbnail_playlist) .build(); + public static final DisplayImageOptions DISPLAY_SEEKBAR_PREVIEW_OPTIONS = + new DisplayImageOptions.Builder() + .cloneFrom(BASE_DISPLAY_IMAGE_OPTIONS) + .build(); + private ImageDisplayConstants() { } } diff --git a/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java b/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java index 2f0b3e132..f77aa0fda 100644 --- a/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java +++ b/app/src/main/java/org/schabi/newpipe/util/KioskTranslator.java @@ -63,20 +63,20 @@ public final class KioskTranslator { case "Top 50": case "New & hot": case "conferences": - return ThemeHelper.resolveResourceIdFromAttr(c, R.attr.ic_kiosk_hot); + return R.drawable.ic_whatshot; case "Local": - return ThemeHelper.resolveResourceIdFromAttr(c, R.attr.ic_kiosk_local); + return R.drawable.ic_home; case "Recently added": case "recent": - return ThemeHelper.resolveResourceIdFromAttr(c, R.attr.ic_kiosk_recent); + return R.drawable.ic_add_circle_outline; case "Most liked": - return ThemeHelper.resolveResourceIdFromAttr(c, R.attr.ic_thumb_up); + return R.drawable.ic_thumb_up; case "live": - return ThemeHelper.resolveResourceIdFromAttr(c, R.attr.ic_live_tv); + return R.drawable.ic_live_tv; case "Featured": - return ThemeHelper.resolveResourceIdFromAttr(c, R.attr.ic_stars); + return R.drawable.ic_stars; case "Radio": - return ThemeHelper.resolveResourceIdFromAttr(c, R.attr.ic_radio); + return R.drawable.ic_radio; default: return 0; } 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 4f4fd5283..27db9a1f9 100644 --- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java @@ -21,6 +21,7 @@ import androidx.fragment.app.FragmentTransaction; import com.nostra13.universalimageloader.core.ImageLoader; import org.schabi.newpipe.MainActivity; +import org.schabi.newpipe.NewPipeDatabase; import org.schabi.newpipe.R; import org.schabi.newpipe.RouterActivity; import org.schabi.newpipe.about.AboutActivity; @@ -53,16 +54,18 @@ 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.settings.SettingsActivity; +import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.ArrayList; -import static org.schabi.newpipe.util.ShareUtils.installApp; +import static org.schabi.newpipe.util.external_communication.ShareUtils.installApp; public final class NavigationHelper { public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag"; public static final String SEARCH_FRAGMENT_TAG = "search_fragment_tag"; - private NavigationHelper() { } + private NavigationHelper() { + } /*////////////////////////////////////////////////////////////////////////// // Players @@ -111,18 +114,22 @@ public final class NavigationHelper { public static void playOnMainPlayer(final AppCompatActivity activity, @NonNull final PlayQueue playQueue) { final PlayQueueItem item = playQueue.getItem(); - assert item != null; - openVideoDetailFragment(activity, activity.getSupportFragmentManager(), - item.getServiceId(), item.getUrl(), item.getTitle(), playQueue, false); + if (item != null) { + openVideoDetailFragment(activity, activity.getSupportFragmentManager(), + item.getServiceId(), item.getUrl(), item.getTitle(), playQueue, + false); + } } public static void playOnMainPlayer(final Context context, @NonNull final PlayQueue playQueue, final boolean switchingPlayers) { final PlayQueueItem item = playQueue.getItem(); - assert item != null; - openVideoDetail(context, - item.getServiceId(), item.getUrl(), item.getTitle(), playQueue, switchingPlayers); + if (item != null) { + openVideoDetail(context, + item.getServiceId(), item.getUrl(), item.getTitle(), playQueue, + switchingPlayers); + } } public static void playOnPopupPlayer(final Context context, @@ -247,7 +254,7 @@ public final class NavigationHelper { public static void resolveActivityOrAskToInstall(final Context context, final Intent intent) { if (intent.resolveActivity(context.getPackageManager()) != null) { - ShareUtils.openIntentInApp(context, intent); + ShareUtils.openIntentInApp(context, intent, false); } else { if (context instanceof Activity) { new AlertDialog.Builder(context) @@ -343,13 +350,13 @@ public final class NavigationHelper { final boolean switchingPlayers) { final boolean autoPlay; - @Nullable final MainPlayer.PlayerType playerType = PlayerHolder.getType(); + @Nullable final MainPlayer.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.isPlaying(); // keep play/pause state + autoPlay = PlayerHolder.getInstance().isPlaying(); // keep play/pause state } else if (playerType == MainPlayer.PlayerType.VIDEO) { // opening new stream while already playing in main player autoPlay = PlayerHelper.isAutoplayAllowedByUser(context); @@ -591,6 +598,20 @@ public final class NavigationHelper { 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); } + + /** + * Finish this Activity as well as all Activities running below it + * and then start MainActivity. + * + * @param activity the activity to finish + */ + public static void restartApp(final Activity activity) { + NewPipeDatabase.close(); + activity.finishAffinity(); + final Intent intent = new Intent(activity, MainActivity.class); + activity.startActivity(intent); + } } 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 03400bdbb..c64631b72 100644 --- a/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/PermissionHelper.java @@ -18,6 +18,7 @@ import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import org.schabi.newpipe.R; +import org.schabi.newpipe.settings.NewPipeSettings; public final class PermissionHelper { public static final int DOWNLOAD_DIALOG_REQUEST_CODE = 778; @@ -26,6 +27,10 @@ public final class PermissionHelper { private PermissionHelper() { } public static boolean checkStoragePermissions(final Activity activity, final int requestCode) { + if (NewPipeSettings.useStorageAccessFramework(activity)) { + return true; // Storage permissions are not needed for SAF + } + if (!checkReadStoragePermissions(activity, requestCode)) { return false; } diff --git a/app/src/main/java/org/schabi/newpipe/util/RelatedStreamInfo.java b/app/src/main/java/org/schabi/newpipe/util/RelatedItemInfo.java similarity index 54% rename from app/src/main/java/org/schabi/newpipe/util/RelatedStreamInfo.java rename to app/src/main/java/org/schabi/newpipe/util/RelatedItemInfo.java index 81e203b1f..f96bb0d54 100644 --- a/app/src/main/java/org/schabi/newpipe/util/RelatedStreamInfo.java +++ b/app/src/main/java/org/schabi/newpipe/util/RelatedItemInfo.java @@ -9,19 +9,19 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; -public class RelatedStreamInfo extends ListInfo { - public RelatedStreamInfo(final int serviceId, final ListLinkHandler listUrlIdHandler, - final String name) { +public class RelatedItemInfo extends ListInfo { + public RelatedItemInfo(final int serviceId, final ListLinkHandler listUrlIdHandler, + final String name) { super(serviceId, listUrlIdHandler, name); } - public static RelatedStreamInfo getInfo(final StreamInfo info) { + public static RelatedItemInfo getInfo(final StreamInfo info) { final ListLinkHandler handler = new ListLinkHandler( info.getOriginalUrl(), info.getUrl(), info.getId(), Collections.emptyList(), null); - final RelatedStreamInfo relatedStreamInfo = new RelatedStreamInfo( + final RelatedItemInfo relatedItemInfo = new RelatedItemInfo( info.getServiceId(), handler, info.getName()); - final List streams = new ArrayList<>(info.getRelatedStreams()); - relatedStreamInfo.setRelatedItems(streams); - return relatedStreamInfo; + final List relatedItems = new ArrayList<>(info.getRelatedItems()); + relatedItemInfo.setRelatedItems(relatedItems); + return relatedItemInfo; } } diff --git a/app/src/main/java/org/schabi/newpipe/util/ShareUtils.java b/app/src/main/java/org/schabi/newpipe/util/ShareUtils.java deleted file mode 100644 index 45ec1d015..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/ShareUtils.java +++ /dev/null @@ -1,229 +0,0 @@ -package org.schabi.newpipe.util; - -import android.content.ActivityNotFoundException; -import android.content.ClipData; -import android.content.ClipboardManager; -import android.content.Context; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.net.Uri; -import android.widget.Toast; - -import androidx.core.content.ContextCompat; - -import org.schabi.newpipe.R; - -public final class ShareUtils { - private ShareUtils() { - } - - /** - * Open an Intent to install an app. - *

- * This method tries to open the default app market with the package id passed as the - * second param (a system chooser will be opened if there are multiple markets and no default) - * and falls back to Google Play Store web URL if no app to handle the market scheme was found. - *

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

- * If no browser is set as default, fallbacks to - * {@link ShareUtils#openAppChooser(Context, Intent, String)} - * - * @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(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 (defaultPackageName.equals("android")) { - // No browser set as default (doesn't work on some devices) - openAppChooser(context, intent, context.getString(R.string.open_with)); - } else { - if (defaultPackageName.isEmpty()) { - // No app installed to open a web url - Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show(); - return false; - } else { - try { - intent.setPackage(defaultPackageName); - context.startActivity(intent); - } catch (final ActivityNotFoundException e) { - // Not a browser but an app chooser because of OEMs changes - intent.setPackage(null); - openAppChooser(context, intent, context.getString(R.string.open_with)); - } - } - } - - return true; - } - - /** - * Open the url with the system default browser. - *

- * If no browser is set as default, fallbacks to - * {@link ShareUtils#openAppChooser(Context, Intent, String)} - *

- * This calls {@link ShareUtils#openUrlInBrowser(Context, String, boolean)} with true - * for the boolean parameter - * - * @param context the context to use - * @param url the url to browse - * @return true if the URL can be opened or false if it cannot be - **/ - public static boolean openUrlInBrowser(final Context context, final String url) { - return openUrlInBrowser(context, url, true); - } - - /** - * Open an intent with the system default app. - *

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

- * If no app is set as default, fallbacks to - * {@link ShareUtils#openAppChooser(Context, Intent, String)} - * - * @param context the context to use - * @param intent the intent to open - * @return true if the intent can be opened or false if it cannot be - */ - public static boolean openIntentInApp(final Context context, final Intent intent) { - final String defaultPackageName = getDefaultAppPackageName(context, intent); - - if (defaultPackageName.equals("android")) { - // No app set as default (doesn't work on some devices) - openAppChooser(context, intent, context.getString(R.string.open_with)); - } else { - if (defaultPackageName.isEmpty()) { - // No app installed to open the intent - Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show(); - return false; - } else { - try { - intent.setPackage(defaultPackageName); - context.startActivity(intent); - } catch (final ActivityNotFoundException e) { - // Not an app to open the intent but an app chooser because of OEMs changes - intent.setPackage(null); - openAppChooser(context, intent, context.getString(R.string.open_with)); - } - } - } - - return true; - } - - /** - * Open the system chooser to launch an intent. - *

- * This method opens an {@link android.content.Intent#ACTION_CHOOSER} of the intent putted - * as the viewIntent param. A string for the chooser's title must be passed as the last param. - * - * @param context the context to use - * @param intent the intent to open - * @param chooserStringTitle the string of chooser's title - */ - private static void openAppChooser(final Context context, final Intent intent, - final String chooserStringTitle) { - final Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER); - chooserIntent.putExtra(Intent.EXTRA_INTENT, intent); - chooserIntent.putExtra(Intent.EXTRA_TITLE, chooserStringTitle); - chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - context.startActivity(chooserIntent); - } - - /** - * Get the default 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(final Context context, final Intent intent) { - final ResolveInfo resolveInfo = context.getPackageManager().resolveActivity(intent, - PackageManager.MATCH_DEFAULT_ONLY); - - if (resolveInfo == null) { - return ""; - } else { - return resolveInfo.activityInfo.packageName; - } - } - - /** - * Open the android share menu to share the current url. - * - * @param context the context to use - * @param subject the url subject, typically the title - * @param url the url to share - */ - public static void shareText(final Context context, final String subject, final String url) { - final Intent shareIntent = new Intent(Intent.ACTION_SEND); - shareIntent.setType("text/plain"); - shareIntent.putExtra(Intent.EXTRA_SUBJECT, subject); - shareIntent.putExtra(Intent.EXTRA_TEXT, url); - - openAppChooser(context, shareIntent, context.getString(R.string.share_dialog_title)); - } - - /** - * Copy the text to clipboard, and indicate to the user whether the operation was completed - * successfully using a Toast. - * - * @param context the context to use - * @param text the text to copy - */ - public static void copyToClipboard(final Context context, final String text) { - final ClipboardManager clipboardManager = - ContextCompat.getSystemService(context, ClipboardManager.class); - - if (clipboardManager == null) { - Toast.makeText(context, R.string.permission_denied, Toast.LENGTH_LONG).show(); - return; - } - - clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)); - Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); - } -} 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 ab28205fc..6ebdaee02 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StateSaver.java +++ b/app/src/main/java/org/schabi/newpipe/util/StateSaver.java @@ -72,10 +72,10 @@ public final class StateSaver { } /** - * @see #tryToRestore(SavedState, WriteRead) * @param outState * @param writeRead * @return the saved state + * @see #tryToRestore(SavedState, WriteRead) */ public static SavedState tryToRestore(final Bundle outState, final WriteRead writeRead) { if (outState == null || writeRead == null) { @@ -93,6 +93,7 @@ public final class StateSaver { /** * Try to restore the state from memory and disk, * using the {@link StateSaver.WriteRead#readFrom(Queue)} from the writeRead. + * * @param savedState * @param writeRead * @return the saved state @@ -143,19 +144,18 @@ public final class StateSaver { } /** - * @see #tryToSave(boolean, String, String, WriteRead) * @param isChangingConfig * @param savedState * @param outState * @param writeRead * @return the saved state or {@code null} + * @see #tryToSave(boolean, String, String, WriteRead) */ @Nullable public static SavedState tryToSave(final boolean isChangingConfig, @Nullable final SavedState savedState, final Bundle outState, final WriteRead writeRead) { - @NonNull - final String currentSavedPrefix; + @NonNull final String currentSavedPrefix; if (savedState == null || TextUtils.isEmpty(savedState.getPrefixFileSaved())) { // Generate unique prefix currentSavedPrefix = System.nanoTime() - writeRead.hashCode() + ""; @@ -299,8 +299,11 @@ public final class StateSaver { cacheDir = new File(cacheDir, CACHE_DIR_NAME); if (cacheDir.exists()) { - for (final File file : cacheDir.listFiles()) { - file.delete(); + final File[] list = cacheDir.listFiles(); + if (list != null) { + for (final File file : list) { + file.delete(); + } } } } diff --git a/app/src/main/java/org/schabi/newpipe/util/StreamDialogEntry.java b/app/src/main/java/org/schabi/newpipe/util/StreamDialogEntry.java index 73fee32f7..89b48c9a7 100644 --- a/app/src/main/java/org/schabi/newpipe/util/StreamDialogEntry.java +++ b/app/src/main/java/org/schabi/newpipe/util/StreamDialogEntry.java @@ -9,13 +9,18 @@ import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.local.dialog.PlaylistAppendDialog; import org.schabi.newpipe.local.dialog.PlaylistCreationDialog; +import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.player.MainPlayer; import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.playqueue.SinglePlayQueue; +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; + import static org.schabi.newpipe.player.MainPlayer.PlayerType.AUDIO; import static org.schabi.newpipe.player.MainPlayer.PlayerType.POPUP; @@ -24,13 +29,20 @@ public enum StreamDialogEntry { // enum values with DEFAULT actions // ////////////////////////////////////// + show_channel_details(R.string.show_channel_details, (fragment, item) -> + // For some reason `getParentFragmentManager()` doesn't work, but this does. + NavigationHelper.openChannelFragment( + fragment.requireActivity().getSupportFragmentManager(), + item.getServiceId(), item.getUploaderUrl(), item.getUploaderName()) + ), + /** * Enqueues the stream automatically to the current PlayerType.
*
* Info: Add this entry within showStreamDialog. */ enqueue(R.string.enqueue_stream, (fragment, item) -> { - final MainPlayer.PlayerType type = PlayerHolder.getType(); + final MainPlayer.PlayerType type = PlayerHolder.getInstance().getType(); if (type == AUDIO) { NavigationHelper.enqueueOnBackgroundPlayer(fragment.getContext(), @@ -59,30 +71,40 @@ public enum StreamDialogEntry { }), // has to be set manually append_playlist(R.string.append_playlist, (fragment, item) -> { - if (fragment.getFragmentManager() != null) { - final PlaylistAppendDialog d = PlaylistAppendDialog - .fromStreamInfoItems(Collections.singletonList(item)); + final PlaylistAppendDialog d = PlaylistAppendDialog + .fromStreamInfoItems(Collections.singletonList(item)); - PlaylistAppendDialog.onPlaylistFound(fragment.getContext(), - () -> d.show(fragment.getFragmentManager(), "StreamDialogEntry@append_playlist"), - () -> PlaylistCreationDialog.newInstance(d) - .show(fragment.getFragmentManager(), "StreamDialogEntry@create_playlist") - ); - } + PlaylistAppendDialog.onPlaylistFound(fragment.getContext(), + () -> d.show(fragment.getParentFragmentManager(), "StreamDialogEntry@append_playlist"), + () -> PlaylistCreationDialog.newInstance(d) + .show(fragment.getParentFragmentManager(), "StreamDialogEntry@create_playlist") + ); }), play_with_kodi(R.string.play_with_kodi_title, (fragment, item) -> { final Uri videoUrl = Uri.parse(item.getUrl()); try { - NavigationHelper.playWithKore(fragment.getContext(), videoUrl); + NavigationHelper.playWithKore(fragment.requireContext(), videoUrl); } catch (final Exception e) { - KoreUtil.showInstallKoreDialog(fragment.getActivity()); + KoreUtils.showInstallKoreDialog(fragment.getActivity()); } }), share(R.string.share, (fragment, item) -> - ShareUtils.shareText(fragment.getContext(), item.getName(), item.getUrl())); + ShareUtils.shareText(fragment.getContext(), item.getName(), item.getUrl(), + item.getThumbnailUrl())), + open_in_browser(R.string.open_in_browser, (fragment, item) -> + ShareUtils.openUrlInBrowser(fragment.getContext(), item.getUrl())), + + + mark_as_watched(R.string.mark_as_watched, (fragment, item) -> { + new HistoryRecordManager(fragment.getContext()) + .markAsWatched(item) + .onErrorComplete() + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(); + }); /////////////// // variables // diff --git a/app/src/main/java/org/schabi/newpipe/util/TextLinkifier.java b/app/src/main/java/org/schabi/newpipe/util/TextLinkifier.java deleted file mode 100644 index 087677333..000000000 --- a/app/src/main/java/org/schabi/newpipe/util/TextLinkifier.java +++ /dev/null @@ -1,145 +0,0 @@ -package org.schabi.newpipe.util; - -import android.content.Context; -import android.text.SpannableStringBuilder; -import android.text.method.LinkMovementMethod; -import android.text.style.ClickableSpan; -import android.text.style.URLSpan; -import android.text.util.Linkify; -import android.util.Log; -import android.view.View; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.core.text.HtmlCompat; - -import io.noties.markwon.Markwon; -import io.noties.markwon.linkify.LinkifyPlugin; -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; -import io.reactivex.rxjava3.core.Single; -import io.reactivex.rxjava3.disposables.Disposable; -import io.reactivex.rxjava3.schedulers.Schedulers; - -public final class TextLinkifier { - public static final String TAG = TextLinkifier.class.getSimpleName(); - - private TextLinkifier() { - } - - /** - * Create web links for contents with an HTML description. - *

- * This will call - * {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView)} - * after having linked the URLs with {@link HtmlCompat#fromHtml(String, int)}. - * - * @param context the context to use - * @param htmlBlock the htmlBlock to be linked - * @param textView the TextView to set the htmlBlock linked - * @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, int)} - * will be called - * @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed - */ - public static Disposable createLinksFromHtmlBlock(final Context context, - final String htmlBlock, - final TextView textView, - final int htmlCompatFlag) { - return changeIntentsOfDescriptionLinks(context, - HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), textView); - } - - /** - * Create web links for contents with a plain text description. - *

- * This will call - * {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView)} - * after having linked the URLs with {@link TextView#setAutoLinkMask(int)} and - * {@link TextView#setText(CharSequence, TextView.BufferType)}. - * - * @param context the context to use - * @param plainTextBlock the block of plain text to be linked - * @param textView the TextView to set the plain text block linked - * @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed - */ - public static Disposable createLinksFromPlainText(final Context context, - final String plainTextBlock, - final TextView textView) { - textView.setAutoLinkMask(Linkify.WEB_URLS); - textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE); - return changeIntentsOfDescriptionLinks(context, textView.getText(), textView); - } - - /** - * Create web links for contents with a markdown description. - *

- * This will call - * {@link TextLinkifier#changeIntentsOfDescriptionLinks(Context, CharSequence, TextView)} - * after creating an {@link Markwon} object and using - * {@link Markwon#setMarkdown(TextView, String)}. - * - * @param context the context to use - * @param markdownBlock the block of markdown text to be linked - * @param textView the TextView to set the plain text block linked - * @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed - */ - public static Disposable createLinksFromMarkdownText(final Context context, - final String markdownBlock, - final TextView textView) { - final Markwon markwon = Markwon.builder(context).usePlugin(LinkifyPlugin.create()).build(); - markwon.setMarkdown(textView, markdownBlock); - return changeIntentsOfDescriptionLinks(context, textView.getText(), textView); - } - - /** - * Change links generated by libraries in the description of a content to a custom link action. - *

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

- * This method is required in order to intercept links and e.g. show a confirmation dialog - * before opening a web link. - * - * @param context the context to use - * @param chars the CharSequence to be parsed - * @param textView the TextView in which the converted CharSequence will be applied - * @return a disposable to be stored somewhere and disposed when activity/fragment is destroyed - */ - private static Disposable changeIntentsOfDescriptionLinks(final Context context, - final CharSequence chars, - final TextView textView) { - return Single.fromCallable(() -> { - final SpannableStringBuilder textBlockLinked = new SpannableStringBuilder(chars); - final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), URLSpan.class); - - for (final URLSpan span : urls) { - final ClickableSpan clickableSpan = new ClickableSpan() { - public void onClick(@NonNull final View view) { - ShareUtils.openUrlInBrowser(context, span.getURL(), false); - } - }; - - textBlockLinked.setSpan(clickableSpan, textBlockLinked.getSpanStart(span), - textBlockLinked.getSpanEnd(span), textBlockLinked.getSpanFlags(span)); - textBlockLinked.removeSpan(span); - } - - return textBlockLinked; - }).subscribeOn(Schedulers.computation()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe( - textBlockLinked -> setTextViewCharSequence(textView, textBlockLinked), - throwable -> { - Log.e(TAG, "Unable to linkify text", throwable); - // this should never happen, but if it does, just fallback to it - setTextViewCharSequence(textView, chars); - }); - } - - private static void setTextViewCharSequence(final TextView textView, - final CharSequence charSequence) { - textView.setText(charSequence); - textView.setMovementMethod(LinkMovementMethod.getInstance()); - textView.setVisibility(View.VISIBLE); - } -} diff --git a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java index dcfb7ed19..f3ae002dd 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ThemeHelper.java @@ -23,7 +23,6 @@ import android.app.Activity; import android.content.Context; import android.content.res.Configuration; import android.content.res.Resources; -import android.content.res.TypedArray; import android.util.TypedValue; import androidx.annotation.AttrRes; @@ -31,6 +30,7 @@ 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.core.content.ContextCompat; import androidx.preference.PreferenceManager; @@ -47,6 +47,9 @@ public final class ThemeHelper { * Apply the selected theme (on NewPipe settings) in the context * with the default style (see {@link #setTheme(Context, int)}). * + * ThemeHelper.setDayNightMode should be called before + * the applying theme for the first time in session + * * @param context context that the theme will be applied */ public static void setTheme(final Context context) { @@ -57,6 +60,9 @@ public final class ThemeHelper { * Apply the selected theme (on NewPipe settings) in the context, * themed according with the styles defined for the service . * + * ThemeHelper.setDayNightMode should be called before + * the applying theme for the first time in session + * * @param context context that the theme will be applied * @param serviceId the theme will be styled to the service with this id, * pass -1 to get the default style @@ -120,6 +126,7 @@ public final class ThemeHelper { final String selectedThemeKey = getSelectedThemeKey(context); + int baseTheme = R.style.DarkTheme; // default to dark theme if (selectedThemeKey.equals(lightThemeKey)) { baseTheme = R.style.LightTheme; @@ -202,20 +209,6 @@ public final class ThemeHelper { } } - /** - * Get a resource id from a resource styled according to the context's theme. - * - * @param context Android app context - * @param attr attribute reference of the resource - * @return resource ID - */ - public static int resolveResourceIdFromAttr(final Context context, @AttrRes final int attr) { - final TypedArray a = context.getTheme().obtainStyledAttributes(new int[]{attr}); - final int attributeResourceId = a.getResourceId(0, 0); - a.recycle(); - return attributeResourceId; - } - /** * Get a color from an attr styled according to the context's theme. * @@ -288,4 +281,60 @@ public final class ThemeHelper { return false; } } + + public static void setDayNightMode(final Context context) { + setDayNightMode(context, ThemeHelper.getSelectedThemeKey(context)); + } + + public static void setDayNightMode(final Context context, final String selectedThemeKey) { + final Resources res = context.getResources(); + + if (selectedThemeKey.equals(res.getString(R.string.light_theme_key))) { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); + } else if (selectedThemeKey.equals(res.getString(R.string.dark_theme_key)) + || selectedThemeKey.equals(res.getString(R.string.black_theme_key))) { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES); + } else { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM); + } + } + + + /** + * 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. + * + * @param context the context to use + * @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); + } + } + + /** + * Calculates the number of grid items that can fit horizontally on the screen. The width of a + * grid item is obtained from the thumbnail width plus the right and left paddings. + * + * @param context the context to use + * @return the span count of grid list items + */ + public static int getGridSpanCount(final Context context) { + final Resources res = context.getResources(); + final int minWidth + = res.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width) + + res.getDimensionPixelSize(R.dimen.video_item_search_padding) * 2; + return Math.max(1, res.getDisplayMetrics().widthPixels / minWidth); + } } diff --git a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java index e2b766bb0..bc08e6197 100644 --- a/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java +++ b/app/src/main/java/org/schabi/newpipe/util/ZipHelper.java @@ -1,15 +1,18 @@ package org.schabi.newpipe.util; +import org.schabi.newpipe.streams.io.SharpInputStream; + import java.io.BufferedInputStream; import java.io.File; import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.IOException; import java.util.zip.ZipEntry; -import java.util.zip.ZipFile; import java.util.zip.ZipInputStream; import java.util.zip.ZipOutputStream; +import org.schabi.newpipe.streams.io.StoredFileHelper; + /** * Created by Christian Schabesberger on 28.01.18. * Copyright 2018 Christian Schabesberger @@ -59,24 +62,23 @@ public final class ZipHelper { } /** - * This will extract data from Zipfiles. + * This will extract data from ZipInputStream. * Caution this will override the original file. * - * @param filePath The path of the zip + * @param zipFile The zip file * @param file The path of the file on the disk where the data should be extracted to. * @param name The path of the file inside the zip. * @return will return true if the file was found within the zip file * @throws Exception */ - public static boolean extractFileFromZip(final String filePath, final String file, + public static boolean extractFileFromZip(final StoredFileHelper zipFile, final String file, final String name) throws Exception { try (ZipInputStream inZip = new ZipInputStream(new BufferedInputStream( - new FileInputStream(filePath)))) { + new SharpInputStream(zipFile.getStream())))) { final byte[] data = new byte[BUFFER_SIZE]; - boolean found = false; - ZipEntry ze; + while ((ze = inZip.getNextEntry()) != null) { if (ze.getName().equals(name)) { found = true; @@ -102,8 +104,9 @@ public final class ZipHelper { } } - public static boolean isValidZipFile(final String filePath) { - try (ZipFile ignored = new ZipFile(filePath)) { + public static boolean isValidZipFile(final StoredFileHelper file) { + try (ZipInputStream ignored = new ZipInputStream(new BufferedInputStream( + new SharpInputStream(file.getStream())))) { return true; } catch (final IOException ioe) { return false; diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/InternalUrlsHandler.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/InternalUrlsHandler.java new file mode 100644 index 000000000..39ec51ce4 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/InternalUrlsHandler.java @@ -0,0 +1,154 @@ +package org.schabi.newpipe.util.external_communication; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import org.schabi.newpipe.extractor.NewPipe; +import org.schabi.newpipe.extractor.StreamingService; +import org.schabi.newpipe.extractor.exceptions.ExtractionException; +import org.schabi.newpipe.extractor.exceptions.ParsingException; +import org.schabi.newpipe.extractor.linkhandler.LinkHandlerFactory; +import org.schabi.newpipe.extractor.stream.StreamInfo; +import org.schabi.newpipe.player.playqueue.PlayQueue; +import org.schabi.newpipe.player.playqueue.SinglePlayQueue; +import org.schabi.newpipe.util.ExtractorHelper; +import org.schabi.newpipe.util.NavigationHelper; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +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 InternalUrlsHandler { + private static final Pattern AMPERSAND_TIMESTAMP_PATTERN = Pattern.compile("(.*)&t=(\\d+)"); + private static final Pattern HASHTAG_TIMESTAMP_PATTERN = + Pattern.compile("(.*)#timestamp=(\\d+)"); + + private InternalUrlsHandler() { + } + + /** + * Handle a YouTube timestamp comment URL in NewPipe. + *

+ * This method will check if the provided url is a YouTube comment description URL ({@code + * https://www.youtube.com/watch?v=}video_id{@code #timestamp=}time_in_seconds). If yes, the + * popup player will be opened when the user will click on the timestamp in the comment, + * at the time and for the video indicated in the timestamp. + * + * @param disposables a field of the Activity/Fragment class that calls this method + * @param context the context to use + * @param url the URL to check if it can be handled + * @return true if the URL can be handled by NewPipe, false if it cannot + */ + public static boolean handleUrlCommentsTimestamp(@NonNull final CompositeDisposable + disposables, + final Context context, + @NonNull final String url) { + return handleUrl(context, url, HASHTAG_TIMESTAMP_PATTERN, disposables); + } + + /** + * Handle a YouTube timestamp description URL in NewPipe. + *

+ * This method will check if the provided url is a YouTube timestamp description URL ({@code + * https://www.youtube.com/watch?v=}video_id{@code &t=}time_in_seconds). If yes, the popup + * player will be opened when the user will click on the timestamp in the video description, + * at the time and for the video indicated in the timestamp. + * + * @param disposables a field of the Activity/Fragment class that calls this method + * @param context the context to use + * @param url the URL to check if it can be handled + * @return true if the URL can be handled by NewPipe, false if it cannot + */ + public static boolean handleUrlDescriptionTimestamp(@NonNull final CompositeDisposable + disposables, + final Context context, + @NonNull final String url) { + return handleUrl(context, url, AMPERSAND_TIMESTAMP_PATTERN, disposables); + } + + /** + * Handle an URL in NewPipe. + *

+ * This method will check if the provided url can be handled in NewPipe or not. If this is a + * service URL with a timestamp, the popup player will be opened and true will be returned; + * else, false will be returned. + * + * @param context the context to use + * @param url the URL to check if it can be handled + * @param pattern the pattern to use + * @param disposables a field of the Activity/Fragment class that calls this method + * @return true if the URL can be handled by NewPipe, false if it cannot + */ + private static boolean handleUrl(final Context context, + @NonNull final String url, + @NonNull final Pattern pattern, + @NonNull final CompositeDisposable disposables) { + final Matcher matcher = pattern.matcher(url); + if (!matcher.matches()) { + return false; + } + final String matchedUrl = matcher.group(1); + final int seconds = Integer.parseInt(matcher.group(2)); + + final StreamingService service; + final StreamingService.LinkType linkType; + try { + service = NewPipe.getServiceByUrl(matchedUrl); + linkType = service.getLinkTypeByUrl(matchedUrl); + if (linkType == StreamingService.LinkType.NONE) { + return false; + } + } catch (final ExtractionException e) { + return false; + } + + if (linkType == StreamingService.LinkType.STREAM && seconds != -1) { + return playOnPopup(context, matchedUrl, service, seconds, disposables); + } else { + NavigationHelper.openRouterActivity(context, matchedUrl); + return true; + } + } + + /** + * Play a content in the floating player. + * + * @param context the context to be used + * @param url the URL of the content + * @param service the service of the content + * @param seconds the position in seconds at which the floating player will start + * @param disposables disposables created by the method are added here and their lifecycle + * should be handled by the calling class + * @return true if the playback of the content has successfully started or false if not + */ + public static boolean playOnPopup(final Context context, + final String url, + @NonNull final StreamingService service, + final int seconds, + @NonNull final CompositeDisposable disposables) { + final LinkHandlerFactory factory = service.getStreamLHFactory(); + final String cleanUrl; + + try { + cleanUrl = factory.getUrl(factory.getId(url)); + } catch (final ParsingException e) { + return 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 * 1000); + NavigationHelper.playOnPopupPlayer(context, playQueue, false); + })); + return true; + } +} diff --git a/app/src/main/java/org/schabi/newpipe/util/KoreUtil.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java similarity index 70% rename from app/src/main/java/org/schabi/newpipe/util/KoreUtil.java rename to app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java index de6f3fa9a..6801f24ef 100644 --- a/app/src/main/java/org/schabi/newpipe/util/KoreUtil.java +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/KoreUtils.java @@ -1,28 +1,31 @@ -package org.schabi.newpipe.util; +package org.schabi.newpipe.util.external_communication; import android.content.Context; +import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.preference.PreferenceManager; import org.schabi.newpipe.R; import org.schabi.newpipe.extractor.ServiceList; +import org.schabi.newpipe.util.NavigationHelper; -public final class KoreUtil { - private KoreUtil() { } +public final class KoreUtils { + private KoreUtils() { } public static boolean isServiceSupportedByKore(final int serviceId) { return (serviceId == ServiceList.YouTube.getServiceId() || serviceId == ServiceList.SoundCloud.getServiceId()); } - public static boolean shouldShowPlayWithKodi(final Context context, final int serviceId) { + public static boolean shouldShowPlayWithKodi(@NonNull final Context context, + final int serviceId) { return isServiceSupportedByKore(serviceId) && PreferenceManager.getDefaultSharedPreferences(context) .getBoolean(context.getString(R.string.show_play_with_kodi_key), false); } - public static void showInstallKoreDialog(final Context context) { + 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) -> 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 new file mode 100644 index 000000000..e49cd6ea2 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java @@ -0,0 +1,302 @@ +package org.schabi.newpipe.util.external_communication; + +import android.content.ActivityNotFoundException; +import android.content.ClipData; +import android.content.ClipboardManager; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.Build; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.core.content.ContextCompat; + +import org.schabi.newpipe.R; + +public final class ShareUtils { + private ShareUtils() { + } + + /** + * Open an Intent to install an app. + *

+ * This method tries to open the default app market with the package id passed as the + * second param (a system chooser will be opened if there are multiple markets and no default) + * and falls back to Google Play Store web URL if no app to handle the market scheme was found. + *

+ * It uses {@link #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. + * + * @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, + 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 (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. + *

+ * If no browser is set as default, fallbacks to + * {@link #openAppChooser(Context, Intent, boolean)} + *

+ * This calls {@link #openUrlInBrowser(Context, String, boolean)} with true + * for the boolean parameter + * + * @param context the context to use + * @param url the url to browse + * @return true if the URL can be opened or false if it cannot be + **/ + public static boolean openUrlInBrowser(@NonNull final Context context, final String url) { + return openUrlInBrowser(context, url, true); + } + + /** + * Open an intent with the system default app. + *

+ * The intent can be of every type, excepted a web intent for which + * {@link #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. + * + * @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 + */ + 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 { + context.startActivity(intent); + } + + return true; + } + + /** + * Open the system chooser to launch an intent. + *

+ * This method opens an {@link android.content.Intent#ACTION_CHOOSER} of the intent putted + * as the intent param. If the setTitleChooser boolean is true, the string "Open with" will be + * set as the title of the system chooser. + * For Android P and higher, title for {@link android.content.Intent#ACTION_SEND} system + * choosers must be set on this intent, not on the + * {@link android.content.Intent#ACTION_CHOOSER} intent. + * + * @param context the context to use + * @param intent the intent to open + * @param setTitleChooser set the title "Open with" to the chooser if true, else not + */ + private static void openAppChooser(@NonNull final Context context, + @NonNull final Intent intent, + final boolean setTitleChooser) { + final Intent chooserIntent = new Intent(Intent.ACTION_CHOOSER); + chooserIntent.putExtra(Intent.EXTRA_INTENT, intent); + chooserIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + if (setTitleChooser) { + chooserIntent.putExtra(Intent.EXTRA_TITLE, context.getString(R.string.open_with)); + } + + // 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); + } + if (permFlags != 0) { + ClipData targetClipData = intent.getClipData(); + if (targetClipData == null && intent.getData() != null) { + final ClipData.Item item = new ClipData.Item(intent.getData()); + final String[] mimeTypes; + if (intent.getType() != null) { + mimeTypes = new String[] {intent.getType()}; + } else { + mimeTypes = new String[] {}; + } + targetClipData = new ClipData(null, mimeTypes, item); + } + if (targetClipData != null) { + chooserIntent.setClipData(targetClipData); + 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; + } + } + + /** + * 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. + * + * @param context the context to use + * @param title the title of the content + * @param content the content to share + * @param imagePreviewUrl the image of the subject + */ + public static void shareText(@NonNull final Context context, + @NonNull final String title, + final String content, + final String imagePreviewUrl) { + final Intent shareIntent = new Intent(Intent.ACTION_SEND); + shareIntent.setType("text/plain"); + shareIntent.putExtra(Intent.EXTRA_TEXT, content); + if (!title.isEmpty()) { + shareIntent.putExtra(Intent.EXTRA_TITLE, 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); + }*/ + + openAppChooser(context, shareIntent, false); + } + + /** + * 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. + *

+ * This calls {@link #shareText(Context, String, String, String)} with an empty string for the + * imagePreviewUrl parameter. + * + * @param context the context to use + * @param title the title of the content + * @param content the content to share + */ + public static void shareText(@NonNull final Context context, + @NonNull final String title, + final String content) { + shareText(context, title, content, ""); + } + + /** + * Copy the text to clipboard, and indicate to the user whether the operation was completed + * successfully using a Toast. + * + * @param context the context to use + * @param text the text to copy + */ + public static void copyToClipboard(@NonNull final Context context, final String text) { + final ClipboardManager clipboardManager = + ContextCompat.getSystemService(context, ClipboardManager.class); + + if (clipboardManager == null) { + Toast.makeText(context, R.string.permission_denied, Toast.LENGTH_LONG).show(); + return; + } + + clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text)); + Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show(); + } +} 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 new file mode 100644 index 000000000..76da09609 --- /dev/null +++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/TextLinkifier.java @@ -0,0 +1,287 @@ +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(); + private static final Pattern HASHTAGS_PATTERN = Pattern.compile("(#[A-Za-z0-9_]+)"); + private static final Pattern TIMESTAMPS_PATTERN = Pattern.compile( + "(?:^|(?!:)\\W)(?:([0-5]?[0-9]):)?([0-5]?[0-9]):([0-5][0-9])(?=$|(?!:)\\W)"); + + 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 = TIMESTAMPS_PATTERN.matcher(descriptionText); + + while (timestampsMatches.find()) { + final int timestampStart = timestampsMatches.start(2); + final int timestampEnd = timestampsMatches.end(3); + final String parsedTimestamp = descriptionText.substring(timestampStart, timestampEnd); + final String[] timestampParts = parsedTimestamp.split(":"); + + final int seconds; + if (timestampParts.length == 3) { // timestamp format: XX:XX:XX + seconds = Integer.parseInt(timestampParts[0]) * 3600 // hours + + Integer.parseInt(timestampParts[1]) * 60 // minutes + + Integer.parseInt(timestampParts[2]); // seconds + } else if (timestampParts.length == 2) { // timestamp format: XX:XX + seconds = Integer.parseInt(timestampParts[0]) * 60 // minutes + + Integer.parseInt(timestampParts[1]); // seconds + } else { + continue; + } + + spannableDescription.setSpan(new ClickableSpan() { + @Override + public void onClick(@NonNull final View view) { + playOnPopup(context, relatedInfo.getUrl(), relatedInfo.getService(), seconds, + disposables); + } + }, timestampStart, 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/us/shandian/giga/get/DownloadMission.java b/app/src/main/java/us/shandian/giga/get/DownloadMission.java index 2b3faa3e0..9d8eaf9a5 100644 --- a/app/src/main/java/us/shandian/giga/get/DownloadMission.java +++ b/app/src/main/java/us/shandian/giga/get/DownloadMission.java @@ -26,7 +26,7 @@ import java.util.Objects; import javax.net.ssl.SSLException; -import us.shandian.giga.io.StoredFileHelper; +import org.schabi.newpipe.streams.io.StoredFileHelper; import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.util.Utility; @@ -664,7 +664,7 @@ public class DownloadMission extends Mission { * @return {@code true}, if storage is invalid and cannot be used */ public boolean hasInvalidStorage() { - return errCode == ERROR_PROGRESS_LOST || storage == null || storage.isInvalid() || !storage.existsAsFile(); + return errCode == ERROR_PROGRESS_LOST || storage == null || !storage.existsAsFile(); } /** diff --git a/app/src/main/java/us/shandian/giga/get/Mission.java b/app/src/main/java/us/shandian/giga/get/Mission.java index ecb0eaebd..77b9c1e33 100644 --- a/app/src/main/java/us/shandian/giga/get/Mission.java +++ b/app/src/main/java/us/shandian/giga/get/Mission.java @@ -5,7 +5,7 @@ import androidx.annotation.NonNull; import java.io.Serializable; import java.util.Calendar; -import us.shandian.giga.io.StoredFileHelper; +import org.schabi.newpipe.streams.io.StoredFileHelper; public abstract class Mission implements Serializable { private static final long serialVersionUID = 1L;// last bump: 27 march 2019 @@ -25,6 +25,10 @@ public abstract class Mission implements Serializable { */ public long timestamp; + public long getTimestamp() { + return timestamp; + } + /** * pre-defined content type */ @@ -35,10 +39,6 @@ public abstract class Mission implements Serializable { */ public StoredFileHelper storage; - public long getTimestamp() { - return timestamp; - } - /** * Delete the downloaded file * @@ -57,7 +57,7 @@ public abstract class Mission implements Serializable { @NonNull @Override public String toString() { - Calendar calendar = Calendar.getInstance(); + final Calendar calendar = Calendar.getInstance(); calendar.setTimeInMillis(timestamp); return "[" + calendar.getTime().toString() + "] " + (storage.isInvalid() ? storage.getName() : storage.getUri()); } diff --git a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java index 15c45c6fd..704385212 100644 --- a/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java +++ b/app/src/main/java/us/shandian/giga/get/sqlite/FinishedMissionStore.java @@ -17,7 +17,7 @@ import java.util.Objects; import us.shandian.giga.get.DownloadMission; import us.shandian.giga.get.FinishedMission; import us.shandian.giga.get.Mission; -import us.shandian.giga.io.StoredFileHelper; +import org.schabi.newpipe.streams.io.StoredFileHelper; /** * SQLite helper to store finished {@link us.shandian.giga.get.FinishedMission}'s diff --git a/app/src/main/java/us/shandian/giga/io/SharpInputStream.java b/app/src/main/java/us/shandian/giga/io/SharpInputStream.java deleted file mode 100644 index 0d6320b53..000000000 --- a/app/src/main/java/us/shandian/giga/io/SharpInputStream.java +++ /dev/null @@ -1,61 +0,0 @@ -/* - * To change this license header, choose License Headers in Project Properties. - * To change this template file, choose Tools | Templates - * and open the template in the editor. - */ -package us.shandian.giga.io; - -import androidx.annotation.NonNull; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.IOException; -import java.io.InputStream; - -/** - * Wrapper for the classic {@link java.io.InputStream} - * - * @author kapodamy - */ -public class SharpInputStream extends InputStream { - - private final SharpStream base; - - public SharpInputStream(SharpStream base) throws IOException { - if (!base.canRead()) { - throw new IOException("The provided stream is not readable"); - } - this.base = base; - } - - @Override - public int read() throws IOException { - return base.read(); - } - - @Override - public int read(@NonNull byte[] bytes) throws IOException { - return base.read(bytes); - } - - @Override - public int read(@NonNull byte[] bytes, int i, int i1) throws IOException { - return base.read(bytes, i, i1); - } - - @Override - public long skip(long l) throws IOException { - return base.skip(l); - } - - @Override - public int available() { - long res = base.available(); - return res > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) res; - } - - @Override - public void close() { - base.close(); - } -} diff --git a/app/src/main/java/us/shandian/giga/io/StoredFileHelper.java b/app/src/main/java/us/shandian/giga/io/StoredFileHelper.java deleted file mode 100644 index eba9437e1..000000000 --- a/app/src/main/java/us/shandian/giga/io/StoredFileHelper.java +++ /dev/null @@ -1,384 +0,0 @@ -package us.shandian.giga.io; - -import android.annotation.TargetApi; -import android.content.ContentResolver; -import android.content.Context; -import android.content.Intent; -import android.net.Uri; -import android.os.Build; -import android.provider.DocumentsContract; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.documentfile.provider.DocumentFile; -import androidx.fragment.app.Fragment; - -import org.schabi.newpipe.streams.io.SharpStream; - -import java.io.File; -import java.io.IOException; -import java.io.Serializable; -import java.net.URI; - -public class StoredFileHelper implements Serializable { - private static final long serialVersionUID = 0L; - public static final String DEFAULT_MIME = "application/octet-stream"; - - private transient DocumentFile docFile; - private transient DocumentFile docTree; - private transient File ioFile; - private transient Context context; - - protected String source; - private String sourceTree; - - protected String tag; - - private String srcName; - private String srcType; - - public StoredFileHelper(@Nullable Uri parent, String filename, String mime, String tag) { - this.source = null;// this instance will be "invalid" see invalidate()/isInvalid() methods - - this.srcName = filename; - this.srcType = mime == null ? DEFAULT_MIME : mime; - if (parent != null) this.sourceTree = parent.toString(); - - this.tag = tag; - } - - @TargetApi(Build.VERSION_CODES.LOLLIPOP) - StoredFileHelper(@Nullable Context context, DocumentFile tree, String filename, String mime, boolean safe) throws IOException { - this.docTree = tree; - this.context = context; - - DocumentFile res; - - if (safe) { - // no conflicts (the filename is not in use) - res = this.docTree.createFile(mime, filename); - if (res == null) throw new IOException("Cannot create the file"); - } else { - res = createSAF(context, mime, filename); - } - - this.docFile = res; - - this.source = docFile.getUri().toString(); - this.sourceTree = docTree.getUri().toString(); - - this.srcName = this.docFile.getName(); - this.srcType = this.docFile.getType(); - } - - StoredFileHelper(File location, String filename, String mime) throws IOException { - this.ioFile = new File(location, 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"); - } - - this.source = Uri.fromFile(this.ioFile).toString(); - this.sourceTree = Uri.fromFile(location).toString(); - - this.srcName = ioFile.getName(); - this.srcType = mime; - } - - @TargetApi(Build.VERSION_CODES.KITKAT) - public StoredFileHelper(Context context, @Nullable Uri parent, @NonNull Uri path, String tag) throws IOException { - this.tag = tag; - this.source = path.toString(); - - if (path.getScheme() == null || path.getScheme().equalsIgnoreCase(ContentResolver.SCHEME_FILE)) { - this.ioFile = new File(URI.create(this.source)); - } else { - DocumentFile file = DocumentFile.fromSingleUri(context, path); - - if (file == null) throw new RuntimeException("SAF not available"); - - this.context = context; - - if (file.getName() == null) { - this.source = null; - return; - } else { - this.docFile = file; - takePermissionSAF(); - } - } - - if (parent != null) { - if (!ContentResolver.SCHEME_FILE.equals(parent.getScheme())) - this.docTree = DocumentFile.fromTreeUri(context, parent); - - this.sourceTree = parent.toString(); - } - - this.srcName = getName(); - this.srcType = getType(); - } - - - public static StoredFileHelper deserialize(@NonNull StoredFileHelper storage, Context context) throws IOException { - Uri treeUri = storage.sourceTree == null ? null : Uri.parse(storage.sourceTree); - - if (storage.isInvalid()) - return new StoredFileHelper(treeUri, storage.srcName, storage.srcType, storage.tag); - - StoredFileHelper instance = new StoredFileHelper(context, treeUri, Uri.parse(storage.source), storage.tag); - - // under SAF, if the target document is deleted, conserve the filename and mime - if (instance.srcName == null) instance.srcName = storage.srcName; - if (instance.srcType == null) instance.srcType = storage.srcType; - - return instance; - } - - public static void requestSafWithFileCreation(@NonNull Fragment who, int requestCode, String filename, String mime) { - // SAF notes: - // ACTION_OPEN_DOCUMENT Do not let you create the file, useful for overwrite files - // ACTION_CREATE_DOCUMENT No overwrite support, useless the file provider resolve the conflict - - Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT) - .addCategory(Intent.CATEGORY_OPENABLE) - .setType(mime) - .putExtra(Intent.EXTRA_TITLE, filename) - .addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | StoredDirectoryHelper.PERMISSION_FLAGS) - .putExtra("android.content.extra.SHOW_ADVANCED", true);// hack, show all storage disks - - who.startActivityForResult(intent, requestCode); - } - - - public SharpStream getStream() throws IOException { - invalid(); - - if (docFile == null) - return new FileStream(ioFile); - else - return new FileStreamSAF(context.getContentResolver(), docFile.getUri()); - } - - /** - * Indicates whatever if is possible access using the {@code java.io} API - * - * @return {@code true} for Java I/O API, otherwise, {@code false} for Storage Access Framework - */ - public boolean isDirect() { - invalid(); - - return docFile == null; - } - - public boolean isInvalid() { - return source == null; - } - - public Uri getUri() { - invalid(); - - return docFile == null ? Uri.fromFile(ioFile) : docFile.getUri(); - } - - public Uri getParentUri() { - invalid(); - - return sourceTree == null ? null : Uri.parse(sourceTree); - } - - public void truncate() throws IOException { - invalid(); - - try (SharpStream fs = getStream()) { - fs.setLength(0); - } - } - - public boolean delete() { - if (source == null) return true; - if (docFile == null) return ioFile.delete(); - - - boolean res = docFile.delete(); - - try { - int flags = Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION; - context.getContentResolver().releasePersistableUriPermission(docFile.getUri(), flags); - } catch (Exception ex) { - // nothing to do - } - - return res; - } - - public long length() { - invalid(); - - return docFile == null ? ioFile.length() : docFile.length(); - } - - public boolean canWrite() { - if (source == null) return false; - return docFile == null ? ioFile.canWrite() : docFile.canWrite(); - } - - public String getName() { - if (source == null) - return srcName; - else if (docFile == null) - return ioFile.getName(); - - String name = docFile.getName(); - return name == null ? srcName : name; - } - - public String getType() { - if (source == null || docFile == null) - return srcType; - - String type = docFile.getType(); - return type == null ? srcType : type; - } - - public String getTag() { - return tag; - } - - public boolean existsAsFile() { - if (source == null) return false; - - // WARNING: DocumentFile.exists() and DocumentFile.isFile() methods are slow - boolean exists = docFile == null ? ioFile.exists() : docFile.exists(); - boolean isFile = docFile == null ? ioFile.isFile() : docFile.isFile();// ¿docFile.isVirtual() means is no-physical? - - return exists && isFile; - } - - public boolean create() { - invalid(); - boolean result; - - if (docFile == null) { - try { - result = ioFile.createNewFile(); - } catch (IOException e) { - return false; - } - } else if (docTree == null) { - result = false; - } else { - if (!docTree.canRead() || !docTree.canWrite()) return false; - try { - docFile = createSAF(context, srcType, srcName); - if (docFile == null || docFile.getName() == null) return false; - result = true; - } catch (IOException e) { - return false; - } - } - - if (result) { - source = (docFile == null ? Uri.fromFile(ioFile) : docFile.getUri()).toString(); - srcName = getName(); - srcType = getType(); - } - - return result; - } - - public void invalidate() { - if (source == null) return; - - srcName = getName(); - srcType = getType(); - - source = null; - - docTree = null; - docFile = null; - ioFile = null; - context = null; - } - - public boolean equals(StoredFileHelper storage) { - if (this == storage) return true; - - // note: do not compare tags, files can have the same parent folder - //if (stringMismatch(this.tag, storage.tag)) return false; - - if (stringMismatch(getLowerCase(this.sourceTree), getLowerCase(this.sourceTree))) - return false; - - if (this.isInvalid() || storage.isInvalid()) { - if (this.srcName == null || storage.srcName == null || this.srcType == null || storage.srcType == null) return false; - return this.srcName.equalsIgnoreCase(storage.srcName) && this.srcType.equalsIgnoreCase(storage.srcType); - } - - if (this.isDirect() != storage.isDirect()) return false; - - if (this.isDirect()) - return this.ioFile.getPath().equalsIgnoreCase(storage.ioFile.getPath()); - - return DocumentsContract.getDocumentId( - this.docFile.getUri() - ).equalsIgnoreCase(DocumentsContract.getDocumentId( - storage.docFile.getUri() - )); - } - - @NonNull - @Override - public String toString() { - if (source == null) - return "[Invalid state] name=" + srcName + " type=" + srcType + " tag=" + tag; - else - return "sourceFile=" + source + " treeSource=" + (sourceTree == null ? "" : sourceTree) + " tag=" + tag; - } - - - private void invalid() { - if (source == null) - throw new IllegalStateException("In invalid state"); - } - - private void takePermissionSAF() throws IOException { - try { - context.getContentResolver().takePersistableUriPermission(docFile.getUri(), StoredDirectoryHelper.PERMISSION_FLAGS); - } catch (Exception e) { - if (docFile.getName() == null) throw new IOException(e); - } - } - - private DocumentFile createSAF(@Nullable Context context, String mime, String filename) throws IOException { - DocumentFile res = StoredDirectoryHelper.findFileSAFHelper(context, docTree, filename); - - if (res != null && res.exists() && res.isDirectory()) { - if (!res.delete()) - throw new IOException("Directory with the same name found but cannot delete"); - res = null; - } - - if (res == null) { - res = this.docTree.createFile(srcType == null ? DEFAULT_MIME : mime, filename); - if (res == null) throw new IOException("Cannot create the file"); - } - - return res; - } - - private String getLowerCase(String str) { - return str == null ? null : str.toLowerCase(); - } - - private boolean stringMismatch(String str1, String str2) { - if (str1 == null && str2 == null) return false; - if ((str1 == null) != (str2 == null)) return true; - - return !str1.equals(str2); - } -} 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 8359fce9a..7c248c2b6 100644 --- a/app/src/main/java/us/shandian/giga/service/DownloadManager.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManager.java @@ -19,8 +19,8 @@ import us.shandian.giga.get.DownloadMission; import us.shandian.giga.get.FinishedMission; import us.shandian.giga.get.Mission; import us.shandian.giga.get.sqlite.FinishedMissionStore; -import us.shandian.giga.io.StoredDirectoryHelper; -import us.shandian.giga.io.StoredFileHelper; +import org.schabi.newpipe.streams.io.StoredDirectoryHelper; +import org.schabi.newpipe.streams.io.StoredFileHelper; import us.shandian.giga.util.Utility; import static org.schabi.newpipe.BuildConfig.DEBUG; @@ -106,7 +106,8 @@ public class DownloadManager { } /** - * Loads finished missions from the data source + * Loads finished missions from the data source and forgets finished missions whose file does + * not exist anymore. */ private ArrayList loadFinishedMissions() { ArrayList finishedMissions = mFinishedMissionStore.loadFinishedMissions(); @@ -148,7 +149,7 @@ public class DownloadManager { if (sub.getName().equals(".tmp")) continue; DownloadMission mis = Utility.readFromFile(sub); - if (mis == null || mis.isFinished()) { + if (mis == null || mis.isFinished() || mis.hasInvalidStorage()) { //noinspection ResultOfMethodCallIgnored sub.delete(); continue; @@ -331,14 +332,29 @@ public class DownloadManager { } /** - * Get a finished mission by its path + * Get the index into {@link #mMissionsFinished} of a finished mission by its path, return + * {@code -1} if there is no such mission. This function also checks if the matched mission's + * file exists, and, if it does not, the related mission is forgotten about (like in {@link + * #loadFinishedMissions()}) and {@code -1} is returned. * - * @param storage where the file possible is stored + * @param storage where the file would be stored * @return the mission index or -1 if no such mission exists */ private int getFinishedMissionIndex(StoredFileHelper storage) { for (int i = 0; i < mMissionsFinished.size(); i++) { if (mMissionsFinished.get(i).storage.equals(storage)) { + // If the file does not exist the mission is not valid anymore. Also checking if + // length == 0 since the file picker may create an empty file before yielding it, + // but that does not mean the file really belonged to a previous mission. + if (!storage.existsAsFile() || storage.length() == 0) { + if (DEBUG) { + Log.d(TAG, "matched downloaded file removed: " + storage.getName()); + } + + mFinishedMissionStore.deleteMission(mMissionsFinished.get(i)); + mMissionsFinished.remove(i); + return -1; // finished mission whose associated file was removed + } return i; } } 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 568c3497a..52c28828d 100755 --- a/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java +++ b/app/src/main/java/us/shandian/giga/service/DownloadManagerService.java @@ -47,8 +47,8 @@ import java.util.ArrayList; import us.shandian.giga.get.DownloadMission; import us.shandian.giga.get.MissionRecoveryInfo; -import us.shandian.giga.io.StoredDirectoryHelper; -import us.shandian.giga.io.StoredFileHelper; +import org.schabi.newpipe.streams.io.StoredDirectoryHelper; +import org.schabi.newpipe.streams.io.StoredFileHelper; import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.service.DownloadManager.NetworkState; 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 41a254b49..e06485fdf 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 @@ -2,7 +2,6 @@ package us.shandian.giga.ui.adapter; import android.annotation.SuppressLint; import android.app.NotificationManager; -import android.content.ActivityNotFoundException; import android.content.Context; import android.content.Intent; import android.graphics.Color; @@ -45,7 +44,7 @@ import org.schabi.newpipe.error.ErrorActivity; import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.util.NavigationHelper; -import org.schabi.newpipe.util.ShareUtils; +import org.schabi.newpipe.util.external_communication.ShareUtils; import java.io.File; import java.net.URI; @@ -61,7 +60,7 @@ 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.io.StoredFileHelper; +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; @@ -348,10 +347,8 @@ public class MissionAdapter extends Adapter implements Handler.Callb if (BuildConfig.DEBUG) Log.v(TAG, "Mime: " + mimeType + " package: " + BuildConfig.APPLICATION_ID + ".provider"); - final Uri uri = resolveShareableUri(mission); - Intent intent = new Intent(Intent.ACTION_VIEW); - intent.setDataAndType(uri, mimeType); + intent.setDataAndType(resolveShareableUri(mission), mimeType); intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { @@ -361,10 +358,8 @@ public class MissionAdapter extends Adapter implements Handler.Callb intent.addFlags(FLAG_ACTIVITY_NEW_TASK); } - //mContext.grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION); - if (intent.resolveActivity(mContext.getPackageManager()) != null) { - ShareUtils.openIntentInApp(mContext, intent); + ShareUtils.openIntentInApp(mContext, intent, false); } else { Toast.makeText(mContext, R.string.toast_no_player, Toast.LENGTH_LONG).show(); } @@ -377,19 +372,18 @@ public class MissionAdapter extends Adapter implements Handler.Callb shareIntent.setType(resolveMimeType(mission)); shareIntent.putExtra(Intent.EXTRA_STREAM, resolveShareableUri(mission)); shareIntent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); + final Intent intent = new Intent(Intent.ACTION_CHOOSER); intent.putExtra(Intent.EXTRA_INTENT, shareIntent); - intent.putExtra(Intent.EXTRA_TITLE, mContext.getString(R.string.share_dialog_title)); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - - try { - intent.setPackage("android"); - mContext.startActivity(intent); - } catch (final ActivityNotFoundException e) { - // falling back to OEM chooser if Android's system chooser was removed by the OEM - intent.setPackage(null); - mContext.startActivity(intent); + // unneeded to set a title to the chooser on Android P and higher because the system + // ignores this title on these versions + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1) { + intent.putExtra(Intent.EXTRA_TITLE, mContext.getString(R.string.share_dialog_title)); } + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + intent.addFlags(FLAG_GRANT_READ_URI_PERMISSION); + + mContext.startActivity(intent); } /** 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 b42ebbeb4..c554766ff 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 @@ -55,6 +55,14 @@ public class Deleter { } 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); + commit(); + mIterator.hide(item); items.add(0, item); 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 3270b2b6f..2cca3239b 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 @@ -1,7 +1,6 @@ package us.shandian.giga.ui.fragment; import android.app.Activity; -import android.app.AlertDialog; import android.content.ComponentName; import android.content.Context; import android.content.Intent; @@ -18,7 +17,11 @@ import android.view.View; import android.view.ViewGroup; import android.widget.Toast; +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; import androidx.fragment.app.Fragment; import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.GridLayoutManager; @@ -29,14 +32,13 @@ import com.nononsenseapps.filepicker.Utils; import org.schabi.newpipe.R; import org.schabi.newpipe.settings.NewPipeSettings; +import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.util.FilePickerActivityHelper; -import org.schabi.newpipe.util.ThemeHelper; import java.io.File; import java.io.IOException; import us.shandian.giga.get.DownloadMission; -import us.shandian.giga.io.StoredFileHelper; import us.shandian.giga.service.DownloadManager; import us.shandian.giga.service.DownloadManagerService; import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder; @@ -45,7 +47,6 @@ import us.shandian.giga.ui.adapter.MissionAdapter; public class MissionsFragment extends Fragment { private static final int SPAN_SIZE = 2; - private static final int REQUEST_DOWNLOAD_SAVE_AS = 0x1230; private SharedPreferences mPrefs; private boolean mLinear; @@ -65,7 +66,8 @@ public class MissionsFragment extends Fragment { private boolean mForceUpdate; private DownloadMission unsafeMissionTarget = null; - + private final ActivityResultLauncher requestDownloadSaveAsLauncher = + registerForActivityResult(new StartActivityForResult(), this::requestDownloadSaveAsResult); private final ServiceConnection mConnection = new ServiceConnection() { @Override @@ -224,10 +226,9 @@ public class MissionsFragment extends Fragment { mList.setAdapter(mAdapter); if (mSwitch != null) { - mSwitch.setIcon(ThemeHelper.resolveResourceIdFromAttr( - requireContext(), mLinear - ? R.attr.ic_grid - : R.attr.ic_list)); + mSwitch.setIcon(mLinear + ? R.drawable.ic_apps + : R.drawable.ic_list); mSwitch.setTitle(mLinear ? R.string.grid : R.string.list); mPrefs.edit().putBoolean("linear", mLinear).apply(); } @@ -243,27 +244,22 @@ public class MissionsFragment extends Fragment { private void recoverMission(@NonNull DownloadMission mission) { unsafeMissionTarget = mission; + final Uri initialPath; if (NewPipeSettings.useStorageAccessFramework(mContext)) { - StoredFileHelper.requestSafWithFileCreation( - MissionsFragment.this, - REQUEST_DOWNLOAD_SAVE_AS, - mission.storage.getName(), - mission.storage.getType() - ); - + initialPath = null; } else { - File initialSavePath; - if (DownloadManager.TAG_VIDEO.equals(mission.storage.getType())) - initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MOVIES); - else + final File initialSavePath; + if (DownloadManager.TAG_AUDIO.equals(mission.storage.getType())) { initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MUSIC); - - initialSavePath = new File(initialSavePath, mission.storage.getName()); - startActivityForResult( - FilePickerActivityHelper.chooseFileToSave(mContext, initialSavePath.getAbsolutePath()), - REQUEST_DOWNLOAD_SAVE_AS - ); + } else { + initialSavePath = NewPipeSettings.getDir(Environment.DIRECTORY_MOVIES); + } + initialPath = Uri.parse(initialSavePath.getAbsolutePath()); } + + requestDownloadSaveAsLauncher.launch( + StoredFileHelper.getNewPicker(mContext, mission.storage.getName(), + mission.storage.getType(), initialPath)); } @Override @@ -297,18 +293,17 @@ public class MissionsFragment extends Fragment { if (mBinder != null) mBinder.enableNotifications(true); } - @Override - public void onActivityResult(int requestCode, int resultCode, Intent data) { - super.onActivityResult(requestCode, resultCode, data); + private void requestDownloadSaveAsResult(final ActivityResult result) { + if (result.getResultCode() != Activity.RESULT_OK) { + return; + } - if (requestCode != REQUEST_DOWNLOAD_SAVE_AS || resultCode != Activity.RESULT_OK) return; - - if (unsafeMissionTarget == null || data.getData() == null) { + if (unsafeMissionTarget == null || result.getData() == null) { return; } try { - Uri fileUri = data.getData(); + Uri fileUri = result.getData().getData(); if (fileUri.getAuthority() != null && FilePickerActivityHelper.isOwnFileUri(mContext, fileUri)) { fileUri = Uri.fromFile(Utils.getFileForUri(fileUri)); } 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 c090c7211..9e6787d5d 100644 --- a/app/src/main/java/us/shandian/giga/util/Utility.java +++ b/app/src/main/java/us/shandian/giga/util/Utility.java @@ -29,7 +29,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.Locale; -import us.shandian.giga.io.StoredFileHelper; +import org.schabi.newpipe.streams.io.StoredFileHelper; public class Utility { @@ -182,12 +182,12 @@ public class Utility { public static int getIconForFileType(FileType type) { switch (type) { case MUSIC: - return R.drawable.ic_headset_white_24dp; + return R.drawable.ic_headset; default: case VIDEO: - return R.drawable.ic_movie_white_24dp; + return R.drawable.ic_movie; case SUBTITLE: - return R.drawable.ic_subtitles_white_24dp; + return R.drawable.ic_subtitles; } } diff --git a/app/src/main/res/drawable-hdpi/ic_close_white.png b/app/src/main/res/drawable-hdpi/ic_close_white.png new file mode 100644 index 000000000..5546fb0ff Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_close_white.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_close_white_24dp_png.png b/app/src/main/res/drawable-hdpi/ic_close_white_24dp_png.png deleted file mode 100644 index 9af50602d..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_close_white_24dp_png.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 new file mode 100644 index 000000000..1f1f9046c Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_hourglass_top_white.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_hourglass_top_white_24dp_png.png b/app/src/main/res/drawable-hdpi/ic_hourglass_top_white_24dp_png.png deleted file mode 100644 index dc2f5122a..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_hourglass_top_white_24dp_png.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 6c05313dd..cd3b6d182 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_newpipe_update.png b/app/src/main/res/drawable-hdpi/ic_newpipe_update.png index f8e0fc597..047d2f798 100755 Binary files a/app/src/main/res/drawable-hdpi/ic_newpipe_update.png and b/app/src/main/res/drawable-hdpi/ic_newpipe_update.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 new file mode 100644 index 000000000..c706f8097 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_replay_white.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_replay_white_24dp_png.png b/app/src/main/res/drawable-hdpi/ic_replay_white_24dp_png.png deleted file mode 100644 index 01b248180..000000000 Binary files a/app/src/main/res/drawable-hdpi/ic_replay_white_24dp_png.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 new file mode 100644 index 000000000..1037ea613 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_close_white.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_close_white_24dp_png.png b/app/src/main/res/drawable-mdpi/ic_close_white_24dp_png.png deleted file mode 100644 index 199af1303..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_close_white_24dp_png.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 new file mode 100644 index 000000000..734e8eca3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_hourglass_top_white.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_hourglass_top_white_24dp_png.png b/app/src/main/res/drawable-mdpi/ic_hourglass_top_white_24dp_png.png deleted file mode 100644 index 8df1a61ec..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_hourglass_top_white_24dp_png.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 97c60c91c..f967011b0 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_newpipe_update.png b/app/src/main/res/drawable-mdpi/ic_newpipe_update.png index 23b1dbfa3..bec3631ab 100755 Binary files a/app/src/main/res/drawable-mdpi/ic_newpipe_update.png and b/app/src/main/res/drawable-mdpi/ic_newpipe_update.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 new file mode 100644 index 000000000..24558a423 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_replay_white.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_replay_white_24dp_png.png b/app/src/main/res/drawable-mdpi/ic_replay_white_24dp_png.png deleted file mode 100644 index f351cf709..000000000 Binary files a/app/src/main/res/drawable-mdpi/ic_replay_white_24dp_png.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_add_white_24dp.xml b/app/src/main/res/drawable-night/ic_add.xml similarity index 100% rename from app/src/main/res/drawable/ic_add_white_24dp.xml rename to app/src/main/res/drawable-night/ic_add.xml diff --git a/app/src/main/res/drawable/ic_add_circle_outline_white_24dp.xml b/app/src/main/res/drawable-night/ic_add_circle_outline.xml similarity index 100% rename from app/src/main/res/drawable/ic_add_circle_outline_white_24dp.xml rename to app/src/main/res/drawable-night/ic_add_circle_outline.xml diff --git a/app/src/main/res/drawable/ic_apps_white_24dp.xml b/app/src/main/res/drawable-night/ic_apps.xml similarity index 100% rename from app/src/main/res/drawable/ic_apps_white_24dp.xml rename to app/src/main/res/drawable-night/ic_apps.xml diff --git a/app/src/main/res/drawable/ic_arrow_back_white_24dp.xml b/app/src/main/res/drawable-night/ic_arrow_back.xml similarity index 100% rename from app/src/main/res/drawable/ic_arrow_back_white_24dp.xml rename to app/src/main/res/drawable-night/ic_arrow_back.xml diff --git a/app/src/main/res/drawable/ic_asterisk_white_24dp.xml b/app/src/main/res/drawable-night/ic_asterisk.xml similarity index 100% rename from app/src/main/res/drawable/ic_asterisk_white_24dp.xml rename to app/src/main/res/drawable-night/ic_asterisk.xml diff --git a/app/src/main/res/drawable/ic_attach_money_white_24dp.xml b/app/src/main/res/drawable-night/ic_attach_money.xml similarity index 100% rename from app/src/main/res/drawable/ic_attach_money_white_24dp.xml rename to app/src/main/res/drawable-night/ic_attach_money.xml diff --git a/app/src/main/res/drawable/ic_backup_white_24dp.xml b/app/src/main/res/drawable-night/ic_backup.xml similarity index 100% rename from app/src/main/res/drawable/ic_backup_white_24dp.xml rename to app/src/main/res/drawable-night/ic_backup.xml diff --git a/app/src/main/res/drawable/ic_bookmark_white_24dp.xml b/app/src/main/res/drawable-night/ic_bookmark.xml similarity index 100% rename from app/src/main/res/drawable/ic_bookmark_white_24dp.xml rename to app/src/main/res/drawable-night/ic_bookmark.xml diff --git a/app/src/main/res/drawable/ic_bug_report_white_24dp.xml b/app/src/main/res/drawable-night/ic_bug_report.xml similarity index 100% rename from app/src/main/res/drawable/ic_bug_report_white_24dp.xml rename to app/src/main/res/drawable-night/ic_bug_report.xml diff --git a/app/src/main/res/drawable/ic_cast_white_24dp.xml b/app/src/main/res/drawable-night/ic_cast.xml similarity index 100% rename from app/src/main/res/drawable/ic_cast_white_24dp.xml rename to app/src/main/res/drawable-night/ic_cast.xml diff --git a/app/src/main/res/drawable/ic_child_care_white_24dp.xml b/app/src/main/res/drawable-night/ic_child_care.xml similarity index 100% rename from app/src/main/res/drawable/ic_child_care_white_24dp.xml rename to app/src/main/res/drawable-night/ic_child_care.xml diff --git a/app/src/main/res/drawable/ic_close_white_24dp.xml b/app/src/main/res/drawable-night/ic_close.xml similarity index 100% rename from app/src/main/res/drawable/ic_close_white_24dp.xml rename to app/src/main/res/drawable-night/ic_close.xml diff --git a/app/src/main/res/drawable/ic_cloud_download_white_24dp.xml b/app/src/main/res/drawable-night/ic_cloud_download.xml similarity index 100% rename from app/src/main/res/drawable/ic_cloud_download_white_24dp.xml rename to app/src/main/res/drawable-night/ic_cloud_download.xml diff --git a/app/src/main/res/drawable/ic_computer_white_24dp.xml b/app/src/main/res/drawable-night/ic_computer.xml similarity index 100% rename from app/src/main/res/drawable/ic_computer_white_24dp.xml rename to app/src/main/res/drawable-night/ic_computer.xml diff --git a/app/src/main/res/drawable/ic_crop_portrait_white_24dp.xml b/app/src/main/res/drawable-night/ic_crop_portrait.xml similarity index 100% rename from app/src/main/res/drawable/ic_crop_portrait_white_24dp.xml rename to app/src/main/res/drawable-night/ic_crop_portrait.xml diff --git a/app/src/main/res/drawable/ic_delete_white_24dp.xml b/app/src/main/res/drawable-night/ic_delete.xml similarity index 100% rename from app/src/main/res/drawable/ic_delete_white_24dp.xml rename to app/src/main/res/drawable-night/ic_delete.xml diff --git a/app/src/main/res/drawable/ic_directions_bike_white_24dp.xml b/app/src/main/res/drawable-night/ic_directions_bike.xml similarity index 100% rename from app/src/main/res/drawable/ic_directions_bike_white_24dp.xml rename to app/src/main/res/drawable-night/ic_directions_bike.xml diff --git a/app/src/main/res/drawable/ic_directions_car_white_24dp.xml b/app/src/main/res/drawable-night/ic_directions_car.xml similarity index 100% rename from app/src/main/res/drawable/ic_directions_car_white_24dp.xml rename to app/src/main/res/drawable-night/ic_directions_car.xml diff --git a/app/src/main/res/drawable/ic_done_white_24dp.xml b/app/src/main/res/drawable-night/ic_done.xml similarity index 100% rename from app/src/main/res/drawable/ic_done_white_24dp.xml rename to app/src/main/res/drawable-night/ic_done.xml diff --git a/app/src/main/res/drawable/ic_drag_handle_white_24dp.xml b/app/src/main/res/drawable-night/ic_drag_handle.xml similarity index 100% rename from app/src/main/res/drawable/ic_drag_handle_white_24dp.xml rename to app/src/main/res/drawable-night/ic_drag_handle.xml diff --git a/app/src/main/res/drawable/ic_edit_white_24dp.xml b/app/src/main/res/drawable-night/ic_edit.xml similarity index 100% rename from app/src/main/res/drawable/ic_edit_white_24dp.xml rename to app/src/main/res/drawable-night/ic_edit.xml diff --git a/app/src/main/res/drawable/ic_expand_less_white_24dp.xml b/app/src/main/res/drawable-night/ic_expand_less.xml similarity index 100% rename from app/src/main/res/drawable/ic_expand_less_white_24dp.xml rename to app/src/main/res/drawable-night/ic_expand_less.xml diff --git a/app/src/main/res/drawable/ic_expand_more_white_24dp.xml b/app/src/main/res/drawable-night/ic_expand_more.xml similarity index 100% rename from app/src/main/res/drawable/ic_expand_more_white_24dp.xml rename to app/src/main/res/drawable-night/ic_expand_more.xml diff --git a/app/src/main/res/drawable/ic_explore_white_24dp.xml b/app/src/main/res/drawable-night/ic_explore.xml similarity index 100% rename from app/src/main/res/drawable/ic_explore_white_24dp.xml rename to app/src/main/res/drawable-night/ic_explore.xml diff --git a/app/src/main/res/drawable/ic_fastfood_white_24dp.xml b/app/src/main/res/drawable-night/ic_fastfood.xml similarity index 100% rename from app/src/main/res/drawable/ic_fastfood_white_24dp.xml rename to app/src/main/res/drawable-night/ic_fastfood.xml diff --git a/app/src/main/res/drawable/ic_favorite_white_24dp.xml b/app/src/main/res/drawable-night/ic_favorite.xml similarity index 100% rename from app/src/main/res/drawable/ic_favorite_white_24dp.xml rename to app/src/main/res/drawable-night/ic_favorite.xml diff --git a/app/src/main/res/drawable/ic_file_download_white_24dp.xml b/app/src/main/res/drawable-night/ic_file_download.xml similarity index 100% rename from app/src/main/res/drawable/ic_file_download_white_24dp.xml rename to app/src/main/res/drawable-night/ic_file_download.xml diff --git a/app/src/main/res/drawable/ic_filter_list_white_24dp.xml b/app/src/main/res/drawable-night/ic_filter_list.xml similarity index 100% rename from app/src/main/res/drawable/ic_filter_list_white_24dp.xml rename to app/src/main/res/drawable-night/ic_filter_list.xml diff --git a/app/src/main/res/drawable/ic_fitness_center_white_24dp.xml b/app/src/main/res/drawable-night/ic_fitness_center.xml similarity index 100% rename from app/src/main/res/drawable/ic_fitness_center_white_24dp.xml rename to app/src/main/res/drawable-night/ic_fitness_center.xml diff --git a/app/src/main/res/drawable/ic_headset_white_24dp.xml b/app/src/main/res/drawable-night/ic_headset.xml similarity index 91% rename from app/src/main/res/drawable/ic_headset_white_24dp.xml rename to app/src/main/res/drawable-night/ic_headset.xml index 3ca2936b8..f23764766 100644 --- a/app/src/main/res/drawable/ic_headset_white_24dp.xml +++ b/app/src/main/res/drawable-night/ic_headset.xml @@ -5,6 +5,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable-night/ic_heart.xml b/app/src/main/res/drawable-night/ic_heart.xml new file mode 100644 index 000000000..6128a3d0d --- /dev/null +++ b/app/src/main/res/drawable-night/ic_heart.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_help_white_24dp.xml b/app/src/main/res/drawable-night/ic_help.xml similarity index 100% rename from app/src/main/res/drawable/ic_help_white_24dp.xml rename to app/src/main/res/drawable-night/ic_help.xml diff --git a/app/src/main/res/drawable/ic_history_white_24dp.xml b/app/src/main/res/drawable-night/ic_history.xml similarity index 100% rename from app/src/main/res/drawable/ic_history_white_24dp.xml rename to app/src/main/res/drawable-night/ic_history.xml diff --git a/app/src/main/res/drawable/ic_home_white_24dp.xml b/app/src/main/res/drawable-night/ic_home.xml similarity index 100% rename from app/src/main/res/drawable/ic_home_white_24dp.xml rename to app/src/main/res/drawable-night/ic_home.xml diff --git a/app/src/main/res/drawable/ic_import_export_white_24dp.xml b/app/src/main/res/drawable-night/ic_import_export.xml similarity index 100% rename from app/src/main/res/drawable/ic_import_export_white_24dp.xml rename to app/src/main/res/drawable-night/ic_import_export.xml diff --git a/app/src/main/res/drawable/ic_info_outline_white_24dp.xml b/app/src/main/res/drawable-night/ic_info_outline.xml similarity index 91% rename from app/src/main/res/drawable/ic_info_outline_white_24dp.xml rename to app/src/main/res/drawable-night/ic_info_outline.xml index 2465f7808..d772001df 100644 --- a/app/src/main/res/drawable/ic_info_outline_white_24dp.xml +++ b/app/src/main/res/drawable-night/ic_info_outline.xml @@ -5,6 +5,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable/ic_insert_emoticon_white_24dp.xml b/app/src/main/res/drawable-night/ic_insert_emoticon.xml similarity index 100% rename from app/src/main/res/drawable/ic_insert_emoticon_white_24dp.xml rename to app/src/main/res/drawable-night/ic_insert_emoticon.xml diff --git a/app/src/main/res/drawable/ic_language_white_24dp.xml b/app/src/main/res/drawable-night/ic_language.xml similarity index 100% rename from app/src/main/res/drawable/ic_language_white_24dp.xml rename to app/src/main/res/drawable-night/ic_language.xml diff --git a/app/src/main/res/drawable/ic_list_white_24dp.xml b/app/src/main/res/drawable-night/ic_list.xml similarity index 100% rename from app/src/main/res/drawable/ic_list_white_24dp.xml rename to app/src/main/res/drawable-night/ic_list.xml diff --git a/app/src/main/res/drawable/ic_live_tv_white_24dp.xml b/app/src/main/res/drawable-night/ic_live_tv.xml similarity index 100% rename from app/src/main/res/drawable/ic_live_tv_white_24dp.xml rename to app/src/main/res/drawable-night/ic_live_tv.xml diff --git a/app/src/main/res/drawable/ic_megaphone_white_24dp.xml b/app/src/main/res/drawable-night/ic_megaphone.xml similarity index 100% rename from app/src/main/res/drawable/ic_megaphone_white_24dp.xml rename to app/src/main/res/drawable-night/ic_megaphone.xml diff --git a/app/src/main/res/drawable/ic_mic_white_24dp.xml b/app/src/main/res/drawable-night/ic_mic.xml similarity index 100% rename from app/src/main/res/drawable/ic_mic_white_24dp.xml rename to app/src/main/res/drawable-night/ic_mic.xml diff --git a/app/src/main/res/drawable/ic_more_vert_white_24dp.xml b/app/src/main/res/drawable-night/ic_more_vert.xml similarity index 100% rename from app/src/main/res/drawable/ic_more_vert_white_24dp.xml rename to app/src/main/res/drawable-night/ic_more_vert.xml diff --git a/app/src/main/res/drawable/ic_motorcycle_white_24dp.xml b/app/src/main/res/drawable-night/ic_motorcycle.xml similarity index 100% rename from app/src/main/res/drawable/ic_motorcycle_white_24dp.xml rename to app/src/main/res/drawable-night/ic_motorcycle.xml diff --git a/app/src/main/res/drawable/ic_movie_white_24dp.xml b/app/src/main/res/drawable-night/ic_movie.xml similarity index 100% rename from app/src/main/res/drawable/ic_movie_white_24dp.xml rename to app/src/main/res/drawable-night/ic_movie.xml diff --git a/app/src/main/res/drawable/ic_music_note_white_24dp.xml b/app/src/main/res/drawable-night/ic_music_note.xml similarity index 100% rename from app/src/main/res/drawable/ic_music_note_white_24dp.xml rename to app/src/main/res/drawable-night/ic_music_note.xml diff --git a/app/src/main/res/drawable/ic_palette_white_24dp.xml b/app/src/main/res/drawable-night/ic_palette.xml similarity index 100% rename from app/src/main/res/drawable/ic_palette_white_24dp.xml rename to app/src/main/res/drawable-night/ic_palette.xml diff --git a/app/src/main/res/drawable/ic_pause_white_24dp.xml b/app/src/main/res/drawable-night/ic_pause.xml similarity index 100% rename from app/src/main/res/drawable/ic_pause_white_24dp.xml rename to app/src/main/res/drawable-night/ic_pause.xml diff --git a/app/src/main/res/drawable/ic_people_white_24dp.xml b/app/src/main/res/drawable-night/ic_people.xml similarity index 100% rename from app/src/main/res/drawable/ic_people_white_24dp.xml rename to app/src/main/res/drawable-night/ic_people.xml diff --git a/app/src/main/res/drawable/ic_person_white_24dp.xml b/app/src/main/res/drawable-night/ic_person.xml similarity index 100% rename from app/src/main/res/drawable/ic_person_white_24dp.xml rename to app/src/main/res/drawable-night/ic_person.xml diff --git a/app/src/main/res/drawable/ic_pets_white_24dp.xml b/app/src/main/res/drawable-night/ic_pets.xml similarity index 100% rename from app/src/main/res/drawable/ic_pets_white_24dp.xml rename to app/src/main/res/drawable-night/ic_pets.xml diff --git a/app/src/main/res/drawable/ic_picture_in_picture_white_24dp.xml b/app/src/main/res/drawable-night/ic_picture_in_picture.xml similarity index 91% rename from app/src/main/res/drawable/ic_picture_in_picture_white_24dp.xml rename to app/src/main/res/drawable-night/ic_picture_in_picture.xml index f6b3205cc..1b01f3233 100644 --- a/app/src/main/res/drawable/ic_picture_in_picture_white_24dp.xml +++ b/app/src/main/res/drawable-night/ic_picture_in_picture.xml @@ -5,6 +5,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable/ic_play_arrow_white_24dp.xml b/app/src/main/res/drawable-night/ic_play_arrow.xml similarity index 87% rename from app/src/main/res/drawable/ic_play_arrow_white_24dp.xml rename to app/src/main/res/drawable-night/ic_play_arrow.xml index 098b71d1f..95cace1c8 100644 --- a/app/src/main/res/drawable/ic_play_arrow_white_24dp.xml +++ b/app/src/main/res/drawable-night/ic_play_arrow.xml @@ -5,6 +5,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable/ic_playlist_add_white_24dp.xml b/app/src/main/res/drawable-night/ic_playlist_add.xml similarity index 100% rename from app/src/main/res/drawable/ic_playlist_add_white_24dp.xml rename to app/src/main/res/drawable-night/ic_playlist_add.xml diff --git a/app/src/main/res/drawable/ic_playlist_add_check_white_24dp.xml b/app/src/main/res/drawable-night/ic_playlist_add_check.xml similarity index 100% rename from app/src/main/res/drawable/ic_playlist_add_check_white_24dp.xml rename to app/src/main/res/drawable-night/ic_playlist_add_check.xml diff --git a/app/src/main/res/drawable/ic_public_white_24dp.xml b/app/src/main/res/drawable-night/ic_public.xml similarity index 100% rename from app/src/main/res/drawable/ic_public_white_24dp.xml rename to app/src/main/res/drawable-night/ic_public.xml diff --git a/app/src/main/res/drawable/ic_radio_white_24dp.xml b/app/src/main/res/drawable-night/ic_radio.xml similarity index 100% rename from app/src/main/res/drawable/ic_radio_white_24dp.xml rename to app/src/main/res/drawable-night/ic_radio.xml diff --git a/app/src/main/res/drawable/ic_refresh_white_24dp.xml b/app/src/main/res/drawable-night/ic_refresh.xml similarity index 100% rename from app/src/main/res/drawable/ic_refresh_white_24dp.xml rename to app/src/main/res/drawable-night/ic_refresh.xml diff --git a/app/src/main/res/drawable/ic_restaurant_white_24dp.xml b/app/src/main/res/drawable-night/ic_restaurant.xml similarity index 100% rename from app/src/main/res/drawable/ic_restaurant_white_24dp.xml rename to app/src/main/res/drawable-night/ic_restaurant.xml diff --git a/app/src/main/res/drawable/ic_rss_feed_white_24dp.xml b/app/src/main/res/drawable-night/ic_rss_feed.xml similarity index 100% rename from app/src/main/res/drawable/ic_rss_feed_white_24dp.xml rename to app/src/main/res/drawable-night/ic_rss_feed.xml diff --git a/app/src/main/res/drawable/ic_save_white_24dp.xml b/app/src/main/res/drawable-night/ic_save.xml similarity index 100% rename from app/src/main/res/drawable/ic_save_white_24dp.xml rename to app/src/main/res/drawable-night/ic_save.xml diff --git a/app/src/main/res/drawable/ic_school_white_24dp.xml b/app/src/main/res/drawable-night/ic_school.xml similarity index 100% rename from app/src/main/res/drawable/ic_school_white_24dp.xml rename to app/src/main/res/drawable-night/ic_school.xml diff --git a/app/src/main/res/drawable/ic_search_white_24dp.xml b/app/src/main/res/drawable-night/ic_search.xml similarity index 100% rename from app/src/main/res/drawable/ic_search_white_24dp.xml rename to app/src/main/res/drawable-night/ic_search.xml diff --git a/app/src/main/res/drawable/ic_search_add_white_24dp.xml b/app/src/main/res/drawable-night/ic_search_add.xml similarity index 100% rename from app/src/main/res/drawable/ic_search_add_white_24dp.xml rename to app/src/main/res/drawable-night/ic_search_add.xml diff --git a/app/src/main/res/drawable-night/ic_select_all.xml b/app/src/main/res/drawable-night/ic_select_all.xml new file mode 100644 index 000000000..157734911 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_select_all.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_white_24dp.xml b/app/src/main/res/drawable-night/ic_settings.xml similarity index 100% rename from app/src/main/res/drawable/ic_settings_white_24dp.xml rename to app/src/main/res/drawable-night/ic_settings.xml diff --git a/app/src/main/res/drawable/ic_share_white_24dp.xml b/app/src/main/res/drawable-night/ic_share.xml similarity index 100% rename from app/src/main/res/drawable/ic_share_white_24dp.xml rename to app/src/main/res/drawable-night/ic_share.xml diff --git a/app/src/main/res/drawable/ic_shopping_cart_white_24dp.xml b/app/src/main/res/drawable-night/ic_shopping_cart.xml similarity index 100% rename from app/src/main/res/drawable/ic_shopping_cart_white_24dp.xml rename to app/src/main/res/drawable-night/ic_shopping_cart.xml diff --git a/app/src/main/res/drawable/ic_sort_white_24dp.xml b/app/src/main/res/drawable-night/ic_sort.xml similarity index 100% rename from app/src/main/res/drawable/ic_sort_white_24dp.xml rename to app/src/main/res/drawable-night/ic_sort.xml diff --git a/app/src/main/res/drawable/ic_stars_white_24dp.xml b/app/src/main/res/drawable-night/ic_stars.xml similarity index 100% rename from app/src/main/res/drawable/ic_stars_white_24dp.xml rename to app/src/main/res/drawable-night/ic_stars.xml diff --git a/app/src/main/res/drawable/ic_telescope_white_24dp.xml b/app/src/main/res/drawable-night/ic_telescope.xml similarity index 100% rename from app/src/main/res/drawable/ic_telescope_white_24dp.xml rename to app/src/main/res/drawable-night/ic_telescope.xml diff --git a/app/src/main/res/drawable/ic_thumb_down_white_24dp.xml b/app/src/main/res/drawable-night/ic_thumb_down.xml similarity index 100% rename from app/src/main/res/drawable/ic_thumb_down_white_24dp.xml rename to app/src/main/res/drawable-night/ic_thumb_down.xml diff --git a/app/src/main/res/drawable/ic_thumb_up_white_24dp.xml b/app/src/main/res/drawable-night/ic_thumb_up.xml similarity index 100% rename from app/src/main/res/drawable/ic_thumb_up_white_24dp.xml rename to app/src/main/res/drawable-night/ic_thumb_up.xml diff --git a/app/src/main/res/drawable/ic_trending_up_white_24dp.xml b/app/src/main/res/drawable-night/ic_trending_up.xml similarity index 100% rename from app/src/main/res/drawable/ic_trending_up_white_24dp.xml rename to app/src/main/res/drawable-night/ic_trending_up.xml diff --git a/app/src/main/res/drawable/ic_tv_white_24dp.xml b/app/src/main/res/drawable-night/ic_tv.xml similarity index 100% rename from app/src/main/res/drawable/ic_tv_white_24dp.xml rename to app/src/main/res/drawable-night/ic_tv.xml diff --git a/app/src/main/res/drawable/ic_videogame_asset_white_24dp.xml b/app/src/main/res/drawable-night/ic_videogame_asset.xml similarity index 100% rename from app/src/main/res/drawable/ic_videogame_asset_white_24dp.xml rename to app/src/main/res/drawable-night/ic_videogame_asset.xml diff --git a/app/src/main/res/drawable-night/ic_visibility_off.xml b/app/src/main/res/drawable-night/ic_visibility_off.xml new file mode 100644 index 000000000..689f3f47c --- /dev/null +++ b/app/src/main/res/drawable-night/ic_visibility_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable-night/ic_visibility_on.xml b/app/src/main/res/drawable-night/ic_visibility_on.xml new file mode 100644 index 000000000..e02f1d191 --- /dev/null +++ b/app/src/main/res/drawable-night/ic_visibility_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_off_black_24dp.xml b/app/src/main/res/drawable-night/ic_volume_off.xml similarity index 94% rename from app/src/main/res/drawable/ic_volume_off_black_24dp.xml rename to app/src/main/res/drawable-night/ic_volume_off.xml index 19f166ddc..a2cabcee0 100644 --- a/app/src/main/res/drawable/ic_volume_off_black_24dp.xml +++ b/app/src/main/res/drawable-night/ic_volume_off.xml @@ -4,6 +4,7 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> + diff --git a/app/src/main/res/drawable/ic_volume_up_white_24dp.xml b/app/src/main/res/drawable-night/ic_volume_up.xml similarity index 100% rename from app/src/main/res/drawable/ic_volume_up_white_24dp.xml rename to app/src/main/res/drawable-night/ic_volume_up.xml diff --git a/app/src/main/res/drawable/ic_watch_later_white_24dp.xml b/app/src/main/res/drawable-night/ic_watch_later.xml similarity index 100% rename from app/src/main/res/drawable/ic_watch_later_white_24dp.xml rename to app/src/main/res/drawable-night/ic_watch_later.xml diff --git a/app/src/main/res/drawable/ic_wb_sunny_white_24dp.xml b/app/src/main/res/drawable-night/ic_wb_sunny.xml similarity index 100% rename from app/src/main/res/drawable/ic_wb_sunny_white_24dp.xml rename to app/src/main/res/drawable-night/ic_wb_sunny.xml diff --git a/app/src/main/res/drawable/ic_whatshot_white_24dp.xml b/app/src/main/res/drawable-night/ic_whatshot.xml similarity index 100% rename from app/src/main/res/drawable/ic_whatshot_white_24dp.xml rename to app/src/main/res/drawable-night/ic_whatshot.xml diff --git a/app/src/main/res/drawable/ic_work_white_24dp.xml b/app/src/main/res/drawable-night/ic_work.xml similarity index 100% rename from app/src/main/res/drawable/ic_work_white_24dp.xml rename to app/src/main/res/drawable-night/ic_work.xml diff --git a/app/src/main/res/drawable-nodpi/background_header.png b/app/src/main/res/drawable-nodpi/background_header.png index 04032b55d..e00e9a21f 100644 Binary files a/app/src/main/res/drawable-nodpi/background_header.png and b/app/src/main/res/drawable-nodpi/background_header.png differ diff --git a/app/src/main/res/drawable-nodpi/buddy.png b/app/src/main/res/drawable-nodpi/buddy.png index 878c5dff3..8713ee02b 100644 Binary files a/app/src/main/res/drawable-nodpi/buddy.png and b/app/src/main/res/drawable-nodpi/buddy.png 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 index 3c5f8f994..64d4cb1a0 100644 Binary files a/app/src/main/res/drawable-nodpi/buddy_channel_item.png and b/app/src/main/res/drawable-nodpi/buddy_channel_item.png differ diff --git a/app/src/main/res/drawable-nodpi/channel_banner.png b/app/src/main/res/drawable-nodpi/channel_banner.png index 7532bd3a2..12e70bb6d 100644 Binary files a/app/src/main/res/drawable-nodpi/channel_banner.png and b/app/src/main/res/drawable-nodpi/channel_banner.png differ diff --git a/app/src/main/res/drawable-nodpi/dummy_thumbnail.png b/app/src/main/res/drawable-nodpi/dummy_thumbnail.png index 49d6e5110..86f454186 100644 Binary files a/app/src/main/res/drawable-nodpi/dummy_thumbnail.png and b/app/src/main/res/drawable-nodpi/dummy_thumbnail.png 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 index d6ab854c3..02f698918 100644 Binary files a/app/src/main/res/drawable-nodpi/dummy_thumbnail_dark.png and b/app/src/main/res/drawable-nodpi/dummy_thumbnail_dark.png 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 index 3873b83cc..9ba84fdb4 100644 Binary files a/app/src/main/res/drawable-nodpi/dummy_thumbnail_playlist.png and b/app/src/main/res/drawable-nodpi/dummy_thumbnail_playlist.png 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 index 55c5c105d..49c12af83 100644 Binary files a/app/src/main/res/drawable-nodpi/newpipe_logo_nude_shadow.png and b/app/src/main/res/drawable-nodpi/newpipe_logo_nude_shadow.png differ diff --git a/app/src/main/res/drawable-nodpi/not_available_monkey.png b/app/src/main/res/drawable-nodpi/not_available_monkey.png index babd53602..35b216c48 100644 Binary files a/app/src/main/res/drawable-nodpi/not_available_monkey.png and b/app/src/main/res/drawable-nodpi/not_available_monkey.png 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 index 848e109c2..13c44b649 100644 Binary files a/app/src/main/res/drawable-nodpi/place_holder_bandcamp.png and b/app/src/main/res/drawable-nodpi/place_holder_bandcamp.png 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 index 0f9bd26c2..c4ba2a6f4 100644 Binary files a/app/src/main/res/drawable-nodpi/place_holder_cloud.png and b/app/src/main/res/drawable-nodpi/place_holder_cloud.png 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 index 7e3d22e81..9b479ed4f 100644 Binary files a/app/src/main/res/drawable-nodpi/place_holder_gadse.png and b/app/src/main/res/drawable-nodpi/place_holder_gadse.png 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 index 331bf94f6..81dfdb8cc 100644 Binary files a/app/src/main/res/drawable-nodpi/place_holder_peertube.png and b/app/src/main/res/drawable-nodpi/place_holder_peertube.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 new file mode 100644 index 000000000..568663ed0 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_close_white.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_close_white_24dp_png.png b/app/src/main/res/drawable-xhdpi/ic_close_white_24dp_png.png deleted file mode 100644 index fc69b5bb5..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_close_white_24dp_png.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 new file mode 100644 index 000000000..e53c699db Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_hourglass_top_white.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_hourglass_top_white_24dp_png.png b/app/src/main/res/drawable-xhdpi/ic_hourglass_top_white_24dp_png.png deleted file mode 100644 index 29a36f543..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_hourglass_top_white_24dp_png.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 d4e94d0d1..5fe229a96 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_newpipe_update.png b/app/src/main/res/drawable-xhdpi/ic_newpipe_update.png index b9a296064..31eba305c 100755 Binary files a/app/src/main/res/drawable-xhdpi/ic_newpipe_update.png and b/app/src/main/res/drawable-xhdpi/ic_newpipe_update.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 new file mode 100644 index 000000000..47b75ceb9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_replay_white.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_replay_white_24dp_png.png b/app/src/main/res/drawable-xhdpi/ic_replay_white_24dp_png.png deleted file mode 100644 index 153e3dbf3..000000000 Binary files a/app/src/main/res/drawable-xhdpi/ic_replay_white_24dp_png.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 new file mode 100644 index 000000000..990895143 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_close_white.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_close_white_24dp_png.png b/app/src/main/res/drawable-xxhdpi/ic_close_white_24dp_png.png deleted file mode 100644 index 9ec308cef..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_close_white_24dp_png.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 new file mode 100644 index 000000000..b8b98737f Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_hourglass_top_white.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_hourglass_top_white_24dp_png.png b/app/src/main/res/drawable-xxhdpi/ic_hourglass_top_white_24dp_png.png deleted file mode 100644 index 9d214c497..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_hourglass_top_white_24dp_png.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 fa554585f..595d5ab11 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_newpipe_update.png b/app/src/main/res/drawable-xxhdpi/ic_newpipe_update.png index 5d348e6e3..eda411234 100755 Binary files a/app/src/main/res/drawable-xxhdpi/ic_newpipe_update.png and b/app/src/main/res/drawable-xxhdpi/ic_newpipe_update.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 new file mode 100644 index 000000000..9a8e1507d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_replay_white.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_replay_white_24dp_png.png b/app/src/main/res/drawable-xxhdpi/ic_replay_white_24dp_png.png deleted file mode 100644 index dc60f4ecd..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/ic_replay_white_24dp_png.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 new file mode 100644 index 000000000..06854ca49 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_close_white.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_close_white_24dp_png.png b/app/src/main/res/drawable-xxxhdpi/ic_close_white_24dp_png.png deleted file mode 100644 index 535d1df0c..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_close_white_24dp_png.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_hourglass_top_white_24dp_png.png b/app/src/main/res/drawable-xxxhdpi/ic_hourglass_top_white.png similarity index 100% rename from app/src/main/res/drawable-xxxhdpi/ic_hourglass_top_white_24dp_png.png rename to app/src/main/res/drawable-xxxhdpi/ic_hourglass_top_white.png 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 26e134fac..699e0c158 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_newpipe_update.png b/app/src/main/res/drawable-xxxhdpi/ic_newpipe_update.png index bc06d3953..0771140c1 100755 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_newpipe_update.png and b/app/src/main/res/drawable-xxxhdpi/ic_newpipe_update.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 new file mode 100644 index 000000000..6a9092761 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_replay_white.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_replay_white_24dp_png.png b/app/src/main/res/drawable-xxxhdpi/ic_replay_white_24dp_png.png deleted file mode 100644 index 372bc8bd1..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/ic_replay_white_24dp_png.png and /dev/null differ diff --git a/app/src/main/res/drawable/ic_add_black_24dp.xml b/app/src/main/res/drawable/ic_add.xml similarity index 100% rename from app/src/main/res/drawable/ic_add_black_24dp.xml rename to app/src/main/res/drawable/ic_add.xml diff --git a/app/src/main/res/drawable/ic_add_circle_outline_black_24dp.xml b/app/src/main/res/drawable/ic_add_circle_outline.xml similarity index 100% rename from app/src/main/res/drawable/ic_add_circle_outline_black_24dp.xml rename to app/src/main/res/drawable/ic_add_circle_outline.xml diff --git a/app/src/main/res/drawable/ic_apps_black_24dp.xml b/app/src/main/res/drawable/ic_apps.xml similarity index 100% rename from app/src/main/res/drawable/ic_apps_black_24dp.xml rename to app/src/main/res/drawable/ic_apps.xml diff --git a/app/src/main/res/drawable/ic_arrow_back_black_24dp.xml b/app/src/main/res/drawable/ic_arrow_back.xml similarity index 100% rename from app/src/main/res/drawable/ic_arrow_back_black_24dp.xml rename to app/src/main/res/drawable/ic_arrow_back.xml diff --git a/app/src/main/res/drawable/ic_arrow_drop_down_white_24dp.xml b/app/src/main/res/drawable/ic_arrow_drop_down.xml similarity index 100% rename from app/src/main/res/drawable/ic_arrow_drop_down_white_24dp.xml rename to app/src/main/res/drawable/ic_arrow_drop_down.xml diff --git a/app/src/main/res/drawable/ic_arrow_drop_up_white_24dp.xml b/app/src/main/res/drawable/ic_arrow_drop_up.xml similarity index 100% rename from app/src/main/res/drawable/ic_arrow_drop_up_white_24dp.xml rename to app/src/main/res/drawable/ic_arrow_drop_up.xml diff --git a/app/src/main/res/drawable/ic_art_track_white_24dp.xml b/app/src/main/res/drawable/ic_art_track.xml similarity index 100% rename from app/src/main/res/drawable/ic_art_track_white_24dp.xml rename to app/src/main/res/drawable/ic_art_track.xml diff --git a/app/src/main/res/drawable/ic_asterisk_black_24dp.xml b/app/src/main/res/drawable/ic_asterisk.xml similarity index 100% rename from app/src/main/res/drawable/ic_asterisk_black_24dp.xml rename to app/src/main/res/drawable/ic_asterisk.xml diff --git a/app/src/main/res/drawable/ic_attach_money_black_24dp.xml b/app/src/main/res/drawable/ic_attach_money.xml similarity index 100% rename from app/src/main/res/drawable/ic_attach_money_black_24dp.xml rename to app/src/main/res/drawable/ic_attach_money.xml diff --git a/app/src/main/res/drawable/ic_backup_black_24dp.xml b/app/src/main/res/drawable/ic_backup.xml similarity index 100% rename from app/src/main/res/drawable/ic_backup_black_24dp.xml rename to app/src/main/res/drawable/ic_backup.xml diff --git a/app/src/main/res/drawable/ic_bookmark_black_24dp.xml b/app/src/main/res/drawable/ic_bookmark.xml similarity index 100% rename from app/src/main/res/drawable/ic_bookmark_black_24dp.xml rename to app/src/main/res/drawable/ic_bookmark.xml diff --git a/app/src/main/res/drawable/ic_brightness_high_white_24dp.xml b/app/src/main/res/drawable/ic_brightness_high.xml similarity index 100% rename from app/src/main/res/drawable/ic_brightness_high_white_24dp.xml rename to app/src/main/res/drawable/ic_brightness_high.xml diff --git a/app/src/main/res/drawable/ic_brightness_low_white_24dp.xml b/app/src/main/res/drawable/ic_brightness_low.xml similarity index 100% rename from app/src/main/res/drawable/ic_brightness_low_white_24dp.xml rename to app/src/main/res/drawable/ic_brightness_low.xml diff --git a/app/src/main/res/drawable/ic_brightness_medium_white_24dp.xml b/app/src/main/res/drawable/ic_brightness_medium.xml similarity index 100% rename from app/src/main/res/drawable/ic_brightness_medium_white_24dp.xml rename to app/src/main/res/drawable/ic_brightness_medium.xml diff --git a/app/src/main/res/drawable/ic_bug_report_black_24dp.xml b/app/src/main/res/drawable/ic_bug_report.xml similarity index 100% rename from app/src/main/res/drawable/ic_bug_report_black_24dp.xml rename to app/src/main/res/drawable/ic_bug_report.xml diff --git a/app/src/main/res/drawable/ic_cast_black_24dp.xml b/app/src/main/res/drawable/ic_cast.xml similarity index 100% rename from app/src/main/res/drawable/ic_cast_black_24dp.xml rename to app/src/main/res/drawable/ic_cast.xml diff --git a/app/src/main/res/drawable/ic_child_care_black_24dp.xml b/app/src/main/res/drawable/ic_child_care.xml similarity index 100% rename from app/src/main/res/drawable/ic_child_care_black_24dp.xml rename to app/src/main/res/drawable/ic_child_care.xml diff --git a/app/src/main/res/drawable/ic_close_black_24dp.xml b/app/src/main/res/drawable/ic_close.xml similarity index 100% rename from app/src/main/res/drawable/ic_close_black_24dp.xml rename to app/src/main/res/drawable/ic_close.xml diff --git a/app/src/main/res/drawable/ic_cloud_download_black_24dp.xml b/app/src/main/res/drawable/ic_cloud_download.xml similarity index 100% rename from app/src/main/res/drawable/ic_cloud_download_black_24dp.xml rename to app/src/main/res/drawable/ic_cloud_download.xml diff --git a/app/src/main/res/drawable/ic_comment_white_24dp.xml b/app/src/main/res/drawable/ic_comment.xml similarity index 100% rename from app/src/main/res/drawable/ic_comment_white_24dp.xml rename to app/src/main/res/drawable/ic_comment.xml diff --git a/app/src/main/res/drawable/ic_computer_black_24dp.xml b/app/src/main/res/drawable/ic_computer.xml similarity index 100% rename from app/src/main/res/drawable/ic_computer_black_24dp.xml rename to app/src/main/res/drawable/ic_computer.xml diff --git a/app/src/main/res/drawable/ic_crop_portrait_black_24dp.xml b/app/src/main/res/drawable/ic_crop_portrait.xml similarity index 100% rename from app/src/main/res/drawable/ic_crop_portrait_black_24dp.xml rename to app/src/main/res/drawable/ic_crop_portrait.xml diff --git a/app/src/main/res/drawable/ic_delete_black_24dp.xml b/app/src/main/res/drawable/ic_delete.xml similarity index 100% rename from app/src/main/res/drawable/ic_delete_black_24dp.xml rename to app/src/main/res/drawable/ic_delete.xml diff --git a/app/src/main/res/drawable/ic_description_white_24dp.xml b/app/src/main/res/drawable/ic_description.xml similarity index 100% rename from app/src/main/res/drawable/ic_description_white_24dp.xml rename to app/src/main/res/drawable/ic_description.xml diff --git a/app/src/main/res/drawable/ic_directions_bike_black_24dp.xml b/app/src/main/res/drawable/ic_directions_bike.xml similarity index 100% rename from app/src/main/res/drawable/ic_directions_bike_black_24dp.xml rename to app/src/main/res/drawable/ic_directions_bike.xml diff --git a/app/src/main/res/drawable/ic_directions_car_black_24dp.xml b/app/src/main/res/drawable/ic_directions_car.xml similarity index 100% rename from app/src/main/res/drawable/ic_directions_car_black_24dp.xml rename to app/src/main/res/drawable/ic_directions_car.xml diff --git a/app/src/main/res/drawable/ic_done_black_24dp.xml b/app/src/main/res/drawable/ic_done.xml similarity index 100% rename from app/src/main/res/drawable/ic_done_black_24dp.xml rename to app/src/main/res/drawable/ic_done.xml diff --git a/app/src/main/res/drawable/ic_drag_handle_black_24dp.xml b/app/src/main/res/drawable/ic_drag_handle.xml similarity index 100% rename from app/src/main/res/drawable/ic_drag_handle_black_24dp.xml rename to app/src/main/res/drawable/ic_drag_handle.xml diff --git a/app/src/main/res/drawable/ic_edit_black_24dp.xml b/app/src/main/res/drawable/ic_edit.xml similarity index 100% rename from app/src/main/res/drawable/ic_edit_black_24dp.xml rename to app/src/main/res/drawable/ic_edit.xml diff --git a/app/src/main/res/drawable/ic_expand_less_black_24dp.xml b/app/src/main/res/drawable/ic_expand_less.xml similarity index 100% rename from app/src/main/res/drawable/ic_expand_less_black_24dp.xml rename to app/src/main/res/drawable/ic_expand_less.xml diff --git a/app/src/main/res/drawable/ic_expand_more_black_24dp.xml b/app/src/main/res/drawable/ic_expand_more.xml similarity index 100% rename from app/src/main/res/drawable/ic_expand_more_black_24dp.xml rename to app/src/main/res/drawable/ic_expand_more.xml diff --git a/app/src/main/res/drawable/ic_explore_black_24dp.xml b/app/src/main/res/drawable/ic_explore.xml similarity index 100% rename from app/src/main/res/drawable/ic_explore_black_24dp.xml rename to app/src/main/res/drawable/ic_explore.xml diff --git a/app/src/main/res/drawable/ic_fast_forward_white_24dp.xml b/app/src/main/res/drawable/ic_fast_forward.xml similarity index 100% rename from app/src/main/res/drawable/ic_fast_forward_white_24dp.xml rename to app/src/main/res/drawable/ic_fast_forward.xml diff --git a/app/src/main/res/drawable/ic_fast_rewind_white_24dp.xml b/app/src/main/res/drawable/ic_fast_rewind.xml similarity index 100% rename from app/src/main/res/drawable/ic_fast_rewind_white_24dp.xml rename to app/src/main/res/drawable/ic_fast_rewind.xml diff --git a/app/src/main/res/drawable/ic_fastfood_black_24dp.xml b/app/src/main/res/drawable/ic_fastfood.xml similarity index 100% rename from app/src/main/res/drawable/ic_fastfood_black_24dp.xml rename to app/src/main/res/drawable/ic_fastfood.xml diff --git a/app/src/main/res/drawable/ic_favorite_black_24dp.xml b/app/src/main/res/drawable/ic_favorite.xml similarity index 100% rename from app/src/main/res/drawable/ic_favorite_black_24dp.xml rename to app/src/main/res/drawable/ic_favorite.xml diff --git a/app/src/main/res/drawable/ic_file_download_black_24dp.xml b/app/src/main/res/drawable/ic_file_download.xml similarity index 100% rename from app/src/main/res/drawable/ic_file_download_black_24dp.xml rename to app/src/main/res/drawable/ic_file_download.xml diff --git a/app/src/main/res/drawable/ic_filter_list_black_24dp.xml b/app/src/main/res/drawable/ic_filter_list.xml similarity index 100% rename from app/src/main/res/drawable/ic_filter_list_black_24dp.xml rename to app/src/main/res/drawable/ic_filter_list.xml diff --git a/app/src/main/res/drawable/ic_fitness_center_black_24dp.xml b/app/src/main/res/drawable/ic_fitness_center.xml similarity index 100% rename from app/src/main/res/drawable/ic_fitness_center_black_24dp.xml rename to app/src/main/res/drawable/ic_fitness_center.xml diff --git a/app/src/main/res/drawable/ic_format_list_numbered_white_24.xml b/app/src/main/res/drawable/ic_format_list_numbered.xml similarity index 100% rename from app/src/main/res/drawable/ic_format_list_numbered_white_24.xml rename to app/src/main/res/drawable/ic_format_list_numbered.xml diff --git a/app/src/main/res/drawable/ic_fullscreen_white_24dp.xml b/app/src/main/res/drawable/ic_fullscreen.xml similarity index 100% rename from app/src/main/res/drawable/ic_fullscreen_white_24dp.xml rename to app/src/main/res/drawable/ic_fullscreen.xml diff --git a/app/src/main/res/drawable/ic_fullscreen_exit_white_24dp.xml b/app/src/main/res/drawable/ic_fullscreen_exit.xml similarity index 100% rename from app/src/main/res/drawable/ic_fullscreen_exit_white_24dp.xml rename to app/src/main/res/drawable/ic_fullscreen_exit.xml diff --git a/app/src/main/res/drawable/ic_headset_black_24dp.xml b/app/src/main/res/drawable/ic_headset.xml similarity index 100% rename from app/src/main/res/drawable/ic_headset_black_24dp.xml rename to app/src/main/res/drawable/ic_headset.xml diff --git a/app/src/main/res/drawable/ic_heart.xml b/app/src/main/res/drawable/ic_heart.xml new file mode 100644 index 000000000..86d1f0527 --- /dev/null +++ b/app/src/main/res/drawable/ic_heart.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_help_black_24dp.xml b/app/src/main/res/drawable/ic_help.xml similarity index 100% rename from app/src/main/res/drawable/ic_help_black_24dp.xml rename to app/src/main/res/drawable/ic_help.xml diff --git a/app/src/main/res/drawable/ic_history_black_24dp.xml b/app/src/main/res/drawable/ic_history.xml similarity index 100% rename from app/src/main/res/drawable/ic_history_black_24dp.xml rename to app/src/main/res/drawable/ic_history.xml diff --git a/app/src/main/res/drawable/ic_home_black_24dp.xml b/app/src/main/res/drawable/ic_home.xml similarity index 100% rename from app/src/main/res/drawable/ic_home_black_24dp.xml rename to app/src/main/res/drawable/ic_home.xml diff --git a/app/src/main/res/drawable/ic_hourglass_top_white_24dp.xml b/app/src/main/res/drawable/ic_hourglass_top.xml similarity index 100% rename from app/src/main/res/drawable/ic_hourglass_top_white_24dp.xml rename to app/src/main/res/drawable/ic_hourglass_top.xml diff --git a/app/src/main/res/drawable/ic_import_export_black_24dp.xml b/app/src/main/res/drawable/ic_import_export.xml similarity index 100% rename from app/src/main/res/drawable/ic_import_export_black_24dp.xml rename to app/src/main/res/drawable/ic_import_export.xml diff --git a/app/src/main/res/drawable/ic_info_outline_black_24dp.xml b/app/src/main/res/drawable/ic_info_outline.xml similarity index 100% rename from app/src/main/res/drawable/ic_info_outline_black_24dp.xml rename to app/src/main/res/drawable/ic_info_outline.xml diff --git a/app/src/main/res/drawable/ic_insert_emoticon_black_24dp.xml b/app/src/main/res/drawable/ic_insert_emoticon.xml similarity index 100% rename from app/src/main/res/drawable/ic_insert_emoticon_black_24dp.xml rename to app/src/main/res/drawable/ic_insert_emoticon.xml diff --git a/app/src/main/res/drawable/ic_language_black_24dp.xml b/app/src/main/res/drawable/ic_language.xml similarity index 100% rename from app/src/main/res/drawable/ic_language_black_24dp.xml rename to app/src/main/res/drawable/ic_language.xml diff --git a/app/src/main/res/drawable/ic_list_black_24dp.xml b/app/src/main/res/drawable/ic_list.xml similarity index 100% rename from app/src/main/res/drawable/ic_list_black_24dp.xml rename to app/src/main/res/drawable/ic_list.xml diff --git a/app/src/main/res/drawable/ic_live_tv_black_24dp.xml b/app/src/main/res/drawable/ic_live_tv.xml similarity index 100% rename from app/src/main/res/drawable/ic_live_tv_black_24dp.xml rename to app/src/main/res/drawable/ic_live_tv.xml diff --git a/app/src/main/res/drawable/ic_megaphone_black_24dp.xml b/app/src/main/res/drawable/ic_megaphone.xml similarity index 100% rename from app/src/main/res/drawable/ic_megaphone_black_24dp.xml rename to app/src/main/res/drawable/ic_megaphone.xml diff --git a/app/src/main/res/drawable/ic_mic_black_24dp.xml b/app/src/main/res/drawable/ic_mic.xml similarity index 100% rename from app/src/main/res/drawable/ic_mic_black_24dp.xml rename to app/src/main/res/drawable/ic_mic.xml diff --git a/app/src/main/res/drawable/ic_more_vert_black_24dp.xml b/app/src/main/res/drawable/ic_more_vert.xml similarity index 100% rename from app/src/main/res/drawable/ic_more_vert_black_24dp.xml rename to app/src/main/res/drawable/ic_more_vert.xml diff --git a/app/src/main/res/drawable/ic_motorcycle_black_24dp.xml b/app/src/main/res/drawable/ic_motorcycle.xml similarity index 100% rename from app/src/main/res/drawable/ic_motorcycle_black_24dp.xml rename to app/src/main/res/drawable/ic_motorcycle.xml diff --git a/app/src/main/res/drawable/ic_movie_black_24dp.xml b/app/src/main/res/drawable/ic_movie.xml similarity index 100% rename from app/src/main/res/drawable/ic_movie_black_24dp.xml rename to app/src/main/res/drawable/ic_movie.xml diff --git a/app/src/main/res/drawable/ic_music_note_black_24dp.xml b/app/src/main/res/drawable/ic_music_note.xml similarity index 100% rename from app/src/main/res/drawable/ic_music_note_black_24dp.xml rename to app/src/main/res/drawable/ic_music_note.xml diff --git a/app/src/main/res/drawable/ic_next_white_24dp.xml b/app/src/main/res/drawable/ic_next.xml similarity index 100% rename from app/src/main/res/drawable/ic_next_white_24dp.xml rename to app/src/main/res/drawable/ic_next.xml diff --git a/app/src/main/res/drawable/ic_palette_black_24dp.xml b/app/src/main/res/drawable/ic_palette.xml similarity index 100% rename from app/src/main/res/drawable/ic_palette_black_24dp.xml rename to app/src/main/res/drawable/ic_palette.xml diff --git a/app/src/main/res/drawable/ic_pause_black_24dp.xml b/app/src/main/res/drawable/ic_pause.xml similarity index 100% rename from app/src/main/res/drawable/ic_pause_black_24dp.xml rename to app/src/main/res/drawable/ic_pause.xml diff --git a/app/src/main/res/drawable/ic_people_black_24dp.xml b/app/src/main/res/drawable/ic_people.xml similarity index 100% rename from app/src/main/res/drawable/ic_people_black_24dp.xml rename to app/src/main/res/drawable/ic_people.xml diff --git a/app/src/main/res/drawable/ic_person_black_24dp.xml b/app/src/main/res/drawable/ic_person.xml similarity index 100% rename from app/src/main/res/drawable/ic_person_black_24dp.xml rename to app/src/main/res/drawable/ic_person.xml diff --git a/app/src/main/res/drawable/ic_pets_black_24dp.xml b/app/src/main/res/drawable/ic_pets.xml similarity index 100% rename from app/src/main/res/drawable/ic_pets_black_24dp.xml rename to app/src/main/res/drawable/ic_pets.xml diff --git a/app/src/main/res/drawable/ic_picture_in_picture_black_24dp.xml b/app/src/main/res/drawable/ic_picture_in_picture.xml similarity index 100% rename from app/src/main/res/drawable/ic_picture_in_picture_black_24dp.xml rename to app/src/main/res/drawable/ic_picture_in_picture.xml diff --git a/app/src/main/res/drawable/ic_play_arrow_black_24dp.xml b/app/src/main/res/drawable/ic_play_arrow.xml similarity index 100% rename from app/src/main/res/drawable/ic_play_arrow_black_24dp.xml rename to app/src/main/res/drawable/ic_play_arrow.xml diff --git a/app/src/main/res/drawable/ic_playlist_add_black_24dp.xml b/app/src/main/res/drawable/ic_playlist_add.xml similarity index 100% rename from app/src/main/res/drawable/ic_playlist_add_black_24dp.xml rename to app/src/main/res/drawable/ic_playlist_add.xml diff --git a/app/src/main/res/drawable/ic_playlist_add_check_black_24dp.xml b/app/src/main/res/drawable/ic_playlist_add_check.xml similarity index 100% rename from app/src/main/res/drawable/ic_playlist_add_check_black_24dp.xml rename to app/src/main/res/drawable/ic_playlist_add_check.xml diff --git a/app/src/main/res/drawable/ic_playlist_play_white_24dp.xml b/app/src/main/res/drawable/ic_playlist_play.xml similarity index 100% rename from app/src/main/res/drawable/ic_playlist_play_white_24dp.xml rename to app/src/main/res/drawable/ic_playlist_play.xml diff --git a/app/src/main/res/drawable/ic_previous_white_24dp.xml b/app/src/main/res/drawable/ic_previous.xml similarity index 100% rename from app/src/main/res/drawable/ic_previous_white_24dp.xml rename to app/src/main/res/drawable/ic_previous.xml diff --git a/app/src/main/res/drawable/ic_public_black_24dp.xml b/app/src/main/res/drawable/ic_public.xml similarity index 100% rename from app/src/main/res/drawable/ic_public_black_24dp.xml rename to app/src/main/res/drawable/ic_public.xml diff --git a/app/src/main/res/drawable/ic_radio_black_24dp.xml b/app/src/main/res/drawable/ic_radio.xml similarity index 100% rename from app/src/main/res/drawable/ic_radio_black_24dp.xml rename to app/src/main/res/drawable/ic_radio.xml diff --git a/app/src/main/res/drawable/ic_refresh_black_24dp.xml b/app/src/main/res/drawable/ic_refresh.xml similarity index 100% rename from app/src/main/res/drawable/ic_refresh_black_24dp.xml rename to app/src/main/res/drawable/ic_refresh.xml diff --git a/app/src/main/res/drawable/ic_repeat_white_24dp.xml b/app/src/main/res/drawable/ic_repeat.xml similarity index 100% rename from app/src/main/res/drawable/ic_repeat_white_24dp.xml rename to app/src/main/res/drawable/ic_repeat.xml diff --git a/app/src/main/res/drawable/ic_replay_white_24dp.xml b/app/src/main/res/drawable/ic_replay.xml similarity index 100% rename from app/src/main/res/drawable/ic_replay_white_24dp.xml rename to app/src/main/res/drawable/ic_replay.xml diff --git a/app/src/main/res/drawable/ic_restaurant_black_24dp.xml b/app/src/main/res/drawable/ic_restaurant.xml similarity index 100% rename from app/src/main/res/drawable/ic_restaurant_black_24dp.xml rename to app/src/main/res/drawable/ic_restaurant.xml diff --git a/app/src/main/res/drawable/ic_rss_feed_black_24dp.xml b/app/src/main/res/drawable/ic_rss_feed.xml similarity index 100% rename from app/src/main/res/drawable/ic_rss_feed_black_24dp.xml rename to app/src/main/res/drawable/ic_rss_feed.xml diff --git a/app/src/main/res/drawable/ic_save_black_24dp.xml b/app/src/main/res/drawable/ic_save.xml similarity index 100% rename from app/src/main/res/drawable/ic_save_black_24dp.xml rename to app/src/main/res/drawable/ic_save.xml diff --git a/app/src/main/res/drawable/ic_school_black_24dp.xml b/app/src/main/res/drawable/ic_school.xml similarity index 100% rename from app/src/main/res/drawable/ic_school_black_24dp.xml rename to app/src/main/res/drawable/ic_school.xml diff --git a/app/src/main/res/drawable/ic_screen_rotation_white_24dp.xml b/app/src/main/res/drawable/ic_screen_rotation.xml similarity index 100% rename from app/src/main/res/drawable/ic_screen_rotation_white_24dp.xml rename to app/src/main/res/drawable/ic_screen_rotation.xml diff --git a/app/src/main/res/drawable/ic_search_black_24dp.xml b/app/src/main/res/drawable/ic_search.xml similarity index 100% rename from app/src/main/res/drawable/ic_search_black_24dp.xml rename to app/src/main/res/drawable/ic_search.xml diff --git a/app/src/main/res/drawable/ic_search_add_black_24dp.xml b/app/src/main/res/drawable/ic_search_add.xml similarity index 100% rename from app/src/main/res/drawable/ic_search_add_black_24dp.xml rename to app/src/main/res/drawable/ic_search_add.xml diff --git a/app/src/main/res/drawable/ic_select_all.xml b/app/src/main/res/drawable/ic_select_all.xml new file mode 100644 index 000000000..e8693d51b --- /dev/null +++ b/app/src/main/res/drawable/ic_select_all.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_settings_black_24dp.xml b/app/src/main/res/drawable/ic_settings.xml similarity index 100% rename from app/src/main/res/drawable/ic_settings_black_24dp.xml rename to app/src/main/res/drawable/ic_settings.xml diff --git a/app/src/main/res/drawable/ic_settings_backup_restore_white_24dp.xml b/app/src/main/res/drawable/ic_settings_backup_restore.xml similarity index 100% rename from app/src/main/res/drawable/ic_settings_backup_restore_white_24dp.xml rename to app/src/main/res/drawable/ic_settings_backup_restore.xml diff --git a/app/src/main/res/drawable/ic_settings_backup_restore_black_24dp.xml b/app/src/main/res/drawable/ic_settings_backup_restore_black_24dp.xml deleted file mode 100644 index 57f966536..000000000 --- a/app/src/main/res/drawable/ic_settings_backup_restore_black_24dp.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - diff --git a/app/src/main/res/drawable/ic_share_black_24dp.xml b/app/src/main/res/drawable/ic_share.xml similarity index 100% rename from app/src/main/res/drawable/ic_share_black_24dp.xml rename to app/src/main/res/drawable/ic_share.xml diff --git a/app/src/main/res/drawable/ic_shopping_cart_black_24dp.xml b/app/src/main/res/drawable/ic_shopping_cart.xml similarity index 100% rename from app/src/main/res/drawable/ic_shopping_cart_black_24dp.xml rename to app/src/main/res/drawable/ic_shopping_cart.xml diff --git a/app/src/main/res/drawable/ic_shuffle_white_24dp.xml b/app/src/main/res/drawable/ic_shuffle.xml similarity index 100% rename from app/src/main/res/drawable/ic_shuffle_white_24dp.xml rename to app/src/main/res/drawable/ic_shuffle.xml diff --git a/app/src/main/res/drawable/ic_sort_black_24dp.xml b/app/src/main/res/drawable/ic_sort.xml similarity index 100% rename from app/src/main/res/drawable/ic_sort_black_24dp.xml rename to app/src/main/res/drawable/ic_sort.xml diff --git a/app/src/main/res/drawable/ic_stars_black_24dp.xml b/app/src/main/res/drawable/ic_stars.xml similarity index 100% rename from app/src/main/res/drawable/ic_stars_black_24dp.xml rename to app/src/main/res/drawable/ic_stars.xml diff --git a/app/src/main/res/drawable/ic_subtitles_white_24dp.xml b/app/src/main/res/drawable/ic_subtitles.xml similarity index 100% rename from app/src/main/res/drawable/ic_subtitles_white_24dp.xml rename to app/src/main/res/drawable/ic_subtitles.xml diff --git a/app/src/main/res/drawable/ic_telescope_black_24dp.xml b/app/src/main/res/drawable/ic_telescope.xml similarity index 100% rename from app/src/main/res/drawable/ic_telescope_black_24dp.xml rename to app/src/main/res/drawable/ic_telescope.xml diff --git a/app/src/main/res/drawable/ic_thumb_down_black_24dp.xml b/app/src/main/res/drawable/ic_thumb_down.xml similarity index 100% rename from app/src/main/res/drawable/ic_thumb_down_black_24dp.xml rename to app/src/main/res/drawable/ic_thumb_down.xml diff --git a/app/src/main/res/drawable/ic_thumb_up_black_24dp.xml b/app/src/main/res/drawable/ic_thumb_up.xml similarity index 100% rename from app/src/main/res/drawable/ic_thumb_up_black_24dp.xml rename to app/src/main/res/drawable/ic_thumb_up.xml diff --git a/app/src/main/res/drawable/ic_trending_up_black_24dp.xml b/app/src/main/res/drawable/ic_trending_up.xml similarity index 100% rename from app/src/main/res/drawable/ic_trending_up_black_24dp.xml rename to app/src/main/res/drawable/ic_trending_up.xml diff --git a/app/src/main/res/drawable/ic_tv_black_24dp.xml b/app/src/main/res/drawable/ic_tv.xml similarity index 100% rename from app/src/main/res/drawable/ic_tv_black_24dp.xml rename to app/src/main/res/drawable/ic_tv.xml diff --git a/app/src/main/res/drawable/ic_videogame_asset_black_24dp.xml b/app/src/main/res/drawable/ic_videogame_asset.xml similarity index 100% rename from app/src/main/res/drawable/ic_videogame_asset_black_24dp.xml rename to app/src/main/res/drawable/ic_videogame_asset.xml diff --git a/app/src/main/res/drawable/ic_visibility_off.xml b/app/src/main/res/drawable/ic_visibility_off.xml new file mode 100644 index 000000000..e0b170300 --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_visibility_on.xml b/app/src/main/res/drawable/ic_visibility_on.xml new file mode 100644 index 000000000..6c95a5d29 --- /dev/null +++ b/app/src/main/res/drawable/ic_visibility_on.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_volume_down_white_24dp.xml b/app/src/main/res/drawable/ic_volume_down.xml similarity index 100% rename from app/src/main/res/drawable/ic_volume_down_white_24dp.xml rename to app/src/main/res/drawable/ic_volume_down.xml diff --git a/app/src/main/res/drawable/ic_volume_mute_white_24dp.xml b/app/src/main/res/drawable/ic_volume_mute.xml similarity index 100% rename from app/src/main/res/drawable/ic_volume_mute_white_24dp.xml rename to app/src/main/res/drawable/ic_volume_mute.xml diff --git a/app/src/main/res/drawable/ic_volume_off_white_24dp.xml b/app/src/main/res/drawable/ic_volume_off.xml similarity index 98% rename from app/src/main/res/drawable/ic_volume_off_white_24dp.xml rename to app/src/main/res/drawable/ic_volume_off.xml index 2f8d6cfb4..7700239a3 100644 --- a/app/src/main/res/drawable/ic_volume_off_white_24dp.xml +++ b/app/src/main/res/drawable/ic_volume_off.xml @@ -7,4 +7,4 @@ - + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_volume_up_black_24dp.xml b/app/src/main/res/drawable/ic_volume_up.xml similarity index 94% rename from app/src/main/res/drawable/ic_volume_up_black_24dp.xml rename to app/src/main/res/drawable/ic_volume_up.xml index 2ee5bce43..aaaf84983 100644 --- a/app/src/main/res/drawable/ic_volume_up_black_24dp.xml +++ b/app/src/main/res/drawable/ic_volume_up.xml @@ -1,6 +1,7 @@ + + + diff --git a/app/src/main/res/drawable/splash_background.xml b/app/src/main/res/drawable/splash_background.xml index 61c3d058f..c9b018add 100644 --- a/app/src/main/res/drawable/splash_background.xml +++ b/app/src/main/res/drawable/splash_background.xml @@ -1,6 +1,12 @@ - + - + + + + \ No newline at end of file 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 b106e7437..4b79d92f6 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 @@ -12,8 +12,8 @@ android:id="@+id/appbar" android:layout_width="match_parent" android:layout_height="wrap_content" - android:theme="@style/ThemeOverlay.AppCompat.ActionBar" - app:popupTheme="@style/ThemeOverlay.AppCompat.ActionBar"> + android:theme="@style/ThemeOverlay.AppCompat.DayNight.ActionBar" + app:popupTheme="@style/ThemeOverlay.AppCompat.DayNight.ActionBar"> @@ -138,7 +139,7 @@ android:focusable="true" android:scaleType="fitCenter" android:tint="?attr/colorAccent" - app:srcCompat="@drawable/ic_pause_white_24dp" + app:srcCompat="@drawable/ic_pause" tools:ignore="ContentDescription" /> @@ -263,7 +264,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_alignParentBottom="true" - android:background="@drawable/player_controls_background" android:gravity="center" android:orientation="horizontal" android:paddingLeft="16dp" 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 e68ee76d8..d4f1ccc3d 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 @@ -199,7 +199,7 @@ android:layout_gravity="top|end" android:layout_marginTop="11dp" android:layout_marginEnd="10dp" - app:srcCompat="@drawable/ic_expand_more_white_24dp" + app:srcCompat="@drawable/ic_expand_more" tools:ignore="ContentDescription" /> @@ -333,7 +333,7 @@ android:text="@string/rss_button_title" android:textSize="12sp" android:theme="@style/RedButton" - app:drawableLeftCompat="@drawable/ic_rss_feed_white_24dp" + app:drawableLeftCompat="@drawable/ic_rss_feed" tools:ignore="RtlHardcoded" android:visibility="gone"/>--> @@ -367,7 +367,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="?attr/ic_thumb_up" /> + app:srcCompat="@drawable/ic_thumb_up" /> + app:drawableTopCompat="@drawable/ic_playlist_add" /> + app:drawableTopCompat="@drawable/ic_headset" /> + app:drawableTopCompat="@drawable/ic_picture_in_picture" /> + app:drawableTopCompat="@drawable/ic_file_download" /> @@ -529,7 +529,7 @@ android:paddingVertical="@dimen/detail_control_padding" android:text="@string/share" android:textSize="@dimen/detail_control_text_size" - app:drawableTopCompat="?attr/ic_share" /> + app:drawableTopCompat="@drawable/ic_share" /> + app:drawableTopCompat="@drawable/ic_language" /> + app:drawableTopCompat="@drawable/ic_cast" /> @@ -605,6 +605,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="bottom|center" + app:tabIndicatorGravity="top" app:tabIconTint="?attr/colorAccent" app:tabBackground="?attr/windowBackground" app:tabGravity="fill" @@ -613,7 +614,7 @@ diff --git a/app/src/main/res/layout-large-land/player.xml b/app/src/main/res/layout-large-land/player.xml index 07f3ae755..f55632174 100644 --- a/app/src/main/res/layout-large-land/player.xml +++ b/app/src/main/res/layout-large-land/player.xml @@ -5,7 +5,8 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="@color/black" - android:gravity="center"> + android:gravity="center" + android:theme="@style/WhiteTintTheme"> @@ -205,7 +208,8 @@ android:paddingBottom="3dp" android:scaleType="fitCenter" android:visibility="gone" - app:srcCompat="@drawable/ic_format_list_numbered_white_24" + app:srcCompat="@drawable/ic_format_list_numbered" + app:tint="@color/white" tools:ignore="ContentDescription,RtlHardcoded" tools:visibility="visible" /> @@ -218,7 +222,8 @@ android:focusable="true" android:padding="@dimen/player_main_buttons_padding" android:scaleType="fitXY" - app:srcCompat="@drawable/ic_expand_more_white_24dp" + app:srcCompat="@drawable/ic_expand_more" + app:tint="@color/white" tools:ignore="ContentDescription,RtlHardcoded" /> @@ -282,7 +287,8 @@ android:focusable="true" android:padding="@dimen/player_main_buttons_padding" android:scaleType="fitXY" - app:srcCompat="@drawable/ic_cast_white_24dp" + app:srcCompat="@drawable/ic_cast" + app:tint="@color/white" tools:ignore="RtlHardcoded" /> @@ -344,6 +354,50 @@ + + + + + + + + + + + + @@ -438,7 +493,8 @@ android:clickable="true" android:focusable="true" android:scaleType="fitCenter" - app:srcCompat="@drawable/ic_previous_white_24dp" + app:tint="@color/white" + app:srcCompat="@drawable/ic_previous" tools:ignore="ContentDescription" /> @@ -449,7 +505,8 @@ android:layout_weight="1" android:background="?attr/selectableItemBackgroundBorderless" android:scaleType="fitCenter" - app:srcCompat="@drawable/ic_pause_white_24dp" + app:tint="@color/white" + app:srcCompat="@drawable/ic_pause" tools:ignore="ContentDescription" /> @@ -474,7 +532,7 @@ android:layout_width="380dp" android:layout_height="match_parent" android:layout_alignParentEnd="true" - android:background="?attr/queue_background_color" + android:background="@color/queue_background_color" android:visibility="gone" tools:visibility="visible"> @@ -514,9 +572,10 @@ android:focusable="true" android:padding="10dp" android:scaleType="fitXY" - app:srcCompat="?attr/ic_close" /> + app:tint="@color/white" + app:srcCompat="@drawable/ic_close" /> - + + @@ -580,7 +651,7 @@ android:padding="15dp" android:visibility="gone" tools:ignore="ContentDescription" - tools:src="@drawable/ic_fast_rewind_white_24dp" + tools:src="@drawable/ic_fast_rewind" tools:visibility="visible" /> @@ -629,7 +700,7 @@ android:layout_height="70dp" android:layout_centerInParent="true" tools:ignore="ContentDescription" - tools:src="@drawable/ic_volume_up_white_24dp" /> + tools:src="@drawable/ic_volume_up" /> + tools:src="@drawable/ic_brightness_high" /> - diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml index 525b2371f..661c4affc 100644 --- a/app/src/main/res/layout/activity_about.xml +++ b/app/src/main/res/layout/activity_about.xml @@ -2,36 +2,37 @@ + android:theme="@style/ThemeOverlay.AppCompat.DayNight.ActionBar" + app:popupTheme="@style/ThemeOverlay.AppCompat.DayNight.ActionBar"> + android:layout_height="wrap_content" + app:tabTextColor="@color/white" + app:tabIndicatorColor="@color/white" /> diff --git a/app/src/main/res/layout/activity_error.xml b/app/src/main/res/layout/activity_error.xml index c7161ab8e..2dc668df1 100644 --- a/app/src/main/res/layout/activity_error.xml +++ b/app/src/main/res/layout/activity_error.xml @@ -72,7 +72,7 @@ android:textColor="?attr/colorAccent" /> 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 c7c86a069..ec47992bb 100644 --- a/app/src/main/res/layout/activity_player_queue_control.xml +++ b/app/src/main/res/layout/activity_player_queue_control.xml @@ -12,8 +12,8 @@ android:id="@+id/appbar" android:layout_width="match_parent" android:layout_height="wrap_content" - android:theme="@style/ThemeOverlay.AppCompat.ActionBar" - app:popupTheme="@style/ThemeOverlay.AppCompat.ActionBar"> + android:theme="@style/ThemeOverlay.AppCompat.DayNight.ActionBar" + app:popupTheme="@style/ThemeOverlay.AppCompat.DayNight.ActionBar"> @@ -104,7 +105,6 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_alignParentBottom="true" - android:background="@drawable/player_controls_background" android:gravity="center" android:orientation="horizontal" android:paddingLeft="12dp" @@ -180,7 +180,7 @@ android:focusable="true" android:scaleType="fitXY" android:tint="?attr/colorAccent" - app:srcCompat="@drawable/ic_repeat_white_24dp" + app:srcCompat="@drawable/ic_repeat" tools:ignore="ContentDescription" /> diff --git a/app/src/main/res/layout/activity_recaptcha.xml b/app/src/main/res/layout/activity_recaptcha.xml index 65428d9f1..12339d119 100644 --- a/app/src/main/res/layout/activity_recaptcha.xml +++ b/app/src/main/res/layout/activity_recaptcha.xml @@ -11,10 +11,7 @@ android:layout_height="?attr/actionBarSize" android:background="?attr/colorPrimary" android:gravity="center_vertical" - android:minHeight="?attr/actionBarSize" - android:theme="@style/ThemeOverlay.AppCompat.ActionBar" - app:popupTheme="@style/ThemeOverlay.AppCompat.ActionBar" - app:titleTextAppearance="@style/Toolbar.Title" /> + android:minHeight="?attr/actionBarSize" /> + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_bookmark.xml b/app/src/main/res/layout/dialog_bookmark.xml deleted file mode 100644 index 010f66049..000000000 --- a/app/src/main/res/layout/dialog_bookmark.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - diff --git a/app/src/main/res/layout/dialog_playlist_name.xml b/app/src/main/res/layout/dialog_edit_text.xml similarity index 68% rename from app/src/main/res/layout/dialog_playlist_name.xml rename to app/src/main/res/layout/dialog_edit_text.xml index 0e0747381..ff0366e23 100644 --- a/app/src/main/res/layout/dialog_playlist_name.xml +++ b/app/src/main/res/layout/dialog_edit_text.xml @@ -8,14 +8,11 @@ android:paddingRight="@dimen/video_item_search_padding"> + android:maxLines="1" /> 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 6dafc427c..e849524b0 100644 --- a/app/src/main/res/layout/dialog_feed_group_create.xml +++ b/app/src/main/res/layout/dialog_feed_group_create.xml @@ -30,7 +30,7 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" tools:ignore="ContentDescription" - tools:src="?attr/ic_asterisk" /> + tools:src="@drawable/ic_asterisk" /> + android:theme="@style/ContrastToolbarTheme"> diff --git a/app/src/main/res/layout/dialog_playlists.xml b/app/src/main/res/layout/dialog_playlists.xml index 77b884f4f..7564296b3 100644 --- a/app/src/main/res/layout/dialog_playlists.xml +++ b/app/src/main/res/layout/dialog_playlists.xml @@ -20,7 +20,7 @@ android:layout_centerVertical="true" android:layout_marginLeft="12dp" android:layout_marginRight="12dp" - app:srcCompat="?attr/ic_playlist_add" + app:srcCompat="@drawable/ic_playlist_add" tools:ignore="ContentDescription,RtlHardcoded" /> diff --git a/app/src/main/res/layout/error_panel.xml b/app/src/main/res/layout/error_panel.xml index 5141b66b8..355dd17e3 100644 --- a/app/src/main/res/layout/error_panel.xml +++ b/app/src/main/res/layout/error_panel.xml @@ -15,7 +15,29 @@ android:text="@string/general_error" android:textSize="16sp" android:textStyle="bold" - tools:text="Network error" /> + tools:text="Account terminated" /> + + + + +