Compare commits

..

1 Commits

Author SHA1 Message Date
TobiGr
27b2d5de70 [AndroidTV] Fix selecting PeerTube instance in navigation drawer
Fixes #10020
2023-07-15 04:38:36 +02:00
1270 changed files with 8474 additions and 32842 deletions

View File

@ -6,7 +6,7 @@ NewPipe contribution guidelines
## Crash reporting ## 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 bug report for GitHub. 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 ## Issue reporting/feature requests
@ -42,6 +42,10 @@ You'll see *exactly* what is sent, be able to add **your comments**, and then se
* 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. * 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. * 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) ### 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. * 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.
@ -79,6 +83,6 @@ The [ktlint](https://github.com/pinterest/ktlint) plugin does the same job as ch
## Communication ## Communication
* You can use a Matrix account to join the NewPipe channel at [#newpipe:matrix.newpipe-ev.de](https://matrix.to/#/#newpipe:matrix.newpipe-ev.de). Some convenient clients, available both for phone and desktop, are listed at that link. * 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)!
* Alternatively, the #newpipe channel on Libera Chat (`ircs://irc.libera.chat:6697/newpipe`) can also be joined, as it is bridged to the Matrix room. [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 Matrix (including via IRC). * You can post your suggestions, changes, ideas etc. on either GitHub or IRC.

View File

@ -1,3 +1,6 @@
name: Question
description: Ask about anything NewPipe-related
labels: [question]
body: body:
- type: markdown - type: markdown
attributes: attributes:

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: "Checklist" label: "Checklist"
options: options:
- label: "I am able to reproduce the bug with the latest version given here: [CLICK THIS LINK](https://github.com/TeamNewPipe/NewPipe/releases/latest)." - label: "I am able to reproduce the bug with the [latest version](https://github.com/TeamNewPipe/NewPipe/releases/latest)."
required: true required: true
- label: "I made sure that there are *no existing issues* - [open](https://github.com/TeamNewPipe/NewPipe/issues) or [closed](https://github.com/TeamNewPipe/NewPipe/issues?q=is%3Aissue+is%3Aclosed) - which I could contribute my information to." - label: "I made sure that there are *no existing issues* - [open](https://github.com/TeamNewPipe/NewPipe/issues) or [closed](https://github.com/TeamNewPipe/NewPipe/issues?q=is%3Aissue+is%3Aclosed) - which I could contribute my information to."
required: true required: true

View File

@ -3,9 +3,9 @@ contact_links:
- name: ❓ Question - name: ❓ Question
url: https://github.com/TeamNewPipe/NewPipe/discussions/new?category=questions url: https://github.com/TeamNewPipe/NewPipe/discussions/new?category=questions
about: Ask about anything NewPipe-related about: Ask about anything NewPipe-related
- name: 💬 Matrix
url: https://matrix.to/#/#newpipe:matrix.newpipe-ev.de
about: Chat with us via Matrix for quick Q/A
- name: 💬 IRC - name: 💬 IRC
url: https://web.libera.chat/#newpipe url: https://web.libera.chat/#newpipe
about: Chat with us via IRC for quick Q/A about: Chat with us via IRC for quick Q/A
- name: 💬 Matrix
url: https://matrix.to/#/#newpipe:libera.chat
about: Chat with us via Matrix for quick Q/A

View File

@ -1,17 +0,0 @@
# Add 'size/small' label to any changes with less than 50 lines
size/small:
max: 49
# Add 'size/medium' label to any changes between 50 and 249 lines
size/medium:
min: 50
max: 249
# Add 'size/large' label to any changes between 250 and 749 lines
size/large:
min: 250
max: 749
# Add 'size/giant' label to any changes for more than 749 lines
size/giant:
min: 750

View File

@ -1,38 +0,0 @@
name: "Build unsigned release APK on master"
on:
workflow_dispatch:
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: 'master'
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
cache: 'gradle'
- name: "Build release APK"
run: ./gradlew assembleRelease --stacktrace
- name: "Rename APK"
run: |
VERSION_NAME="$(jq -r ".elements[0].versionName" "app/build/outputs/apk/release/output-metadata.json")"
echo "Version name: $VERSION_NAME" >> "$GITHUB_STEP_SUMMARY"
echo '```json' >> "$GITHUB_STEP_SUMMARY"
cat "app/build/outputs/apk/release/output-metadata.json" >> "$GITHUB_STEP_SUMMARY"
echo >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
# assume there is only one APK in that folder
mv app/build/outputs/apk/release/*.apk "app/build/outputs/apk/release/NewPipe_v$VERSION_NAME.apk"
- name: "Upload APK"
uses: actions/upload-artifact@v4
with:
name: app
path: app/build/outputs/apk/release/*.apk

View File

@ -6,7 +6,6 @@ on:
branches: branches:
- dev - dev
- master - master
- refactor
- release** - release**
paths-ignore: paths-ignore:
- 'README.md' - 'README.md'
@ -37,8 +36,8 @@ jobs:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- uses: gradle/wrapper-validation-action@v2 - uses: gradle/wrapper-validation-action@v1
- name: create and checkout branch - name: create and checkout branch
# push events already checked out the branch # push events already checked out the branch
@ -47,10 +46,10 @@ jobs:
BRANCH: ${{ github.head_ref }} BRANCH: ${{ github.head_ref }}
run: git checkout -B "$BRANCH" run: git checkout -B "$BRANCH"
- name: set up JDK - name: set up JDK 17
uses: actions/setup-java@v4 uses: actions/setup-java@v3
with: with:
java-version: 21 java-version: 17
distribution: "temurin" distribution: "temurin"
cache: 'gradle' cache: 'gradle'
@ -58,13 +57,14 @@ jobs:
run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint
- name: Upload APK - name: Upload APK
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
with: with:
name: app name: app
path: app/build/outputs/apk/debug/*.apk path: app/build/outputs/apk/debug/*.apk
test-android: test-android:
runs-on: ubuntu-latest # macos has hardware acceleration. See android-emulator-runner action
runs-on: macos-latest
timeout-minutes: 20 timeout-minutes: 20
strategy: strategy:
matrix: matrix:
@ -80,18 +80,12 @@ jobs:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- name: Enable KVM - name: set up JDK 17
run: | uses: actions/setup-java@v3
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: set up JDK
uses: actions/setup-java@v4
with: with:
java-version: 21 java-version: 17
distribution: "temurin" distribution: "temurin"
cache: 'gradle' cache: 'gradle'
@ -104,7 +98,7 @@ jobs:
script: ./gradlew connectedCheck --stacktrace script: ./gradlew connectedCheck --stacktrace
- name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553 - name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v3
if: failure() if: failure()
with: with:
name: android-test-report-api${{ matrix.api-level }} name: android-test-report-api${{ matrix.api-level }}
@ -117,19 +111,19 @@ jobs:
contents: read contents: read
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
with: with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Set up JDK - name: Set up JDK 17
uses: actions/setup-java@v4 uses: actions/setup-java@v3
with: with:
java-version: 21 java-version: 17
distribution: "temurin" distribution: "temurin"
cache: 'gradle' cache: 'gradle'
- name: Cache SonarCloud packages - name: Cache SonarCloud packages
uses: actions/cache@v4 uses: actions/cache@v3
with: with:
path: ~/.sonar/cache path: ~/.sonar/cache
key: ${{ runner.os }}-sonar key: ${{ runner.os }}-sonar

View File

@ -17,8 +17,6 @@ module.exports = async ({github, context}) => {
initialBody = context.payload.comment.body; initialBody = context.payload.comment.body;
} else if (context.eventName == 'issues') { } else if (context.eventName == 'issues') {
initialBody = context.payload.issue.body; initialBody = context.payload.issue.body;
} else if (context.eventName == 'pull_request') {
initialBody = context.payload.pull_request.body;
} else { } else {
console.log('Aborting: No body found'); console.log('Aborting: No body found');
return; return;
@ -32,8 +30,8 @@ module.exports = async ({github, context}) => {
} }
// Regex for finding images (simple variant) ![ALT_TEXT](https://*.githubusercontent.com/<number>/<variousHexStringsAnd->.<fileExtension>) // Regex for finding images (simple variant) ![ALT_TEXT](https://*.githubusercontent.com/<number>/<variousHexStringsAnd->.<fileExtension>)
const REGEX_USER_CONTENT_IMAGE_LOOKUP = /\!\[([^\]]*)\]\((https:\/\/[-a-z0-9]+\.githubusercontent\.com\/\d+\/[-0-9a-f]{32,512}\.(jpg|gif|png))\)/gm; const REGEX_USER_CONTENT_IMAGE_LOOKUP = /\!\[(.*)\]\((https:\/\/[-a-z0-9]+\.githubusercontent\.com\/\d+\/[-0-9a-f]{32,512}\.(jpg|gif|png))\)/gm;
const REGEX_ASSETS_IMAGE_LOCKUP = /\!\[([^\]]*)\]\((https:\/\/github\.com\/[-\w\d]+\/[-\w\d]+\/assets\/\d+\/[\-0-9a-f]{32,512})\)/gm; const REGEX_ASSETS_IMAGE_LOCKUP = /\!\[(.*)\]\((https:\/\/github\.com\/[-\w\d]+\/[-\w\d]+\/assets\/\d+\/[\-0-9a-f]{32,512})\)/gm;
// Check if we found something // Check if we found something
let foundSimpleImages = REGEX_USER_CONTENT_IMAGE_LOOKUP.test(initialBody) let foundSimpleImages = REGEX_USER_CONTENT_IMAGE_LOOKUP.test(initialBody)
@ -76,17 +74,9 @@ module.exports = async ({github, context}) => {
repo: context.repo.repo, repo: context.repo.repo,
body: newBody body: newBody
}); });
} else if (context.eventName == 'pull_request') {
console.log('Updating pull request', context.payload.pull_request.number);
await github.rest.pulls.update({
pull_number: context.payload.pull_request.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: newBody
});
} }
// Async replace function from https://stackoverflow.com/a/48032528 // Asnyc replace function from https://stackoverflow.com/a/48032528
async function replaceAsync(str, regex, asyncFn) { async function replaceAsync(str, regex, asyncFn) {
const promises = []; const promises = [];
str.replace(regex, (match, ...args) => { str.replace(regex, (match, ...args) => {
@ -138,7 +128,7 @@ module.exports = async ({github, context}) => {
if (shouldModify) { if (shouldModify) {
wasMatchModified = true; wasMatchModified = true;
console.log(`Modifying match '${match}'`); console.log(`Modifying match '${match}'`);
return `<img alt="${g1}" src="${g2}" width=${Math.min(600, Math.floor(IMG_MAX_HEIGHT_PX * probeAspectRatio))} />`; return `<img alt="${g1}" src="${g2}" width=${Math.min(600, (IMG_MAX_HEIGHT_PX * probeAspectRatio).toFixed(0))} />`;
} }
console.log(`Match '${match}' is ok/will not be modified`); console.log(`Match '${match}' is ok/will not be modified`);

View File

@ -5,8 +5,6 @@ on:
types: [created, edited] types: [created, edited]
issues: issues:
types: [opened, edited] types: [opened, edited]
pull_request:
types: [opened, edited]
permissions: permissions:
issues: write issues: write
@ -17,9 +15,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v3
- uses: actions/setup-node@v4 - uses: actions/setup-node@v3
with: with:
node-version: 16 node-version: 16
@ -27,7 +25,7 @@ jobs:
run: npm i probe-image-size@7.2.3 --ignore-scripts run: npm i probe-image-size@7.2.3 --ignore-scripts
- name: Minimize simple images - name: Minimize simple images
uses: actions/github-script@v7 uses: actions/github-script@v6
timeout-minutes: 3 timeout-minutes: 3
with: with:
script: | script: |

View File

@ -1,18 +0,0 @@
name: "PR size labeler"
on: [pull_request_target]
permissions:
contents: read
pull-requests: write
jobs:
changed-lines-count-labeler:
runs-on: ubuntu-latest
name: Automatically labelling pull requests based on the changed lines count
permissions:
pull-requests: write
steps:
- name: Set a label
uses: TeamNewPipe/changed-lines-count-labeler@main
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
configuration-path: .github/changed-lines-count-labeler.yml

21
.idea/icon.svg generated
View File

@ -1,21 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
<style type="text/css">
.st0{fill:#CD201F;}
.st1{fill:#FFFFFF;}
</style>
<g id="Alapkör">
<circle id="XMLID_23_" class="st0" cx="50" cy="50" r="50"/>
</g>
<g id="Elemek">
<path id="XMLID_19_" class="st1" d="M47,28.2c-9-5.3-15.3-9-15.3-9v61.7c0,0,30.4-18,52.3-30.9C72.1,43,57.7,34.5,47,28.2z"/>
</g>
<g id="Fedő">
<path id="XMLID_5_" class="st0" d="M48.4,40.1c-4.1-2.4-7-4.1-7-4.1V64c0,0,13.9-8.2,23.8-14C59.8,46.8,53.3,42.9,48.4,40.1z"/>
<rect id="XMLID_4_" x="41.4" y="55.6" class="st0" width="6.2" height="21"/>
</g>
<g id="Vonalak">
</g>
</svg>

Before

Width:  |  Height:  |  Size: 850 B

View File

@ -13,19 +13,18 @@
<a href="https://github.com/TeamNewPipe/NewPipe/actions" alt="Build Status"><img src="https://github.com/TeamNewPipe/NewPipe/workflows/CI/badge.svg?branch=dev&event=push"></a> <a href="https://github.com/TeamNewPipe/NewPipe/actions" alt="Build Status"><img src="https://github.com/TeamNewPipe/NewPipe/workflows/CI/badge.svg?branch=dev&event=push"></a>
<a href="https://hosted.weblate.org/engage/newpipe/" alt="Translation Status"><img src="https://hosted.weblate.org/widgets/newpipe/-/svg-badge.svg"></a> <a href="https://hosted.weblate.org/engage/newpipe/" alt="Translation Status"><img src="https://hosted.weblate.org/widgets/newpipe/-/svg-badge.svg"></a>
<a href="https://web.libera.chat/#newpipe" alt="IRC channel: #newpipe"><img src="https://img.shields.io/badge/IRC%20chat-%23newpipe-brightgreen.svg"></a> <a href="https://web.libera.chat/#newpipe" alt="IRC channel: #newpipe"><img src="https://img.shields.io/badge/IRC%20chat-%23newpipe-brightgreen.svg"></a>
<a href="https://matrix.to/#/#newpipe:matrix.newpipe-ev.de" alt="Matrix channel: #newpipe"><img src="https://img.shields.io/badge/Matrix%20chat-%23newpipe-blue"></a> <a href="https://www.bountysource.com/teams/newpipe" alt="Bountysource bounties"><img src="https://img.shields.io/bountysource/team/newpipe/activity.svg?colorB=cd201f"></a>
</p> </p>
<hr> <hr>
<p align="center"><a href="#screenshots">Screenshots</a> &bull; <a href="#supported-services">Supported Services</a> &bull; <a href="#description">Description</a> &bull; <a href="#features">Features</a> &bull; <a href="#installation-and-updates">Installation and updates</a> &bull; <a href="#contribution">Contribution</a> &bull; <a href="#donate">Donate</a> &bull; <a href="#license">License</a></p> <p align="center"><a href="#screenshots">Screenshots</a> &bull; <a href="#supported-services">Supported Services</a> &bull; <a href="#description">Description</a> &bull; <a href="#features">Features</a> &bull; <a href="#installation-and-updates">Installation and updates</a> &bull; <a href="#contribution">Contribution</a> &bull; <a href="#donate">Donate</a> &bull; <a href="#license">License</a></p>
<p align="center"><a href="https://newpipe.net">Website</a> &bull; <a href="https://newpipe.net/blog/">Blog</a> &bull; <a href="https://newpipe.net/FAQ/">FAQ</a> &bull; <a href="https://newpipe.net/press/">Press</a></p> <p align="center"><a href="https://newpipe.net">Website</a> &bull; <a href="https://newpipe.net/blog/">Blog</a> &bull; <a href="https://newpipe.net/FAQ/">FAQ</a> &bull; <a href="https://newpipe.net/press/">Press</a></p>
<hr> <hr>
*Read this document in other languages: [Deutsch](doc/README.de.md), [English](README.md), [Español](doc/README.es.md), [Français](doc/README.fr.md), [हिन्दी](doc/README.hi.md), [Italiano](doc/README.it.md), [한국어](doc/README.ko.md), [Português Brasil](doc/README.pt_BR.md), [Polski](doc/README.pl.md), [ਪੰਜਾਬੀ ](doc/README.pa.md), [日本語](doc/README.ja.md), [Română](doc/README.ro.md), [Soomaali](doc/README.so.md), [Türkçe](doc/README.tr.md), [正體中文](doc/README.zh_TW.md), [অসমীয়া](doc/README.asm.md), [Српски](doc/README.sr.md), [العربية](README.ar.md)* *Read this in other languages: [English](README.md), [Español](doc/README.es.md), [हिन्दी](doc/README.hi.md), [한국어](doc/README.ko.md), [Soomaali](doc/README.so.md), [Português Brasil](doc/README.pt_BR.md), [Polski](doc/README.pl.md), [日本語](doc/README.ja.md), [Română](doc/README.ro.md), [Türkçe](doc/README.tr.md), [正體中文](doc/README.zh_TW.md).*
> [!warning] <b>WARNING: THIS APP IS IN BETA, SO YOU MAY ENCOUNTER BUGS. IF YOU DO, OPEN AN ISSUE IN OUR GITHUB REPOSITORY BY FILLING OUT THE ISSUE TEMPLATE.</b>
> <b>THIS APP IS IN BETA, SO YOU MAY ENCOUNTER BUGS. IF YOU DO, OPEN AN ISSUE IN OUR GITHUB REPOSITORY BY FILLING OUT THE ISSUE TEMPLATE.</b>
> <b>PUTTING NEWPIPE, OR ANY FORK OF IT, INTO THE GOOGLE PLAY STORE VIOLATES THEIR TERMS AND CONDITIONS.</b>
> <b>PUTTING NEWPIPE, OR ANY FORK OF IT, INTO THE GOOGLE PLAY STORE VIOLATES THEIR TERMS AND CONDITIONS.</b>
## Screenshots ## Screenshots
@ -96,7 +95,7 @@ Also, since they are free and open source software, neither the app nor the Extr
## Installation and updates ## Installation and updates
You can install NewPipe using one of the following methods: You can install NewPipe using one of the following methods:
1. Add our custom repo to F-Droid and install it from there. The instructions are here: https://newpipe.net/FAQ/tutorials/install-add-fdroid-repo/ 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), [compare the signing key](#apk-info) and install it. 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, and then push the update to users. 3. Update via F-Droid. This is the slowest method of getting updates, as F-Droid must recognize changes, build the APK itself, sign it, and then push the update to users.
4. Build a debug APK yourself. This is the fastest way to get new features on your device, but is much more complicated, so we recommend using one of the other methods. 4. Build a debug APK yourself. This is the fastest way to get new features on your device, but is much more complicated, so we recommend using one of the other methods.
5. If you're interested in a specific feature or bugfix provided in a Pull Request in this repo, you can also download its APK from within the PR. Read the PR description for instructions. The great thing about PR-specific APKs is that they're installed side-by-side the official app, so you don't have to worry about losing your data or messing anything up. 5. If you're interested in a specific feature or bugfix provided in a Pull Request in this repo, you can also download its APK from within the PR. Read the PR description for instructions. The great thing about PR-specific APKs is that they're installed side-by-side the official app, so you don't have to worry about losing your data or messing anything up.
@ -104,20 +103,12 @@ You can install NewPipe using one of the following methods:
We recommend method 1 for most users. APKs installed using method 1 or 2 are compatible with each other (meaning that if you installed NewPipe using either method 1 or 2, you can also update NewPipe using the other), but not with those installed using method 3. This is due to the same signing key (ours) being used for 1 and 2, but a different signing key (F-Droid's) being used for 3. Building a debug APK using method 4 excludes a key entirely. Signing keys help ensure that a user isn't tricked into installing a malicious update to an app. When using method 5, each APK is signed with a different random key supplied by GitHub Actions, so you cannot even update it. You will have to backup and restore the app data each time you wish to use a new APK. We recommend method 1 for most users. APKs installed using method 1 or 2 are compatible with each other (meaning that if you installed NewPipe using either method 1 or 2, you can also update NewPipe using the other), but not with those installed using method 3. This is due to the same signing key (ours) being used for 1 and 2, but a different signing key (F-Droid's) being used for 3. Building a debug APK using method 4 excludes a key entirely. Signing keys help ensure that a user isn't tricked into installing a malicious update to an app. When using method 5, each APK is signed with a different random key supplied by GitHub Actions, so you cannot even update it. You will have to backup and restore the app data each time you wish to use a new APK.
In the meanwhile, if you want to switch sources for some reason (e.g. NewPipe's core functionality breaks and F-Droid doesn't have the latest update yet), we recommend following this procedure: 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 > Backup and Restore > Export Database so you keep your history, subscriptions, and playlists 1. Back up your data via Settings > Content > Export Database so you keep your history, subscriptions, and playlists
2. Uninstall NewPipe 2. Uninstall NewPipe
3. Download the APK from the new source and install it 3. Download the APK from the new source and install it
4. Import the data from step 1 via Settings > Backup and Restore > Import Database 4. Import the data from step 1 via Settings > Content > Import Database
> [!Note] <b>Note: when you're importing a database into the official app, always make sure that it is the one you exported _from_ the official app. If you import a database exported from an APK other than the official app, it may break things. Such an action is unsupported, and you should only do so when you're absolutely certain you know what you're doing.</b>
> When you're importing a database into the official app, always make sure that it is the one you exported _from_ the official app. If you import a database exported from an APK other than the official app, it may break things. Such an action is unsupported, and you should only do so when you're absolutely certain you know what you're doing.
### APK Info
This is the SHA fingerprint of NewPipe's signing key to verify downloaded APKs which are signed by us. The fingerprint is also available on [NewPipe's website](https://newpipe.net#download). This is relevant for method 2.
```
CB:84:06:9B:D6:81:16:BA:FA:E5:EE:4E:E5:B0:8A:56:7A:A6:D8:98:40:4E:7C:B1:2F:9E:75:6D:F5:CF:5C:AB
```
## Contribution ## Contribution
Whether you have ideas, translations, design changes, code cleaning, or even major code changes, help is always welcome. The app gets better and better with each contribution, no matter how big or small! If you'd like to get involved, check our [contribution notes](.github/CONTRIBUTING.md). Whether you have ideas, translations, design changes, code cleaning, or even major code changes, help is always welcome. The app gets better and better with each contribution, no matter how big or small! If you'd like to get involved, check our [contribution notes](.github/CONTRIBUTING.md).
@ -135,6 +126,16 @@ If you like NewPipe, you're welcome to send a donation. We prefer Liberapay, as
<td><a href="https://liberapay.com/TeamNewPipe/"><img src="assets/liberapay_qr_code.png" alt="Visit NewPipe at liberapay.com" width="100px"></a></td> <td><a href="https://liberapay.com/TeamNewPipe/"><img src="assets/liberapay_qr_code.png" alt="Visit NewPipe at liberapay.com" width="100px"></a></td>
<td><a href="https://liberapay.com/TeamNewPipe/donate"><img src="assets/liberapay_donate_button.svg" alt="Donate via Liberapay" height="35px"></a></td> <td><a href="https://liberapay.com/TeamNewPipe/donate"><img src="assets/liberapay_donate_button.svg" alt="Donate via Liberapay" height="35px"></a></td>
</tr> </tr>
<tr>
<td><img src="https://bitcoin.org/img/icons/logotop.svg" alt="Bitcoin"></td>
<td><img src="assets/bitcoin_qr_code.png" alt="Bitcoin QR code" width="100px"></td>
<td><samp>16A9J59ahMRqkLSZjhYj33n9j3fMztFxnh</samp></td>
</tr>
<tr>
<td><a href="https://www.bountysource.com/teams/newpipe"><img src="https://upload.wikimedia.org/wikipedia/commons/thumb/2/22/Bountysource.png/320px-Bountysource.png" alt="Bountysource" width="190px"></a></td>
<td><a href="https://www.bountysource.com/teams/newpipe"><img src="assets/bountysource_qr_code.png" alt="Visit NewPipe at bountysource.com" width="100px"></a></td>
<td><a href="https://www.bountysource.com/teams/newpipe/issues"><img src="https://img.shields.io/bountysource/team/newpipe/activity.svg?colorB=cd201f" height="30px" alt="Check out how many bounties you can earn."></a></td>
</tr>
</table> </table>
## Privacy Policy ## Privacy Policy

View File

@ -12,7 +12,7 @@ plugins {
} }
android { android {
compileSdk 34 compileSdk 33
namespace 'org.schabi.newpipe' namespace 'org.schabi.newpipe'
defaultConfig { defaultConfig {
@ -20,15 +20,8 @@ android {
resValue "string", "app_name", "NewPipe" resValue "string", "app_name", "NewPipe"
minSdk 21 minSdk 21
targetSdk 33 targetSdk 33
if (System.properties.containsKey('versionCodeOverride')) { versionCode 993
versionCode System.getProperty('versionCodeOverride') as Integer versionName "0.25.1"
} else {
versionCode 1003
}
versionName "0.27.6"
if (System.properties.containsKey('versionNameSuffix')) {
versionNameSuffix System.getProperty('versionNameSuffix')
}
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -57,6 +50,9 @@ android {
} }
} }
// Keep the release build type at the end of the list to override 'archivesBaseName' of
// debug build. This seems to be a Gradle bug, therefore
// TODO: update Gradle version
release { release {
if (System.properties.containsKey('packageSuffix')) { if (System.properties.containsKey('packageSuffix')) {
applicationIdSuffix System.getProperty('packageSuffix') applicationIdSuffix System.getProperty('packageSuffix')
@ -99,16 +95,13 @@ android {
buildFeatures { buildFeatures {
viewBinding true viewBinding true
buildConfig true
} }
packagingOptions { packagingOptions {
resources { resources {
// remove two files which belong to jsoup // remove two files which belong to jsoup
// no idea how they ended up in the META-INF dir... // no idea how they ended up in the META-INF dir...
excludes += ['META-INF/README.md', 'META-INF/CHANGES', excludes += ['META-INF/README.md', 'META-INF/CHANGES']
// 'COPYRIGHT' belongs to RxJava...
'META-INF/COPYRIGHT']
} }
} }
} }
@ -116,18 +109,19 @@ android {
ext { ext {
checkstyleVersion = '10.12.1' checkstyleVersion = '10.12.1'
androidxLifecycleVersion = '2.6.2' androidxLifecycleVersion = '2.5.1'
androidxRoomVersion = '2.6.1' androidxRoomVersion = '2.4.3'
androidxWorkVersion = '2.8.1' androidxWorkVersion = '2.7.1'
stateSaverVersion = '1.4.1' icepickVersion = '3.2.0'
exoPlayerVersion = '2.18.7' exoPlayerVersion = '2.18.7'
googleAutoServiceVersion = '1.1.1' googleAutoServiceVersion = '1.0.1'
groupieVersion = '2.10.1' groupieVersion = '2.10.1'
markwonVersion = '4.6.2' markwonVersion = '4.6.2'
leakCanaryVersion = '2.12' leakCanaryVersion = '2.9.1'
stethoVersion = '1.6.0' stethoVersion = '1.6.0'
mockitoVersion = '4.0.0'
} }
configurations { configurations {
@ -142,7 +136,7 @@ checkstyle {
toolVersion = checkstyleVersion toolVersion = checkstyleVersion
} }
tasks.register('runCheckstyle', Checkstyle) { task runCheckstyle(type: Checkstyle) {
source 'src' source 'src'
include '**/*.java' include '**/*.java'
exclude '**/gen/**' exclude '**/gen/**'
@ -163,7 +157,7 @@ tasks.register('runCheckstyle', Checkstyle) {
def outputDir = "${project.buildDir}/reports/ktlint/" def outputDir = "${project.buildDir}/reports/ktlint/"
def inputFiles = project.fileTree(dir: "src", include: "**/*.kt") def inputFiles = project.fileTree(dir: "src", include: "**/*.kt")
tasks.register('runKtlint', JavaExec) { task runKtlint(type: JavaExec) {
inputs.files(inputFiles) inputs.files(inputFiles)
outputs.dir(outputDir) outputs.dir(outputDir)
getMainClass().set("com.pinterest.ktlint.Main") getMainClass().set("com.pinterest.ktlint.Main")
@ -172,7 +166,7 @@ tasks.register('runKtlint', JavaExec) {
jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED")
} }
tasks.register('formatKtlint', JavaExec) { task formatKtlint(type: JavaExec) {
inputs.files(inputFiles) inputs.files(inputFiles)
outputs.dir(outputDir) outputs.dir(outputDir)
getMainClass().set("com.pinterest.ktlint.Main") getMainClass().set("com.pinterest.ktlint.Main")
@ -198,7 +192,7 @@ sonar {
dependencies { dependencies {
/** Desugaring **/ /** Desugaring **/
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.0.4' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3'
/** NewPipe libraries **/ /** NewPipe libraries **/
// You can use a local version by uncommenting a few lines in settings.gradle // You can use a local version by uncommenting a few lines in settings.gradle
@ -206,9 +200,7 @@ dependencies {
// name and the commit hash with the commit hash of the (pushed) commit you want to test // 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/ // This works thanks to JitPack: https://jitpack.io/
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751' implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
// WORKAROUND: if you get errors with the NewPipeExtractor dependency, replace `v0.24.3` with implementation 'com.github.TeamNewPipe:NewPipeExtractor:8495ad619e'
// the corresponding commit hash, since JitPack is sometimes buggy
implementation 'com.github.TeamNewPipe:NewPipeExtractor:9f83b385a'
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0' implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
/** Checkstyle **/ /** Checkstyle **/
@ -216,44 +208,42 @@ dependencies {
ktlint 'com.pinterest:ktlint:0.45.2' ktlint 'com.pinterest:ktlint:0.45.2'
/** Kotlin **/ /** Kotlin **/
implementation "org.jetbrains.kotlin:kotlin-stdlib:${kotlin_version}" implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin_version}"
/** AndroidX **/ /** AndroidX **/
implementation 'androidx.appcompat:appcompat:1.6.1' implementation 'androidx.appcompat:appcompat:1.5.1'
implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.core:core-ktx:1.12.0' implementation 'androidx.core:core-ktx:1.10.0'
implementation 'androidx.documentfile:documentfile:1.0.1' implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'androidx.fragment:fragment-ktx:1.6.2' implementation 'androidx.fragment:fragment-ktx:1.4.1'
implementation "androidx.lifecycle:lifecycle-livedata-ktx:${androidxLifecycleVersion}" implementation "androidx.lifecycle:lifecycle-livedata-ktx:${androidxLifecycleVersion}"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${androidxLifecycleVersion}" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${androidxLifecycleVersion}"
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
implementation 'androidx.media:media:1.7.0' implementation 'androidx.media:media:1.6.0'
implementation 'androidx.preference:preference:1.2.1' implementation 'androidx.preference:preference:1.2.0'
implementation 'androidx.recyclerview:recyclerview:1.3.2' implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation "androidx.room:room-runtime:${androidxRoomVersion}" implementation "androidx.room:room-runtime:${androidxRoomVersion}"
implementation "androidx.room:room-rxjava3:${androidxRoomVersion}" implementation "androidx.room:room-rxjava3:${androidxRoomVersion}"
kapt "androidx.room:room-compiler:${androidxRoomVersion}" kapt "androidx.room:room-compiler:${androidxRoomVersion}"
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
// Newer version specified to prevent accessibility regressions with RecyclerView, see: // Newer version specified to prevent accessibility regressions with RecyclerView, see:
// https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01 // https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}" implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}"
implementation "androidx.work:work-rxjava3:${androidxWorkVersion}" implementation "androidx.work:work-rxjava3:${androidxWorkVersion}"
implementation 'com.google.android.material:material:1.11.0' implementation 'com.google.android.material:material:1.6.1'
implementation "androidx.webkit:webkit:1.9.0"
/** Third-party libraries **/ /** Third-party libraries **/
// Instance state boilerplate elimination // Instance state boilerplate elimination
implementation 'com.github.livefront:bridge:v2.0.2' implementation "frankiesardo:icepick:${icepickVersion}"
implementation "com.evernote:android-state:$stateSaverVersion" kapt "frankiesardo:icepick-processor:${icepickVersion}"
kapt "com.evernote:android-state-processor:$stateSaverVersion"
// HTML parser // HTML parser
implementation "org.jsoup:jsoup:1.17.2" implementation "org.jsoup:jsoup:1.16.1"
// HTTP client // HTTP client
implementation "com.squareup.okhttp3:okhttp:4.12.0" implementation "com.squareup.okhttp3:okhttp:4.11.0"
// Media player // Media player
implementation "com.google.android.exoplayer:exoplayer-core:${exoPlayerVersion}" implementation "com.google.android.exoplayer:exoplayer-core:${exoPlayerVersion}"
@ -282,37 +272,38 @@ dependencies {
implementation "io.noties.markwon:linkify:${markwonVersion}" implementation "io.noties.markwon:linkify:${markwonVersion}"
// Crash reporting // Crash reporting
implementation "ch.acra:acra-core:5.11.3" implementation "ch.acra:acra-core:5.10.1"
// Properly restarting // Properly restarting
implementation 'com.jakewharton:process-phoenix:2.1.2' implementation 'com.jakewharton:process-phoenix:2.1.2'
// Reactive extensions for Java VM // Reactive extensions for Java VM
implementation "io.reactivex.rxjava3:rxjava:3.1.8" implementation "io.reactivex.rxjava3:rxjava:3.1.6"
implementation "io.reactivex.rxjava3:rxandroid:3.0.2" implementation "io.reactivex.rxjava3:rxandroid:3.0.2"
// RxJava binding APIs for Android UI widgets // RxJava binding APIs for Android UI widgets
implementation "com.jakewharton.rxbinding4:rxbinding:4.0.0" implementation "com.jakewharton.rxbinding4:rxbinding:4.0.0"
// Date and time formatting // Date and time formatting
implementation "org.ocpsoft.prettytime:prettytime:5.0.8.Final" implementation "org.ocpsoft.prettytime:prettytime:5.0.6.Final"
/** Debugging **/ /** Debugging **/
// Memory leak detection // Memory leak detection
debugImplementation "com.squareup.leakcanary:leakcanary-object-watcher-android:${leakCanaryVersion}" implementation "com.squareup.leakcanary:leakcanary-object-watcher-android:${leakCanaryVersion}"
debugImplementation "com.squareup.leakcanary:plumber-android:${leakCanaryVersion}" implementation "com.squareup.leakcanary:plumber-android:${leakCanaryVersion}"
debugImplementation "com.squareup.leakcanary:leakcanary-android-core:${leakCanaryVersion}" debugImplementation "com.squareup.leakcanary:leakcanary-android:${leakCanaryVersion}"
// Debug bridge for Android // Debug bridge for Android
debugImplementation "com.facebook.stetho:stetho:${stethoVersion}" debugImplementation "com.facebook.stetho:stetho:${stethoVersion}"
debugImplementation "com.facebook.stetho:stetho-okhttp3:${stethoVersion}" debugImplementation "com.facebook.stetho:stetho-okhttp3:${stethoVersion}"
/** Testing **/ /** Testing **/
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:5.6.0' testImplementation "org.mockito:mockito-core:${mockitoVersion}"
testImplementation "org.mockito:mockito-inline:${mockitoVersion}"
androidTestImplementation "androidx.test.ext:junit:1.1.5" androidTestImplementation "androidx.test.ext:junit:1.1.5"
androidTestImplementation "androidx.test:runner:1.5.2" androidTestImplementation "androidx.test:runner:1.5.2"
androidTestImplementation "androidx.room:room-testing:${androidxRoomVersion}" androidTestImplementation "androidx.room:room-testing:${androidxRoomVersion}"
androidTestImplementation "org.assertj:assertj-core:3.24.2" androidTestImplementation "org.assertj:assertj-core:3.23.1"
} }
static String getGitWorkingBranch() { static String getGitWorkingBranch() {

View File

@ -7,12 +7,20 @@
-keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; } -keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; }
-keep class org.mozilla.javascript.** { *; } -keep class org.mozilla.javascript.** { *; }
-keep class org.mozilla.classfile.ClassFileWriter -keep class org.mozilla.classfile.ClassFileWriter
-dontwarn org.mozilla.javascript.JavaToJSONConverters
-dontwarn org.mozilla.javascript.tools.** -dontwarn org.mozilla.javascript.tools.**
## Rules for ExoPlayer ## Rules for ExoPlayer
-keep class com.google.android.exoplayer2.** { *; } -keep class com.google.android.exoplayer2.** { *; }
## Rules for Icepick. Copy pasted from https://github.com/frankiesardo/icepick
-dontwarn icepick.**
-keep class icepick.** { *; }
-keep class **$$Icepick { *; }
-keepclasseswithmembernames class * {
@icepick.* <fields>;
}
-keepnames class * { @icepick.State *;}
## Rules for OkHttp. Copy pasted from https://github.com/square/okhttp ## Rules for OkHttp. Copy pasted from https://github.com/square/okhttp
-dontwarn okhttp3.** -dontwarn okhttp3.**
-dontwarn okio.** -dontwarn okio.**

View File

@ -1,737 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 8,
"identityHash": "012fc8e7ad3333f1597347f34e76a513",
"entities": [
{
"tableName": "subscriptions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "service_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "avatarUrl",
"columnName": "avatar_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "subscriberCount",
"columnName": "subscriber_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "notificationMode",
"columnName": "notification_mode",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [
{
"name": "index_subscriptions_service_id_url",
"unique": true,
"columnNames": [
"service_id",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
}
],
"foreignKeys": []
},
{
"tableName": "search_history",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)",
"fields": [
{
"fieldPath": "creationDate",
"columnName": "creation_date",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "serviceId",
"columnName": "service_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "search",
"columnName": "search",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_search_history_search",
"unique": false,
"columnNames": [
"search"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)"
}
],
"foreignKeys": []
},
{
"tableName": "streams",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "service_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "streamType",
"columnName": "stream_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uploaderUrl",
"columnName": "uploader_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnail_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "viewCount",
"columnName": "view_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "textualUploadDate",
"columnName": "textual_upload_date",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploadDate",
"columnName": "upload_date",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "isUploadDateApproximation",
"columnName": "is_upload_date_approximation",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [
{
"name": "index_streams_service_id_url",
"unique": true,
"columnNames": [
"service_id",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
}
],
"foreignKeys": []
},
{
"tableName": "stream_history",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "streamUid",
"columnName": "stream_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accessDate",
"columnName": "access_date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "repeatCount",
"columnName": "repeat_count",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"stream_id",
"access_date"
]
},
"indices": [
{
"name": "index_stream_history_stream_id",
"unique": false,
"columnNames": [
"stream_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
}
],
"foreignKeys": [
{
"table": "streams",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"stream_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "stream_state",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "streamUid",
"columnName": "stream_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "progressMillis",
"columnName": "progress_time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"stream_id"
]
},
"indices": [],
"foreignKeys": [
{
"table": "streams",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"stream_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, `thumbnail_stream_id` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isThumbnailPermanent",
"columnName": "is_thumbnail_permanent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "thumbnailStreamId",
"columnName": "thumbnail_stream_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [
{
"name": "index_playlists_name",
"unique": false,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
},
{
"tableName": "playlist_stream_join",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
"fields": [
{
"fieldPath": "playlistUid",
"columnName": "playlist_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "streamUid",
"columnName": "stream_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "index",
"columnName": "join_index",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"playlist_id",
"join_index"
]
},
"indices": [
{
"name": "index_playlist_stream_join_playlist_id_join_index",
"unique": true,
"columnNames": [
"playlist_id",
"join_index"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)"
},
{
"name": "index_playlist_stream_join_stream_id",
"unique": false,
"columnNames": [
"stream_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
}
],
"foreignKeys": [
{
"table": "playlists",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"playlist_id"
],
"referencedColumns": [
"uid"
]
},
{
"table": "streams",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"stream_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "remote_playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "service_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnail_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "streamCount",
"columnName": "stream_count",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [
{
"name": "index_remote_playlists_name",
"unique": false,
"columnNames": [
"name"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)"
},
{
"name": "index_remote_playlists_service_id_url",
"unique": true,
"columnNames": [
"service_id",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
}
],
"foreignKeys": []
},
{
"tableName": "feed",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
"fields": [
{
"fieldPath": "streamId",
"columnName": "stream_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "subscriptionId",
"columnName": "subscription_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"stream_id",
"subscription_id"
]
},
"indices": [
{
"name": "index_feed_subscription_id",
"unique": false,
"columnNames": [
"subscription_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
}
],
"foreignKeys": [
{
"table": "streams",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"stream_id"
],
"referencedColumns": [
"uid"
]
},
{
"table": "subscriptions",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"subscription_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "feed_group",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [
{
"name": "index_feed_group_sort_order",
"unique": false,
"columnNames": [
"sort_order"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
}
],
"foreignKeys": []
},
{
"tableName": "feed_group_subscription_join",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
"fields": [
{
"fieldPath": "feedGroupId",
"columnName": "group_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "subscriptionId",
"columnName": "subscription_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"group_id",
"subscription_id"
]
},
"indices": [
{
"name": "index_feed_group_subscription_join_subscription_id",
"unique": false,
"columnNames": [
"subscription_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
}
],
"foreignKeys": [
{
"table": "feed_group",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"group_id"
],
"referencedColumns": [
"uid"
]
},
{
"table": "subscriptions",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"subscription_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "feed_last_updated",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
"fields": [
{
"fieldPath": "subscriptionId",
"columnName": "subscription_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUpdated",
"columnName": "last_updated",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"subscription_id"
]
},
"indices": [],
"foreignKeys": [
{
"table": "subscriptions",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"subscription_id"
],
"referencedColumns": [
"uid"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '012fc8e7ad3333f1597347f34e76a513')"
]
}
}

View File

@ -1,730 +0,0 @@
{
"formatVersion": 1,
"database": {
"version": 9,
"identityHash": "7591e8039faa74d8c0517dc867af9d3e",
"entities": [
{
"tableName": "subscriptions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "service_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "avatarUrl",
"columnName": "avatar_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "subscriberCount",
"columnName": "subscriber_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "notificationMode",
"columnName": "notification_mode",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [
{
"name": "index_subscriptions_service_id_url",
"unique": true,
"columnNames": [
"service_id",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
}
],
"foreignKeys": []
},
{
"tableName": "search_history",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)",
"fields": [
{
"fieldPath": "creationDate",
"columnName": "creation_date",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "serviceId",
"columnName": "service_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "search",
"columnName": "search",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_search_history_search",
"unique": false,
"columnNames": [
"search"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)"
}
],
"foreignKeys": []
},
{
"tableName": "streams",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "service_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "streamType",
"columnName": "stream_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uploaderUrl",
"columnName": "uploader_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnail_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "viewCount",
"columnName": "view_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "textualUploadDate",
"columnName": "textual_upload_date",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploadDate",
"columnName": "upload_date",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "isUploadDateApproximation",
"columnName": "is_upload_date_approximation",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [
{
"name": "index_streams_service_id_url",
"unique": true,
"columnNames": [
"service_id",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
}
],
"foreignKeys": []
},
{
"tableName": "stream_history",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "streamUid",
"columnName": "stream_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accessDate",
"columnName": "access_date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "repeatCount",
"columnName": "repeat_count",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"stream_id",
"access_date"
]
},
"indices": [
{
"name": "index_stream_history_stream_id",
"unique": false,
"columnNames": [
"stream_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
}
],
"foreignKeys": [
{
"table": "streams",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"stream_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "stream_state",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "streamUid",
"columnName": "stream_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "progressMillis",
"columnName": "progress_time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"stream_id"
]
},
"indices": [],
"foreignKeys": [
{
"table": "streams",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"stream_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, `thumbnail_stream_id` INTEGER NOT NULL, `display_index` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isThumbnailPermanent",
"columnName": "is_thumbnail_permanent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "thumbnailStreamId",
"columnName": "thumbnail_stream_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayIndex",
"columnName": "display_index",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "playlist_stream_join",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
"fields": [
{
"fieldPath": "playlistUid",
"columnName": "playlist_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "streamUid",
"columnName": "stream_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "index",
"columnName": "join_index",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"playlist_id",
"join_index"
]
},
"indices": [
{
"name": "index_playlist_stream_join_playlist_id_join_index",
"unique": true,
"columnNames": [
"playlist_id",
"join_index"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)"
},
{
"name": "index_playlist_stream_join_stream_id",
"unique": false,
"columnNames": [
"stream_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
}
],
"foreignKeys": [
{
"table": "playlists",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"playlist_id"
],
"referencedColumns": [
"uid"
]
},
{
"table": "streams",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"stream_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "remote_playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `display_index` INTEGER NOT NULL, `stream_count` INTEGER)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "service_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnail_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "displayIndex",
"columnName": "display_index",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "streamCount",
"columnName": "stream_count",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [
{
"name": "index_remote_playlists_service_id_url",
"unique": true,
"columnNames": [
"service_id",
"url"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
}
],
"foreignKeys": []
},
{
"tableName": "feed",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
"fields": [
{
"fieldPath": "streamId",
"columnName": "stream_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "subscriptionId",
"columnName": "subscription_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"stream_id",
"subscription_id"
]
},
"indices": [
{
"name": "index_feed_subscription_id",
"unique": false,
"columnNames": [
"subscription_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
}
],
"foreignKeys": [
{
"table": "streams",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"stream_id"
],
"referencedColumns": [
"uid"
]
},
{
"table": "subscriptions",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"subscription_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "feed_group",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [
{
"name": "index_feed_group_sort_order",
"unique": false,
"columnNames": [
"sort_order"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
}
],
"foreignKeys": []
},
{
"tableName": "feed_group_subscription_join",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
"fields": [
{
"fieldPath": "feedGroupId",
"columnName": "group_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "subscriptionId",
"columnName": "subscription_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"group_id",
"subscription_id"
]
},
"indices": [
{
"name": "index_feed_group_subscription_join_subscription_id",
"unique": false,
"columnNames": [
"subscription_id"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
}
],
"foreignKeys": [
{
"table": "feed_group",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"group_id"
],
"referencedColumns": [
"uid"
]
},
{
"table": "subscriptions",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"subscription_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "feed_last_updated",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
"fields": [
{
"fieldPath": "subscriptionId",
"columnName": "subscription_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUpdated",
"columnName": "last_updated",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"subscription_id"
]
},
"indices": [],
"foreignKeys": [
{
"table": "subscriptions",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"subscription_id"
],
"referencedColumns": [
"uid"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7591e8039faa74d8c0517dc867af9d3e')"
]
}
}

View File

@ -4,18 +4,15 @@ import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import androidx.room.Room import androidx.room.Room
import androidx.room.testing.MigrationTestHelper import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.extractor.stream.StreamType
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@ -24,23 +21,20 @@ class DatabaseMigrationTest {
private const val DEFAULT_SERVICE_ID = 0 private const val DEFAULT_SERVICE_ID = 0
private const val DEFAULT_URL = "https://www.youtube.com/watch?v=cDphUib5iG4" private const val DEFAULT_URL = "https://www.youtube.com/watch?v=cDphUib5iG4"
private const val DEFAULT_TITLE = "Test Title" private const val DEFAULT_TITLE = "Test Title"
private const val DEFAULT_NAME = "Test Name"
private val DEFAULT_TYPE = StreamType.VIDEO_STREAM private val DEFAULT_TYPE = StreamType.VIDEO_STREAM
private const val DEFAULT_DURATION = 480L private const val DEFAULT_DURATION = 480L
private const val DEFAULT_UPLOADER_NAME = "Uploader Test" private const val DEFAULT_UPLOADER_NAME = "Uploader Test"
private const val DEFAULT_THUMBNAIL = "https://example.com/example.jpg" private const val DEFAULT_THUMBNAIL = "https://example.com/example.jpg"
private const val DEFAULT_SECOND_SERVICE_ID = 1 private const val DEFAULT_SECOND_SERVICE_ID = 0
private const val DEFAULT_SECOND_URL = "https://www.youtube.com/watch?v=ncQU6iBn5Fc" private const val DEFAULT_SECOND_URL = "https://www.youtube.com/watch?v=ncQU6iBn5Fc"
private const val DEFAULT_THIRD_SERVICE_ID = 2
private const val DEFAULT_THIRD_URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
} }
@get:Rule @get:Rule
val testHelper = MigrationTestHelper( val testHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(), InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java AppDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
) )
@Test @Test
@ -114,20 +108,6 @@ class DatabaseMigrationTest {
Migrations.MIGRATION_6_7 Migrations.MIGRATION_6_7
) )
testHelper.runMigrationsAndValidate(
AppDatabase.DATABASE_NAME,
Migrations.DB_VER_8,
true,
Migrations.MIGRATION_7_8
)
testHelper.runMigrationsAndValidate(
AppDatabase.DATABASE_NAME,
Migrations.DB_VER_9,
true,
Migrations.MIGRATION_8_9
)
val migratedDatabaseV3 = getMigratedDatabase() val migratedDatabaseV3 = getMigratedDatabase()
val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst() val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst()
@ -162,157 +142,6 @@ class DatabaseMigrationTest {
assertNull(secondStreamFromMigratedDatabase.isUploadDateApproximation) assertNull(secondStreamFromMigratedDatabase.isUploadDateApproximation)
} }
@Test
fun migrateDatabaseFrom7to8() {
val databaseInV7 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_7)
val defaultSearch1 = " abc "
val defaultSearch2 = " abc"
val serviceId = DEFAULT_SERVICE_ID // YouTube
// Use id different to YouTube because two searches with the same query
// but different service are considered not equal.
val otherServiceId = ServiceList.SoundCloud.serviceId
databaseInV7.run {
insert(
"search_history", SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", serviceId)
put("search", defaultSearch1)
}
)
insert(
"search_history", SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", serviceId)
put("search", defaultSearch2)
}
)
insert(
"search_history", SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", otherServiceId)
put("search", defaultSearch1)
}
)
insert(
"search_history", SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", otherServiceId)
put("search", defaultSearch2)
}
)
close()
}
testHelper.runMigrationsAndValidate(
AppDatabase.DATABASE_NAME, Migrations.DB_VER_8,
true, Migrations.MIGRATION_7_8
)
testHelper.runMigrationsAndValidate(
AppDatabase.DATABASE_NAME, Migrations.DB_VER_9,
true, Migrations.MIGRATION_8_9
)
val migratedDatabaseV8 = getMigratedDatabase()
val listFromDB = migratedDatabaseV8.searchHistoryDAO().all.blockingFirst()
assertEquals(2, listFromDB.size)
assertEquals("abc", listFromDB[0].search)
assertEquals("abc", listFromDB[1].search)
assertNotEquals(listFromDB[0].serviceId, listFromDB[1].serviceId)
}
@Test
fun migrateDatabaseFrom8to9() {
val databaseInV8 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_8)
val localUid1: Long
val localUid2: Long
val remoteUid1: Long
val remoteUid2: Long
databaseInV8.run {
localUid1 = insert(
"playlists", SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("name", DEFAULT_NAME + "1")
put("is_thumbnail_permanent", false)
put("thumbnail_stream_id", -1)
}
)
localUid2 = insert(
"playlists", SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("name", DEFAULT_NAME + "2")
put("is_thumbnail_permanent", false)
put("thumbnail_stream_id", -1)
}
)
delete(
"playlists", "uid = ?",
Array(1) { localUid1 }
)
remoteUid1 = insert(
"remote_playlists", SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", DEFAULT_SERVICE_ID)
put("url", DEFAULT_URL)
}
)
remoteUid2 = insert(
"remote_playlists", SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", DEFAULT_SECOND_SERVICE_ID)
put("url", DEFAULT_SECOND_URL)
}
)
delete(
"remote_playlists", "uid = ?",
Array(1) { remoteUid2 }
)
close()
}
testHelper.runMigrationsAndValidate(
AppDatabase.DATABASE_NAME,
Migrations.DB_VER_9,
true,
Migrations.MIGRATION_8_9
)
val migratedDatabaseV9 = getMigratedDatabase()
var localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst()
var remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst()
assertEquals(1, localListFromDB.size)
assertEquals(localUid2, localListFromDB[0].uid)
assertEquals(-1, localListFromDB[0].displayIndex)
assertEquals(1, remoteListFromDB.size)
assertEquals(remoteUid1, remoteListFromDB[0].uid)
assertEquals(-1, remoteListFromDB[0].displayIndex)
val localUid3 = migratedDatabaseV9.playlistDAO().insert(
PlaylistEntity(DEFAULT_NAME + "3", false, -1, -1)
)
val remoteUid3 = migratedDatabaseV9.playlistRemoteDAO().insert(
PlaylistRemoteEntity(
DEFAULT_THIRD_SERVICE_ID, DEFAULT_NAME, DEFAULT_THIRD_URL,
DEFAULT_THUMBNAIL, DEFAULT_UPLOADER_NAME, -1, 10
)
)
localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst()
remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst()
assertEquals(2, localListFromDB.size)
assertEquals(localUid3, localListFromDB[1].uid)
assertEquals(-1, localListFromDB[1].displayIndex)
assertEquals(2, remoteListFromDB.size)
assertEquals(remoteUid3, remoteListFromDB[1].uid)
assertEquals(-1, remoteListFromDB[1].displayIndex)
}
private fun getMigratedDatabase(): AppDatabase { private fun getMigratedDatabase(): AppDatabase {
val database: AppDatabase = Room.databaseBuilder( val database: AppDatabase = Room.databaseBuilder(
ApplicationProvider.getApplicationContext(), ApplicationProvider.getApplicationContext(),

View File

@ -1,130 +0,0 @@
package org.schabi.newpipe.database
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import io.reactivex.rxjava3.core.Single
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import org.schabi.newpipe.database.feed.dao.FeedDAO
import org.schabi.newpipe.database.feed.model.FeedEntity
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.stream.StreamWithState
import org.schabi.newpipe.database.stream.dao.StreamDAO
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.subscription.SubscriptionDAO
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.stream.StreamType
import java.io.IOException
import java.time.OffsetDateTime
import kotlin.streams.toList
class FeedDAOTest {
private lateinit var db: AppDatabase
private lateinit var feedDAO: FeedDAO
private lateinit var streamDAO: StreamDAO
private lateinit var subscriptionDAO: SubscriptionDAO
private val serviceId = ServiceList.YouTube.serviceId
private val stream1 = StreamEntity(1, serviceId, "https://youtube.com/watch?v=1", "stream 1", StreamType.VIDEO_STREAM, 1000, "channel-1", "https://youtube.com/channel/1", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-01-01", OffsetDateTime.parse("2023-01-01T00:00:00Z"))
private val stream2 = StreamEntity(2, serviceId, "https://youtube.com/watch?v=2", "stream 2", StreamType.VIDEO_STREAM, 1000, "channel-1", "https://youtube.com/channel/1", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-01-02", OffsetDateTime.parse("2023-01-02T00:00:00Z"))
private val stream3 = StreamEntity(3, serviceId, "https://youtube.com/watch?v=3", "stream 3", StreamType.LIVE_STREAM, 1000, "channel-1", "https://youtube.com/channel/1", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-01-03", OffsetDateTime.parse("2023-01-03T00:00:00Z"))
private val stream4 = StreamEntity(4, serviceId, "https://youtube.com/watch?v=4", "stream 4", StreamType.VIDEO_STREAM, 1000, "channel-2", "https://youtube.com/channel/2", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-08-10", OffsetDateTime.parse("2023-08-10T00:00:00Z"))
private val stream5 = StreamEntity(5, serviceId, "https://youtube.com/watch?v=5", "stream 5", StreamType.VIDEO_STREAM, 1000, "channel-2", "https://youtube.com/channel/2", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-08-20", OffsetDateTime.parse("2023-08-20T00:00:00Z"))
private val stream6 = StreamEntity(6, serviceId, "https://youtube.com/watch?v=6", "stream 6", StreamType.VIDEO_STREAM, 1000, "channel-3", "https://youtube.com/channel/3", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-09-01", OffsetDateTime.parse("2023-09-01T00:00:00Z"))
private val stream7 = StreamEntity(7, serviceId, "https://youtube.com/watch?v=7", "stream 7", StreamType.VIDEO_STREAM, 1000, "channel-4", "https://youtube.com/channel/4", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-08-10", OffsetDateTime.parse("2023-08-10T00:00:00Z"))
private val allStreams = listOf(
stream1, stream2, stream3, stream4, stream5, stream6, stream7
)
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(
context, AppDatabase::class.java
).build()
feedDAO = db.feedDAO()
streamDAO = db.streamDAO()
subscriptionDAO = db.subscriptionDAO()
}
@After
@Throws(IOException::class)
fun closeDb() {
db.close()
}
@Test
fun testUnlinkStreamsOlderThan_KeepOne() {
setupUnlinkDelete("2023-08-15T00:00:00Z")
val streams = feedDAO.getStreams(
FeedGroupEntity.GROUP_ALL_ID, includePlayed = true, includePartiallyPlayed = true, null
)
.blockingGet()
val allowedStreams = listOf(stream3, stream5, stream6, stream7)
assertEqual(streams, allowedStreams)
}
@Test
fun testUnlinkStreamsOlderThan_KeepMultiple() {
setupUnlinkDelete("2023-08-01T00:00:00Z")
val streams = feedDAO.getStreams(
FeedGroupEntity.GROUP_ALL_ID, includePlayed = true, includePartiallyPlayed = true, null
)
.blockingGet()
val allowedStreams = listOf(stream3, stream4, stream5, stream6, stream7)
assertEqual(streams, allowedStreams)
}
private fun assertEqual(streams: List<StreamWithState>?, allowedStreams: List<StreamEntity>) {
assertNotNull(streams)
assertEquals(
allowedStreams,
streams!!
.map { it.stream }
.sortedBy { it.uid }
.toList()
)
}
private fun setupUnlinkDelete(time: String) {
clearAndFillTables()
Single.fromCallable {
feedDAO.unlinkStreamsOlderThan(OffsetDateTime.parse(time))
}.blockingSubscribe()
Single.fromCallable {
streamDAO.deleteOrphans()
}.blockingSubscribe()
}
private fun clearAndFillTables() {
db.clearAllTables()
streamDAO.insertAll(allStreams)
subscriptionDAO.insertAll(
listOf(
SubscriptionEntity.from(ChannelInfo(serviceId, "1", "https://youtube.com/channel/1", "https://youtube.com/channel/1", "channel-1")),
SubscriptionEntity.from(ChannelInfo(serviceId, "2", "https://youtube.com/channel/2", "https://youtube.com/channel/2", "channel-2")),
SubscriptionEntity.from(ChannelInfo(serviceId, "3", "https://youtube.com/channel/3", "https://youtube.com/channel/3", "channel-3")),
SubscriptionEntity.from(ChannelInfo(serviceId, "4", "https://youtube.com/channel/4", "https://youtube.com/channel/4", "channel-4")),
)
)
feedDAO.insertAll(
listOf(
FeedEntity(1, 1),
FeedEntity(2, 1),
FeedEntity(3, 1),
FeedEntity(4, 2),
FeedEntity(5, 2),
FeedEntity(6, 3),
FeedEntity(7, 4),
)
)
}
}

View File

@ -1,82 +0,0 @@
package org.schabi.newpipe.local.subscription;
import static org.junit.Assert.assertEquals;
import androidx.test.core.app.ApplicationProvider;
import org.junit.After;
import org.junit.Before;
import org.junit.Rule;
import org.junit.Test;
import org.schabi.newpipe.database.AppDatabase;
import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.testUtil.TestDatabase;
import org.schabi.newpipe.testUtil.TrampolineSchedulerRule;
import java.io.IOException;
import java.util.List;
public class SubscriptionManagerTest {
private AppDatabase database;
private SubscriptionManager manager;
@Rule
public TrampolineSchedulerRule trampolineScheduler = new TrampolineSchedulerRule();
private SubscriptionEntity getAssertOneSubscriptionEntity() {
final List<SubscriptionEntity> entities = manager
.getSubscriptions(FeedGroupEntity.GROUP_ALL_ID, "", false)
.blockingFirst();
assertEquals(1, entities.size());
return entities.get(0);
}
@Before
public void setup() {
database = TestDatabase.Companion.createReplacingNewPipeDatabase();
manager = new SubscriptionManager(ApplicationProvider.getApplicationContext());
}
@After
public void cleanUp() {
database.close();
}
@Test
public void testInsert() throws ExtractionException, IOException {
final ChannelInfo info = ChannelInfo.getInfo("https://www.youtube.com/c/3blue1brown");
final SubscriptionEntity subscription = SubscriptionEntity.from(info);
manager.insertSubscription(subscription);
final SubscriptionEntity readSubscription = getAssertOneSubscriptionEntity();
// the uid has changed, since the uid is chosen upon inserting, but the rest should match
assertEquals(subscription.getServiceId(), readSubscription.getServiceId());
assertEquals(subscription.getUrl(), readSubscription.getUrl());
assertEquals(subscription.getName(), readSubscription.getName());
assertEquals(subscription.getAvatarUrl(), readSubscription.getAvatarUrl());
assertEquals(subscription.getSubscriberCount(), readSubscription.getSubscriberCount());
assertEquals(subscription.getDescription(), readSubscription.getDescription());
}
@Test
public void testUpdateNotificationMode() throws ExtractionException, IOException {
final ChannelInfo info = ChannelInfo.getInfo("https://www.youtube.com/c/veritasium");
final SubscriptionEntity subscription = SubscriptionEntity.from(info);
subscription.setNotificationMode(0);
manager.insertSubscription(subscription);
manager.updateNotificationMode(subscription.getServiceId(), subscription.getUrl(), 1)
.blockingAwait();
final SubscriptionEntity anotherSubscription = getAssertOneSubscriptionEntity();
assertEquals(0, subscription.getNotificationMode());
assertEquals(subscription.getUrl(), anotherSubscription.getUrl());
assertEquals(1, anotherSubscription.getNotificationMode());
}
}

View File

@ -12,21 +12,15 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest import androidx.test.filters.MediumTest
import androidx.test.internal.runner.junit4.statement.UiThreadStatement import androidx.test.internal.runner.junit4.statement.UiThreadStatement
import org.junit.Assert import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.MediaFormat import org.schabi.newpipe.extractor.MediaFormat
import org.schabi.newpipe.extractor.downloader.Response
import org.schabi.newpipe.extractor.stream.AudioStream import org.schabi.newpipe.extractor.stream.AudioStream
import org.schabi.newpipe.extractor.stream.Stream import org.schabi.newpipe.extractor.stream.Stream
import org.schabi.newpipe.extractor.stream.SubtitlesStream import org.schabi.newpipe.extractor.stream.SubtitlesStream
import org.schabi.newpipe.extractor.stream.VideoStream import org.schabi.newpipe.extractor.stream.VideoStream
import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper
@MediumTest @MediumTest
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@ -90,7 +84,7 @@ class StreamItemAdapterTest {
@Test @Test
fun subtitleStreams_noIcon() { fun subtitleStreams_noIcon() {
val adapter = StreamItemAdapter<SubtitlesStream, Stream>( val adapter = StreamItemAdapter<SubtitlesStream, Stream>(
StreamItemAdapter.StreamInfoWrapper( StreamItemAdapter.StreamSizeWrapper(
(0 until 5).map { (0 until 5).map {
SubtitlesStream.Builder() SubtitlesStream.Builder()
.setContent("https://example.com", true) .setContent("https://example.com", true)
@ -111,7 +105,7 @@ class StreamItemAdapterTest {
@Test @Test
fun audioStreams_noIcon() { fun audioStreams_noIcon() {
val adapter = StreamItemAdapter<AudioStream, Stream>( val adapter = StreamItemAdapter<AudioStream, Stream>(
StreamItemAdapter.StreamInfoWrapper( StreamItemAdapter.StreamSizeWrapper(
(0 until 5).map { (0 until 5).map {
AudioStream.Builder() AudioStream.Builder()
.setId(Stream.ID_UNKNOWN) .setId(Stream.ID_UNKNOWN)
@ -129,109 +123,12 @@ class StreamItemAdapterTest {
} }
} }
@Test
fun retrieveMediaFormatFromFileTypeHeaders() {
val streams = getIncompleteAudioStreams(5)
val wrapper = StreamInfoWrapper(streams, context)
val retrieveMediaFormat = { stream: AudioStream, response: Response ->
StreamInfoWrapper.retrieveMediaFormatFromFileTypeHeaders(stream, wrapper, response)
}
val helper = AssertionHelper(streams, wrapper, retrieveMediaFormat)
helper.assertInvalidResponse(getResponse(mapOf(Pair("content-length", "mp3"))), 0)
helper.assertInvalidResponse(getResponse(mapOf(Pair("file-type", "mp0"))), 1)
helper.assertValidResponse(getResponse(mapOf(Pair("x-amz-meta-file-type", "aiff"))), 2, MediaFormat.AIFF)
helper.assertValidResponse(getResponse(mapOf(Pair("file-type", "mp3"))), 3, MediaFormat.MP3)
}
@Test
fun retrieveMediaFormatFromContentDispositionHeader() {
val streams = getIncompleteAudioStreams(11)
val wrapper = StreamInfoWrapper(streams, context)
val retrieveMediaFormat = { stream: AudioStream, response: Response ->
StreamInfoWrapper.retrieveMediaFormatFromContentDispositionHeader(stream, wrapper, response)
}
val helper = AssertionHelper(streams, wrapper, retrieveMediaFormat)
helper.assertInvalidResponse(getResponse(mapOf(Pair("content-length", "mp3"))), 0)
helper.assertInvalidResponse(
getResponse(mapOf(Pair("Content-Disposition", "filename=\"train.png\""))), 1
)
helper.assertInvalidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"data.csv\""))), 2
)
helper.assertInvalidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; filename=\"data.csv\""))), 3
)
helper.assertInvalidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"fieldName\"; filename*=\"filename.jpg\""))), 4
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Disposition", "filename=\"train.ogg\""))),
5, MediaFormat.OGG
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Disposition", "some-form-data; filename=\"audio.flac\""))),
6, MediaFormat.FLAC
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.aiff\"; filename=\"audio.aiff\""))),
7, MediaFormat.AIFF
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"alien?\"; filename*=UTF-8''%CE%B1%CE%BB%CE%B9%CF%B5%CE%BD.m4a"))),
8, MediaFormat.M4A
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.mp3\"; filename=\"audio.opus\"; filename*=UTF-8''alien.opus"))),
9, MediaFormat.OPUS
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.mp3\"; filename=\"audio.opus\"; filename*=\"UTF-8''alien.opus\""))),
10, MediaFormat.OPUS
)
}
@Test
fun retrieveMediaFormatFromContentTypeHeader() {
val streams = getIncompleteAudioStreams(12)
val wrapper = StreamInfoWrapper(streams, context)
val retrieveMediaFormat = { stream: AudioStream, response: Response ->
StreamInfoWrapper.retrieveMediaFormatFromContentTypeHeader(stream, wrapper, response)
}
val helper = AssertionHelper(streams, wrapper, retrieveMediaFormat)
helper.assertInvalidResponse(getResponse(mapOf(Pair("content-length", "984501"))), 0)
helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "audio/xyz"))), 1)
helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "mp3"))), 2)
helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "mp3"))), 3)
helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "audio/mpeg"))), 4)
helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "audio/aif"))), 5)
helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "whatever"))), 6)
helper.assertInvalidResponse(getResponse(mapOf()), 7)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Type", "audio/flac"))), 8, MediaFormat.FLAC
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Type", "audio/wav"))), 9, MediaFormat.WAV
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Type", "audio/opus"))), 10, MediaFormat.OPUS
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Type", "audio/aiff"))), 11, MediaFormat.AIFF
)
}
/** /**
* @return a list of video streams, in which their video only property mirrors the provided * @return a list of video streams, in which their video only property mirrors the provided
* [videoOnly] vararg. * [videoOnly] vararg.
*/ */
private fun getVideoStreams(vararg videoOnly: Boolean) = private fun getVideoStreams(vararg videoOnly: Boolean) =
StreamItemAdapter.StreamInfoWrapper( StreamItemAdapter.StreamSizeWrapper(
videoOnly.map { videoOnly.map {
VideoStream.Builder() VideoStream.Builder()
.setId(Stream.ID_UNKNOWN) .setId(Stream.ID_UNKNOWN)
@ -264,19 +161,6 @@ class StreamItemAdapterTest {
} }
) )
private fun getIncompleteAudioStreams(size: Int): List<AudioStream> {
val list = ArrayList<AudioStream>(size)
for (i in 1..size) {
list.add(
AudioStream.Builder()
.setId(Stream.ID_UNKNOWN)
.setContent("https://example.com/$i", true)
.build()
)
}
return list
}
/** /**
* Checks whether the item at [position] in the [spinner] has the correct icon visibility when * Checks whether the item at [position] in the [spinner] has the correct icon visibility when
* it is shown in normal mode (selected) and in dropdown mode (user is choosing one of a list). * it is shown in normal mode (selected) and in dropdown mode (user is choosing one of a list).
@ -312,56 +196,11 @@ class StreamItemAdapterTest {
streams.forEachIndexed { index, stream -> streams.forEachIndexed { index, stream ->
val secondaryStreamHelper: SecondaryStreamHelper<T>? = stream?.let { val secondaryStreamHelper: SecondaryStreamHelper<T>? = stream?.let {
SecondaryStreamHelper( SecondaryStreamHelper(
StreamItemAdapter.StreamInfoWrapper(streams, context), StreamItemAdapter.StreamSizeWrapper(streams, context),
it it
) )
} }
put(index, secondaryStreamHelper) put(index, secondaryStreamHelper)
} }
} }
private fun getResponse(headers: Map<String, String>): Response {
val listHeaders = HashMap<String, List<String>>()
headers.forEach { entry ->
listHeaders[entry.key] = listOf(entry.value)
}
return Response(200, null, listHeaders, "", "")
}
/**
* Helper class for assertion related to extractions of [MediaFormat]s.
*/
class AssertionHelper<T : Stream>(
private val streams: List<T>,
private val wrapper: StreamInfoWrapper<T>,
private val retrieveMediaFormat: (stream: T, response: Response) -> Boolean
) {
/**
* Assert that an invalid response does not result in wrongly extracted [MediaFormat].
*/
fun assertInvalidResponse(
response: Response,
index: Int
) {
assertFalse(
"invalid header returns valid value", retrieveMediaFormat(streams[index], response)
)
assertNull("Media format extracted although stated otherwise", wrapper.getFormat(index))
}
/**
* Assert that a valid response results in correctly extracted and handled [MediaFormat].
*/
fun assertValidResponse(
response: Response,
index: Int,
format: MediaFormat
) {
assertTrue(
"header was not recognized", retrieveMediaFormat(streams[index], response)
)
assertEquals("Wrong media format extracted", format, wrapper.getFormat(index))
}
}
} }

View File

@ -3,6 +3,7 @@ package org.schabi.newpipe
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.facebook.stetho.Stetho import com.facebook.stetho.Stetho
import com.facebook.stetho.okhttp3.StethoInterceptor import com.facebook.stetho.okhttp3.StethoInterceptor
import leakcanary.AppWatcher
import leakcanary.LeakCanary import leakcanary.LeakCanary
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.schabi.newpipe.extractor.downloader.Downloader import org.schabi.newpipe.extractor.downloader.Downloader
@ -12,6 +13,8 @@ class DebugApp : App() {
super.onCreate() super.onCreate()
initStetho() initStetho()
// Give each object 10 seconds to be GC'ed, before LeakCanary gets nosy on it
AppWatcher.config = AppWatcher.config.copy(watchDurationMillis = 10000)
LeakCanary.config = LeakCanary.config.copy( LeakCanary.config = LeakCanary.config.copy(
dumpHeap = PreferenceManager dumpHeap = PreferenceManager
.getDefaultSharedPreferences(this).getBoolean( .getDefaultSharedPreferences(this).getBoolean(

View File

@ -367,7 +367,6 @@
<data android:host="tilvids.com" /> <data android:host="tilvids.com" />
<data android:host="video.lqdn.fr" /> <data android:host="video.lqdn.fr" />
<data android:host="video.ploud.fr" /> <data android:host="video.ploud.fr" />
<data android:host="subscribeto.me" />
<data android:pathPrefix="/videos/" /> <!-- it contains playlists --> <data android:pathPrefix="/videos/" /> <!-- it contains playlists -->
<data android:pathPrefix="/w/" /> <!-- short video URLs --> <data android:pathPrefix="/w/" /> <!-- short video URLs -->

View File

@ -1,127 +0,0 @@
<!DOCTYPE html>
<html lang="en"><head><title></title><script>
/**
* Factory method to create and load a BotGuardClient instance.
* @param options - Configuration options for the BotGuardClient.
* @returns A promise that resolves to a loaded BotGuardClient instance.
*/
function loadBotGuard(challengeData) {
this.vm = this[challengeData.globalName];
this.program = challengeData.program;
this.vmFunctions = {};
this.syncSnapshotFunction = null;
if (!this.vm)
throw new Error('[BotGuardClient]: VM not found in the global object');
if (!this.vm.a)
throw new Error('[BotGuardClient]: Could not load program');
const vmFunctionsCallback = function (
asyncSnapshotFunction,
shutdownFunction,
passEventFunction,
checkCameraFunction
) {
this.vmFunctions = {
asyncSnapshotFunction: asyncSnapshotFunction,
shutdownFunction: shutdownFunction,
passEventFunction: passEventFunction,
checkCameraFunction: checkCameraFunction
};
};
this.syncSnapshotFunction = this.vm.a(this.program, vmFunctionsCallback, true, this.userInteractionElement, function () {/** no-op */ }, [ [], [] ])[0]
// an asynchronous function runs in the background and it will eventually call
// `vmFunctionsCallback`, however we need to manually tell JavaScript to pass
// control to the things running in the background by interrupting this async
// function in any way, e.g. with a delay of 1ms. The loop is most probably not
// needed but is there just because.
return new Promise(function (resolve, reject) {
i = 0
refreshIntervalId = setInterval(function () {
if (!!this.vmFunctions.asyncSnapshotFunction) {
resolve(this)
clearInterval(refreshIntervalId);
}
if (i >= 10000) {
reject("asyncSnapshotFunction is null even after 10 seconds")
clearInterval(refreshIntervalId);
}
i += 1;
}, 1);
})
}
/**
* Takes a snapshot asynchronously.
* @returns The snapshot result.
* @example
* ```ts
* const result = await botguard.snapshot({
* contentBinding: {
* c: "a=6&a2=10&b=SZWDwKVIuixOp7Y4euGTgwckbJA&c=1729143849&d=1&t=7200&c1a=1&c6a=1&c6b=1&hh=HrMb5mRWTyxGJphDr0nW2Oxonh0_wl2BDqWuLHyeKLo",
* e: "ENGAGEMENT_TYPE_VIDEO_LIKE",
* encryptedVideoId: "P-vC09ZJcnM"
* }
* });
*
* console.log(result);
* ```
*/
function snapshot(args) {
return new Promise(function (resolve, reject) {
if (!this.vmFunctions.asyncSnapshotFunction)
return reject(new Error('[BotGuardClient]: Async snapshot function not found'));
this.vmFunctions.asyncSnapshotFunction(function (response) { resolve(response) }, [
args.contentBinding,
args.signedTimestamp,
args.webPoSignalOutput,
args.skipPrivacyBuffer
]);
});
}
function runBotGuard(challengeData) {
const interpreterJavascript = challengeData.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;
if (interpreterJavascript) {
new Function(interpreterJavascript)();
} else throw new Error('Could not load VM');
const webPoSignalOutput = [];
return loadBotGuard({
globalName: challengeData.globalName,
globalObj: this,
program: challengeData.program
}).then(function (botguard) {
return botguard.snapshot({ webPoSignalOutput: webPoSignalOutput })
}).then(function (botguardResponse) {
return { webPoSignalOutput: webPoSignalOutput, botguardResponse: botguardResponse }
})
}
function obtainPoToken(webPoSignalOutput, integrityToken, identifier) {
const getMinter = webPoSignalOutput[0];
if (!getMinter)
throw new Error('PMD:Undefined');
const mintCallback = getMinter(integrityToken);
if (!(mintCallback instanceof Function))
throw new Error('APF:Failed');
const result = mintCallback(identifier);
if (!result)
throw new Error('YNJ:Undefined');
if (!(result instanceof Uint8Array))
throw new Error('ODM:Invalid');
return result;
}
</script></head><body></body></html>

View File

@ -25,7 +25,6 @@ import android.view.ViewGroup;
import androidx.annotation.IntDef; import androidx.annotation.IntDef;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.os.BundleCompat;
import androidx.lifecycle.Lifecycle; import androidx.lifecycle.Lifecycle;
import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.PagerAdapter;
@ -285,7 +284,7 @@ public abstract class FragmentStatePagerAdapterMenuWorkaround extends PagerAdapt
Bundle state = null; Bundle state = null;
if (!mSavedState.isEmpty()) { if (!mSavedState.isEmpty()) {
state = new Bundle(); state = new Bundle();
state.putParcelableArrayList("states", mSavedState); state.putParcelableArray("states", mSavedState.toArray(new Fragment.SavedState[0]));
} }
for (int i = 0; i < mFragments.size(); i++) { for (int i = 0; i < mFragments.size(); i++) {
final Fragment f = mFragments.get(i); final Fragment f = mFragments.get(i);
@ -312,12 +311,13 @@ public abstract class FragmentStatePagerAdapterMenuWorkaround extends PagerAdapt
if (state != null) { if (state != null) {
final Bundle bundle = (Bundle) state; final Bundle bundle = (Bundle) state;
bundle.setClassLoader(loader); bundle.setClassLoader(loader);
final var states = BundleCompat.getParcelableArrayList(bundle, "states", final Parcelable[] fss = bundle.getParcelableArray("states");
Fragment.SavedState.class);
mSavedState.clear(); mSavedState.clear();
mFragments.clear(); mFragments.clear();
if (states != null) { if (fss != null) {
mSavedState.addAll(states); for (final Parcelable parcelable : fss) {
mSavedState.add((Fragment.SavedState) parcelable);
}
} }
final Iterable<String> keys = bundle.keySet(); final Iterable<String> keys = bundle.keySet();
for (final String key : keys) { for (final String key : keys) {

View File

@ -17,17 +17,12 @@ import org.acra.config.CoreConfigurationBuilder;
import org.schabi.newpipe.error.ReCaptchaActivity; import org.schabi.newpipe.error.ReCaptchaActivity;
import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.downloader.Downloader; import org.schabi.newpipe.extractor.downloader.Downloader;
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor;
import org.schabi.newpipe.ktx.ExceptionUtils; import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.settings.NewPipeSettings; import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.util.BridgeStateSaverInitializer;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.util.StateSaver; import org.schabi.newpipe.util.StateSaver;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.image.PreferredImageQuality;
import org.schabi.newpipe.util.potoken.PoTokenProviderImpl;
import java.io.IOException; import java.io.IOException;
import java.io.InterruptedIOException; import java.io.InterruptedIOException;
@ -63,8 +58,6 @@ import io.reactivex.rxjava3.plugins.RxJavaPlugins;
public class App extends Application { public class App extends Application {
public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID; public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID;
private static final String TAG = App.class.toString(); private static final String TAG = App.class.toString();
private boolean isFirstRun = false;
private static App app; private static App app;
@NonNull @NonNull
@ -90,13 +83,7 @@ public class App extends Application {
return; return;
} }
// check if the last used preference version is set // Initialize settings first because others inits can use its values
// to determine whether this is the first app run
final int lastUsedPrefVersion = PreferenceManager.getDefaultSharedPreferences(this)
.getInt(getString(R.string.last_used_preferences_version), -1);
isFirstRun = lastUsedPrefVersion == -1;
// Initialize settings first because other initializations can use its values
NewPipeSettings.initSettings(this); NewPipeSettings.initSettings(this);
NewPipe.init(getDownloader(), NewPipe.init(getDownloader(),
@ -104,7 +91,6 @@ public class App extends Application {
Localization.getPreferredContentCountry(this)); Localization.getPreferredContentCountry(this));
Localization.initPrettyTime(Localization.resolvePrettyTime(getApplicationContext())); Localization.initPrettyTime(Localization.resolvePrettyTime(getApplicationContext()));
BridgeStateSaverInitializer.init(this);
StateSaver.init(this); StateSaver.init(this);
initNotificationChannels(); initNotificationChannels();
@ -113,15 +99,12 @@ public class App extends Application {
// Initialize image loader // Initialize image loader
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
PicassoHelper.init(this); PicassoHelper.init(this);
ImageStrategy.setPreferredImageQuality(PreferredImageQuality.fromPreferenceKey(this, PicassoHelper.setShouldLoadImages(
prefs.getString(getString(R.string.image_quality_key), prefs.getBoolean(getString(R.string.download_thumbnail_key), true));
getString(R.string.image_quality_default))));
PicassoHelper.setIndicatorsEnabled(MainActivity.DEBUG PicassoHelper.setIndicatorsEnabled(MainActivity.DEBUG
&& prefs.getBoolean(getString(R.string.show_image_indicators_key), false)); && prefs.getBoolean(getString(R.string.show_image_indicators_key), false));
configureRxJavaErrorHandler(); configureRxJavaErrorHandler();
YoutubeStreamExtractor.setPoTokenProvider(PoTokenProviderImpl.INSTANCE);
} }
@Override @Override
@ -269,7 +252,4 @@ public class App extends Application {
return false; return false;
} }
public boolean isFirstRun() {
return isFirstRun;
}
} }

View File

@ -10,9 +10,9 @@ import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentManager;
import com.evernote.android.state.State; import icepick.Icepick;
import com.livefront.bridge.Bridge; import icepick.State;
import leakcanary.AppWatcher;
public abstract class BaseFragment extends Fragment { public abstract class BaseFragment extends Fragment {
protected final String TAG = getClass().getSimpleName() + "@" + Integer.toHexString(hashCode()); protected final String TAG = getClass().getSimpleName() + "@" + Integer.toHexString(hashCode());
@ -49,7 +49,7 @@ public abstract class BaseFragment extends Fragment {
+ "savedInstanceState = [" + savedInstanceState + "]"); + "savedInstanceState = [" + savedInstanceState + "]");
} }
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
Bridge.restoreInstanceState(this, savedInstanceState); Icepick.restoreInstanceState(this, savedInstanceState);
if (savedInstanceState != null) { if (savedInstanceState != null) {
onRestoreInstanceState(savedInstanceState); onRestoreInstanceState(savedInstanceState);
} }
@ -71,39 +71,26 @@ public abstract class BaseFragment extends Fragment {
@Override @Override
public void onSaveInstanceState(@NonNull final Bundle outState) { public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState); super.onSaveInstanceState(outState);
Bridge.saveInstanceState(this, outState); Icepick.saveInstanceState(this, outState);
} }
protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) { protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) {
} }
@Override
public void onDestroy() {
super.onDestroy();
AppWatcher.INSTANCE.getObjectWatcher().watch(this);
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Init // Init
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
/**
* This method is called in {@link #onViewCreated(View, Bundle)} to initialize the views.
*
* <p>
* {@link #initListeners()} is called after this method to initialize the corresponding
* listeners.
* </p>
* @param rootView The inflated view for this fragment
* (provided by {@link #onViewCreated(View, Bundle)})
* @param savedInstanceState The saved state of this fragment
* (provided by {@link #onViewCreated(View, Bundle)})
*/
protected void initViews(final View rootView, final Bundle savedInstanceState) { protected void initViews(final View rootView, final Bundle savedInstanceState) {
} }
/**
* Initialize the listeners for this fragment.
*
* <p>
* This method is called after {@link #initViews(View, Bundle)}
* in {@link #onViewCreated(View, Bundle)}.
* </p>
*/
protected void initListeners() { protected void initListeners() {
} }
@ -121,20 +108,9 @@ public abstract class BaseFragment extends Fragment {
} }
} }
/**
* Finds the root fragment by looping through all of the parent fragments. The root fragment
* is supposed to be {@link org.schabi.newpipe.fragments.MainFragment}, and is the fragment that
* handles keeping the backstack of opened fragments in NewPipe, and also the player bottom
* sheet. This function therefore returns the fragment manager of said fragment.
*
* @return the fragment manager of the root fragment, i.e.
* {@link org.schabi.newpipe.fragments.MainFragment}
*/
protected FragmentManager getFM() { protected FragmentManager getFM() {
Fragment current = this; return getParentFragment() == null
while (current.getParentFragment() != null) { ? getFragmentManager()
current = current.getParentFragment(); : getParentFragment().getFragmentManager();
}
return current.getFragmentManager();
} }
} }

View File

@ -29,7 +29,7 @@ import okhttp3.ResponseBody;
public final class DownloaderImpl extends Downloader { public final class DownloaderImpl extends Downloader {
public static final String USER_AGENT = public static final String USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0"; "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0";
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY = public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY =
"youtube_restricted_mode_key"; "youtube_restricted_mode_key";
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000"; public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000";
@ -137,8 +137,7 @@ public final class DownloaderImpl extends Downloader {
} }
final okhttp3.Request.Builder requestBuilder = new okhttp3.Request.Builder() final okhttp3.Request.Builder requestBuilder = new okhttp3.Request.Builder()
.method(httpMethod, requestBody) .method(httpMethod, requestBody).url(url)
.url(url)
.addHeader("User-Agent", USER_AGENT); .addHeader("User-Agent", USER_AGENT);
final String cookies = getCookies(url); final String cookies = getCookies(url);
@ -146,33 +145,38 @@ public final class DownloaderImpl extends Downloader {
requestBuilder.addHeader("Cookie", cookies); requestBuilder.addHeader("Cookie", cookies);
} }
headers.forEach((headerName, headerValueList) -> { for (final Map.Entry<String, List<String>> pair : headers.entrySet()) {
requestBuilder.removeHeader(headerName); final String headerName = pair.getKey();
headerValueList.forEach(headerValue -> final List<String> headerValueList = pair.getValue();
requestBuilder.addHeader(headerName, headerValue));
});
try ( if (headerValueList.size() > 1) {
okhttp3.Response response = client.newCall(requestBuilder.build()).execute() requestBuilder.removeHeader(headerName);
) { for (final String headerValue : headerValueList) {
if (response.code() == 429) { requestBuilder.addHeader(headerName, headerValue);
throw new ReCaptchaException("reCaptcha Challenge requested", url);
}
String responseBodyToReturn = null;
try (ResponseBody body = response.body()) {
if (body != null) {
responseBodyToReturn = body.string();
} }
} else if (headerValueList.size() == 1) {
requestBuilder.header(headerName, headerValueList.get(0));
} }
final String latestUrl = response.request().url().toString();
return new Response(
response.code(),
response.message(),
response.headers().toMultimap(),
responseBodyToReturn,
latestUrl);
} }
final okhttp3.Response response = client.newCall(requestBuilder.build()).execute();
if (response.code() == 429) {
response.close();
throw new ReCaptchaException("reCaptcha Challenge requested", url);
}
final ResponseBody body = response.body();
String responseBodyToReturn = null;
if (body != null) {
responseBodyToReturn = body.string();
}
final String latestUrl = response.request().url().toString();
return new Response(response.code(), response.message(), response.headers().toMultimap(),
responseBodyToReturn, latestUrl);
} }
} }

View File

@ -44,7 +44,6 @@ import android.widget.FrameLayout;
import android.widget.Spinner; import android.widget.Spinner;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.ActionBarDrawerToggle; import androidx.appcompat.app.ActionBarDrawerToggle;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
@ -52,7 +51,6 @@ import androidx.core.app.ActivityCompat;
import androidx.core.view.GravityCompat; import androidx.core.view.GravityCompat;
import androidx.drawerlayout.widget.DrawerLayout; import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentContainerView;
import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentManager;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
@ -65,21 +63,19 @@ import org.schabi.newpipe.databinding.InstanceSpinnerLayoutBinding;
import org.schabi.newpipe.databinding.ToolbarLayoutBinding; import org.schabi.newpipe.databinding.ToolbarLayoutBinding;
import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance; import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance;
import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.BackPressable;
import org.schabi.newpipe.fragments.MainFragment; import org.schabi.newpipe.fragments.MainFragment;
import org.schabi.newpipe.fragments.detail.VideoDetailFragment; import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment;
import org.schabi.newpipe.fragments.list.search.SearchFragment; import org.schabi.newpipe.fragments.list.search.SearchFragment;
import org.schabi.newpipe.local.feed.notifications.NotificationWorker; import org.schabi.newpipe.local.feed.notifications.NotificationWorker;
import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.event.OnKeyDownListener; import org.schabi.newpipe.player.event.OnKeyDownListener;
import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.settings.UpdateSettingsFragment;
import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.KioskTranslator; import org.schabi.newpipe.util.KioskTranslator;
@ -87,12 +83,10 @@ import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PeertubeHelper; import org.schabi.newpipe.util.PeertubeHelper;
import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.PermissionHelper;
import org.schabi.newpipe.util.ReleaseVersionUtil;
import org.schabi.newpipe.util.SerializedCache; import org.schabi.newpipe.util.SerializedCache;
import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.util.StateSaver; import org.schabi.newpipe.util.StateSaver;
import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.views.FocusOverlayView; import org.schabi.newpipe.views.FocusOverlayView;
import java.util.ArrayList; import java.util.ArrayList;
@ -121,8 +115,7 @@ public class MainActivity extends AppCompatActivity {
private static final int ITEM_ID_DOWNLOADS = -4; private static final int ITEM_ID_DOWNLOADS = -4;
private static final int ITEM_ID_HISTORY = -5; private static final int ITEM_ID_HISTORY = -5;
private static final int ITEM_ID_SETTINGS = 0; private static final int ITEM_ID_SETTINGS = 0;
private static final int ITEM_ID_DONATION = 1; private static final int ITEM_ID_ABOUT = 1;
private static final int ITEM_ID_ABOUT = 2;
private static final int ORDER = 0; private static final int ORDER = 0;
@ -171,11 +164,6 @@ public class MainActivity extends AppCompatActivity {
// if this is enabled by the user. // if this is enabled by the user.
NotificationWorker.initialize(this); NotificationWorker.initialize(this);
} }
if (!UpdateSettingsFragment.wasUserAskedForConsent(this)
&& !App.getApp().isFirstRun()
&& ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
UpdateSettingsFragment.askForConsentToUpdateChecks(this);
}
} }
@Override @Override
@ -185,8 +173,7 @@ public class MainActivity extends AppCompatActivity {
final App app = App.getApp(); final App app = App.getApp();
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app); final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
if (prefs.getBoolean(app.getString(R.string.update_app_key), false) if (prefs.getBoolean(app.getString(R.string.update_app_key), true)) {
&& prefs.getBoolean(app.getString(R.string.update_check_consent_key), false)) {
// Start the worker which is checking all conditions // Start the worker which is checking all conditions
// and eventually searching for a new version. // and eventually searching for a new version.
NewVersionWorker.enqueueNewVersionCheckingWork(app, false); NewVersionWorker.enqueueNewVersionCheckingWork(app, false);
@ -233,14 +220,14 @@ public class MainActivity extends AppCompatActivity {
final int currentServiceId = ServiceHelper.getSelectedServiceId(this); final int currentServiceId = ServiceHelper.getSelectedServiceId(this);
final StreamingService service = NewPipe.getService(currentServiceId); final StreamingService service = NewPipe.getService(currentServiceId);
int kioskMenuItemId = 0; int kioskId = 0;
for (final String ks : service.getKioskList().getAvailableKiosks()) { for (final String ks : service.getKioskList().getAvailableKiosks()) {
drawerLayoutBinding.navigation.getMenu() drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_tabs_group, kioskMenuItemId, 0, KioskTranslator .add(R.id.menu_tabs_group, kioskId, 0, KioskTranslator
.getTranslatedKioskName(ks, this)) .getTranslatedKioskName(ks, this))
.setIcon(KioskTranslator.getKioskIcon(ks)); .setIcon(KioskTranslator.getKioskIcon(ks));
kioskMenuItemId++; kioskId++;
} }
drawerLayoutBinding.navigation.getMenu() drawerLayoutBinding.navigation.getMenu()
@ -264,10 +251,6 @@ public class MainActivity extends AppCompatActivity {
drawerLayoutBinding.navigation.getMenu() drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_options_about_group, ITEM_ID_SETTINGS, ORDER, R.string.settings) .add(R.id.menu_options_about_group, ITEM_ID_SETTINGS, ORDER, R.string.settings)
.setIcon(R.drawable.ic_settings); .setIcon(R.drawable.ic_settings);
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_options_about_group, ITEM_ID_DONATION, ORDER,
R.string.donation_title)
.setIcon(R.drawable.volunteer_activism_ic);
drawerLayoutBinding.navigation.getMenu() drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_options_about_group, ITEM_ID_ABOUT, ORDER, R.string.tab_about) .add(R.id.menu_options_about_group, ITEM_ID_ABOUT, ORDER, R.string.tab_about)
.setIcon(R.drawable.ic_info_outline); .setIcon(R.drawable.ic_info_outline);
@ -276,8 +259,15 @@ public class MainActivity extends AppCompatActivity {
private boolean drawerItemSelected(final MenuItem item) { private boolean drawerItemSelected(final MenuItem item) {
switch (item.getGroupId()) { switch (item.getGroupId()) {
case R.id.menu_services_group: case R.id.menu_services_group:
changeService(item); if (item.getItemId() == ServiceList.PeerTube.getServiceId()
break; && DeviceUtils.isTv(getApplicationContext())
&& !item.isActionViewExpanded()) {
((Spinner) item.getActionView()).performClick();
return true;
} else {
changeService(item);
break;
}
case R.id.menu_tabs_group: case R.id.menu_tabs_group:
try { try {
tabSelected(item); tabSelected(item);
@ -324,16 +314,20 @@ public class MainActivity extends AppCompatActivity {
NavigationHelper.openStatisticFragment(getSupportFragmentManager()); NavigationHelper.openStatisticFragment(getSupportFragmentManager());
break; break;
default: default:
final StreamingService currentService = ServiceHelper.getSelectedService(this); final int currentServiceId = ServiceHelper.getSelectedServiceId(this);
int kioskMenuItemId = 0; final StreamingService service = NewPipe.getService(currentServiceId);
for (final String kioskId : currentService.getKioskList().getAvailableKiosks()) { String serviceName = "";
if (kioskMenuItemId == item.getItemId()) {
NavigationHelper.openKioskFragment(getSupportFragmentManager(), int kioskId = 0;
currentService.getServiceId(), kioskId); for (final String ks : service.getKioskList().getAvailableKiosks()) {
break; if (kioskId == item.getItemId()) {
serviceName = ks;
} }
kioskMenuItemId++; kioskId++;
} }
NavigationHelper.openKioskFragment(getSupportFragmentManager(), currentServiceId,
serviceName);
break; break;
} }
} }
@ -343,9 +337,6 @@ public class MainActivity extends AppCompatActivity {
case ITEM_ID_SETTINGS: case ITEM_ID_SETTINGS:
NavigationHelper.openSettings(this); NavigationHelper.openSettings(this);
break; break;
case ITEM_ID_DONATION:
ShareUtils.openUrlInBrowser(this, getString(R.string.donation_url));
break;
case ITEM_ID_ABOUT: case ITEM_ID_ABOUT:
NavigationHelper.openAbout(this); NavigationHelper.openAbout(this);
break; break;
@ -400,8 +391,8 @@ public class MainActivity extends AppCompatActivity {
.add(R.id.menu_services_group, s.getServiceId(), ORDER, title) .add(R.id.menu_services_group, s.getServiceId(), ORDER, title)
.setIcon(ServiceHelper.getIcon(s.getServiceId())); .setIcon(ServiceHelper.getIcon(s.getServiceId()));
// peertube specifics // PeerTube specifics
if (s.getServiceId() == 3) { if (s == ServiceList.PeerTube) {
enhancePeertubeMenu(menuItem); enhancePeertubeMenu(menuItem);
} }
} }
@ -567,21 +558,14 @@ public class MainActivity extends AppCompatActivity {
// interacts with a fragment inside fragment_holder so all back presses should be // interacts with a fragment inside fragment_holder so all back presses should be
// handled by it // handled by it
if (bottomSheetHiddenOrCollapsed()) { if (bottomSheetHiddenOrCollapsed()) {
final FragmentManager fm = getSupportFragmentManager(); final Fragment fragment = getSupportFragmentManager()
final Fragment fragment = fm.findFragmentById(R.id.fragment_holder); .findFragmentById(R.id.fragment_holder);
// If current fragment implements BackPressable (i.e. can/wanna handle back press) // If current fragment implements BackPressable (i.e. can/wanna handle back press)
// delegate the back press to it // delegate the back press to it
if (fragment instanceof BackPressable) { if (fragment instanceof BackPressable) {
if (((BackPressable) fragment).onBackPressed()) { if (((BackPressable) fragment).onBackPressed()) {
return; return;
} }
} else if (fragment instanceof CommentRepliesFragment) {
// expand DetailsFragment if CommentRepliesFragment was opened
// to show the top level comments again
// Expand DetailsFragment if CommentRepliesFragment was opened
// and no other CommentRepliesFragments are on top of the back stack
// to show the top level comments again.
openDetailFragmentFromCommentReplies(fm, false);
} }
} else { } else {
@ -657,17 +641,10 @@ public class MainActivity extends AppCompatActivity {
* </pre> * </pre>
*/ */
private void onHomeButtonPressed() { private void onHomeButtonPressed() {
final FragmentManager fm = getSupportFragmentManager(); // If search fragment wasn't found in the backstack...
final Fragment fragment = fm.findFragmentById(R.id.fragment_holder); if (!NavigationHelper.tryGotoSearchFragment(getSupportFragmentManager())) {
// ...go to the main fragment
if (fragment instanceof CommentRepliesFragment) { NavigationHelper.gotoMainFragment(getSupportFragmentManager());
// Expand DetailsFragment if CommentRepliesFragment was opened
// and no other CommentRepliesFragments are on top of the back stack
// to show the top level comments again.
openDetailFragmentFromCommentReplies(fm, true);
} else if (!NavigationHelper.tryGotoSearchFragment(fm)) {
// If search fragment wasn't found in the backstack go to the main fragment
NavigationHelper.gotoMainFragment(fm);
} }
} }
@ -863,68 +840,6 @@ public class MainActivity extends AppCompatActivity {
} }
} }
private void openDetailFragmentFromCommentReplies(
@NonNull final FragmentManager fm,
final boolean popBackStack
) {
// obtain the name of the fragment under the replies fragment that's going to be popped
@Nullable final String fragmentUnderEntryName;
if (fm.getBackStackEntryCount() < 2) {
fragmentUnderEntryName = null;
} else {
fragmentUnderEntryName = fm.getBackStackEntryAt(fm.getBackStackEntryCount() - 2)
.getName();
}
// the root comment is the comment for which the user opened the replies page
@Nullable final CommentRepliesFragment repliesFragment =
(CommentRepliesFragment) fm.findFragmentByTag(CommentRepliesFragment.TAG);
@Nullable final CommentsInfoItem rootComment =
repliesFragment == null ? null : repliesFragment.getCommentsInfoItem();
// sometimes this function pops the backstack, other times it's handled by the system
if (popBackStack) {
fm.popBackStackImmediate();
}
// only expand the bottom sheet back if there are no more nested comment replies fragments
// stacked under the one that is currently being popped
if (CommentRepliesFragment.TAG.equals(fragmentUnderEntryName)) {
return;
}
final BottomSheetBehavior<FragmentContainerView> behavior = BottomSheetBehavior
.from(mainBinding.fragmentPlayerHolder);
// do not return to the comment if the details fragment was closed
if (behavior.getState() == BottomSheetBehavior.STATE_HIDDEN) {
return;
}
// scroll to the root comment once the bottom sheet expansion animation is finished
behavior.addBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() {
@Override
public void onStateChanged(@NonNull final View bottomSheet,
final int newState) {
if (newState == BottomSheetBehavior.STATE_EXPANDED) {
final Fragment detailFragment = fm.findFragmentById(
R.id.fragment_player_holder);
if (detailFragment instanceof VideoDetailFragment && rootComment != null) {
// should always be the case
((VideoDetailFragment) detailFragment).scrollToComment(rootComment);
}
behavior.removeBottomSheetCallback(this);
}
}
@Override
public void onSlide(@NonNull final View bottomSheet, final float slideOffset) {
// not needed, listener is removed once the sheet is expanded
}
});
behavior.setState(BottomSheetBehavior.STATE_EXPANDED);
}
private boolean bottomSheetHiddenOrCollapsed() { private boolean bottomSheetHiddenOrCollapsed() {
final BottomSheetBehavior<FrameLayout> bottomSheetBehavior = final BottomSheetBehavior<FrameLayout> bottomSheetBehavior =
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder); BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder);
@ -933,5 +848,4 @@ public class MainActivity extends AppCompatActivity {
return sheetState == BottomSheetBehavior.STATE_HIDDEN return sheetState == BottomSheetBehavior.STATE_HIDDEN
|| sheetState == BottomSheetBehavior.STATE_COLLAPSED; || sheetState == BottomSheetBehavior.STATE_COLLAPSED;
} }
} }

View File

@ -7,8 +7,6 @@ import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
import static org.schabi.newpipe.database.Migrations.MIGRATION_4_5; import static org.schabi.newpipe.database.Migrations.MIGRATION_4_5;
import static org.schabi.newpipe.database.Migrations.MIGRATION_5_6; import static org.schabi.newpipe.database.Migrations.MIGRATION_5_6;
import static org.schabi.newpipe.database.Migrations.MIGRATION_6_7; import static org.schabi.newpipe.database.Migrations.MIGRATION_6_7;
import static org.schabi.newpipe.database.Migrations.MIGRATION_7_8;
import static org.schabi.newpipe.database.Migrations.MIGRATION_8_9;
import android.content.Context; import android.content.Context;
import android.database.Cursor; import android.database.Cursor;
@ -29,7 +27,7 @@ public final class NewPipeDatabase {
return Room return Room
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME) .databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5, .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9) MIGRATION_5_6, MIGRATION_6_7)
.build(); .build();
} }

View File

@ -20,7 +20,9 @@ import com.grack.nanojson.JsonParser
import com.grack.nanojson.JsonParserException import com.grack.nanojson.JsonParserException
import org.schabi.newpipe.extractor.downloader.Response import org.schabi.newpipe.extractor.downloader.Response
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
import org.schabi.newpipe.util.ReleaseVersionUtil import org.schabi.newpipe.util.ReleaseVersionUtil.coerceUpdateCheckExpiry
import org.schabi.newpipe.util.ReleaseVersionUtil.isLastUpdateCheckExpired
import org.schabi.newpipe.util.ReleaseVersionUtil.isReleaseApk
import java.io.IOException import java.io.IOException
class NewVersionWorker( class NewVersionWorker(
@ -82,7 +84,7 @@ class NewVersionWorker(
@Throws(IOException::class, ReCaptchaException::class) @Throws(IOException::class, ReCaptchaException::class)
private fun checkNewVersion() { private fun checkNewVersion() {
// Check if the current apk is a github one or not. // Check if the current apk is a github one or not.
if (!ReleaseVersionUtil.isReleaseApk) { if (!isReleaseApk()) {
return return
} }
@ -91,7 +93,7 @@ class NewVersionWorker(
// Check if the last request has happened a certain time ago // Check if the last request has happened a certain time ago
// to reduce the number of API requests. // to reduce the number of API requests.
val expiry = prefs.getLong(applicationContext.getString(R.string.update_expiry_key), 0) val expiry = prefs.getLong(applicationContext.getString(R.string.update_expiry_key), 0)
if (!ReleaseVersionUtil.isLastUpdateCheckExpired(expiry)) { if (!isLastUpdateCheckExpired(expiry)) {
return return
} }
} }
@ -106,7 +108,7 @@ class NewVersionWorker(
try { try {
// Store a timestamp which needs to be exceeded, // Store a timestamp which needs to be exceeded,
// before a new request to the API is made. // before a new request to the API is made.
val newExpiry = ReleaseVersionUtil.coerceUpdateCheckExpiry(response.getHeader("expires")) val newExpiry = coerceUpdateCheckExpiry(response.getHeader("expires"))
prefs.edit { prefs.edit {
putLong(applicationContext.getString(R.string.update_expiry_key), newExpiry) putLong(applicationContext.getString(R.string.update_expiry_key), newExpiry)
} }
@ -118,13 +120,13 @@ class NewVersionWorker(
// Parse the json from the response. // Parse the json from the response.
try { try {
val newpipeVersionInfo = JsonParser.`object`() val githubStableObject = JsonParser.`object`()
.from(response.responseBody()).getObject("flavors") .from(response.responseBody()).getObject("flavors")
.getObject("newpipe") .getObject("github").getObject("stable")
val versionName = newpipeVersionInfo.getString("version") val versionName = githubStableObject.getString("version")
val versionCode = newpipeVersionInfo.getInt("version_code") val versionCode = githubStableObject.getInt("version_code")
val apkLocationUrl = newpipeVersionInfo.getString("apk") val apkLocationUrl = githubStableObject.getString("apk")
compareAppVersionAndShowNotification(versionName, apkLocationUrl, versionCode) compareAppVersionAndShowNotification(versionName, apkLocationUrl, versionCode)
} catch (e: JsonParserException) { } catch (e: JsonParserException) {
// Most likely something is wrong in data received from NEWPIPE_API_URL. // Most likely something is wrong in data received from NEWPIPE_API_URL.

View File

@ -75,7 +75,7 @@ public final class QueueItemMenuUtil {
return true; return true;
case R.id.menu_item_share: case R.id.menu_item_share:
shareText(context, item.getTitle(), item.getUrl(), shareText(context, item.getTitle(), item.getUrl(),
item.getThumbnails()); item.getThumbnailUrl());
return true; return true;
case R.id.menu_item_download: case R.id.menu_item_download:
fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(), fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(),

View File

@ -41,14 +41,10 @@ import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LifecycleOwner;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
import com.evernote.android.state.State;
import com.livefront.bridge.Bridge;
import org.schabi.newpipe.database.stream.model.StreamEntity; import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.databinding.ListRadioIconItemBinding; import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding; import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding;
import org.schabi.newpipe.download.DownloadDialog; import org.schabi.newpipe.download.DownloadDialog;
import org.schabi.newpipe.download.LoadingDialog;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.ReCaptchaActivity; import org.schabi.newpipe.error.ReCaptchaActivity;
@ -68,7 +64,6 @@ import org.schabi.newpipe.extractor.exceptions.PrivateContentException;
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException; import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException;
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException; import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.ktx.ExceptionUtils; import org.schabi.newpipe.ktx.ExceptionUtils;
@ -76,11 +71,10 @@ import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.helper.PlayerHelper;
import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue; import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.ChannelTabHelper;
import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ExtractorHelper;
@ -101,6 +95,8 @@ import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.function.Consumer; import java.util.function.Consumer;
import icepick.Icepick;
import icepick.State;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.core.Single;
@ -153,7 +149,7 @@ public class RouterActivity extends AppCompatActivity {
getWindow().setAttributes(params); getWindow().setAttributes(params);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
Bridge.restoreInstanceState(this, savedInstanceState); Icepick.restoreInstanceState(this, savedInstanceState);
// FragmentManager will take care to recreate (Playlist|Download)Dialog when screen rotates // FragmentManager will take care to recreate (Playlist|Download)Dialog when screen rotates
// We used to .setOnDismissListener(dialog -> finish()); when creating these DialogFragments // We used to .setOnDismissListener(dialog -> finish()); when creating these DialogFragments
@ -198,7 +194,7 @@ public class RouterActivity extends AppCompatActivity {
@Override @Override
protected void onSaveInstanceState(@NonNull final Bundle outState) { protected void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState); super.onSaveInstanceState(outState);
Bridge.saveInstanceState(this, outState); Icepick.saveInstanceState(this, outState);
} }
@Override @Override
@ -793,10 +789,10 @@ public class RouterActivity extends AppCompatActivity {
} }
} }
}, () -> }, () -> {
// this branch is executed if there is no activity context // this branch is executed if there is no activity context
inFlight(false) inFlight(false);
); });
} }
<T> Single<T> pleaseWait(final Single<T> single) { <T> Single<T> pleaseWait(final Single<T> single) {
@ -816,24 +812,19 @@ public class RouterActivity extends AppCompatActivity {
@SuppressLint("CheckResult") @SuppressLint("CheckResult")
private void openDownloadDialog(final int currentServiceId, final String currentUrl) { private void openDownloadDialog(final int currentServiceId, final String currentUrl) {
inFlight(true); inFlight(true);
final LoadingDialog loadingDialog = new LoadingDialog(R.string.loading_metadata_title);
loadingDialog.show(getParentFragmentManager(), "loadingDialog");
disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true) disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true)
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.compose(this::pleaseWait) .compose(this::pleaseWait)
.subscribe(result -> .subscribe(result ->
runOnVisible(ctx -> { runOnVisible(ctx -> {
loadingDialog.dismiss();
final FragmentManager fm = ctx.getSupportFragmentManager(); final FragmentManager fm = ctx.getSupportFragmentManager();
final DownloadDialog downloadDialog = new DownloadDialog(ctx, result); final DownloadDialog downloadDialog = new DownloadDialog(ctx, result);
// dismiss listener to be handled by FragmentManager // dismiss listener to be handled by FragmentManager
downloadDialog.show(fm, "downloadDialog"); downloadDialog.show(fm, "downloadDialog");
} }
), throwable -> runOnVisible(ctx -> { ), throwable -> runOnVisible(ctx ->
loadingDialog.dismiss(); ((RouterActivity) ctx).showUnsupportedUrlDialog(currentUrl))));
((RouterActivity) ctx).showUnsupportedUrlDialog(currentUrl);
})));
} }
private void openAddToPlaylistDialog(final int currentServiceId, final String currentUrl) { private void openAddToPlaylistDialog(final int currentServiceId, final String currentUrl) {
@ -1025,16 +1016,7 @@ public class RouterActivity extends AppCompatActivity {
} }
playQueue = new SinglePlayQueue((StreamInfo) info); playQueue = new SinglePlayQueue((StreamInfo) info);
} else if (info instanceof ChannelInfo) { } else if (info instanceof ChannelInfo) {
final Optional<ListLinkHandler> playableTab = ((ChannelInfo) info).getTabs() playQueue = new ChannelPlayQueue((ChannelInfo) info);
.stream()
.filter(ChannelTabHelper::isStreamsTab)
.findFirst();
if (playableTab.isPresent()) {
playQueue = new ChannelTabPlayQueue(info.getServiceId(), playableTab.get());
} else {
return; // there is no playable tab
}
} else if (info instanceof PlaylistInfo) { } else if (info instanceof PlaylistInfo) {
playQueue = new PlaylistPlayQueue((PlaylistInfo) info); playQueue = new PlaylistPlayQueue((PlaylistInfo) info);
} else { } else {

View File

@ -116,7 +116,7 @@ class AboutActivity : AppCompatActivity() {
/** /**
* List of all software components. * List of all software components.
*/ */
private val SOFTWARE_COMPONENTS = arrayListOf( private val SOFTWARE_COMPONENTS = arrayOf(
SoftwareComponent( SoftwareComponent(
"ACRA", "2013", "Kevin Gaudin", "ACRA", "2013", "Kevin Gaudin",
"https://github.com/ACRA/acra", StandardLicenses.APACHE2 "https://github.com/ACRA/acra", StandardLicenses.APACHE2
@ -138,12 +138,8 @@ class AboutActivity : AppCompatActivity() {
"https://github.com/lisawray/groupie", StandardLicenses.MIT "https://github.com/lisawray/groupie", StandardLicenses.MIT
), ),
SoftwareComponent( SoftwareComponent(
"Android-State", "2018", "Evernote", "Icepick", "2015", "Frankie Sardo",
"https://github.com/Evernote/android-state", StandardLicenses.EPL1 "https://github.com/frankiesardo/icepick", StandardLicenses.EPL1
),
SoftwareComponent(
"Bridge", "2021", "Livefront",
"https://github.com/livefront/bridge", StandardLicenses.APACHE2
), ),
SoftwareComponent( SoftwareComponent(
"Jsoup", "2009 - 2020", "Jonathan Hedley", "Jsoup", "2009 - 2020", "Jonathan Hedley",

View File

@ -1,40 +1,30 @@
package org.schabi.newpipe.about package org.schabi.newpipe.about
import android.os.Bundle import android.os.Bundle
import android.util.Base64
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.webkit.WebView
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.FragmentLicensesBinding import org.schabi.newpipe.databinding.FragmentLicensesBinding
import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding
import org.schabi.newpipe.ktx.parcelableArrayList
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.external_communication.ShareUtils
/** /**
* Fragment containing the software licenses. * Fragment containing the software licenses.
*/ */
class LicenseFragment : Fragment() { class LicenseFragment : Fragment() {
private lateinit var softwareComponents: List<SoftwareComponent> private lateinit var softwareComponents: Array<SoftwareComponent>
private var activeSoftwareComponent: SoftwareComponent? = null private var activeLicense: License? = null
private val compositeDisposable = CompositeDisposable() private val compositeDisposable = CompositeDisposable()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
softwareComponents = arguments?.parcelableArrayList<SoftwareComponent>(ARG_COMPONENTS)!! softwareComponents = arguments?.getParcelableArray(ARG_COMPONENTS) as Array<SoftwareComponent>
.sortedBy { it.name } // Sort components by name activeLicense = savedInstanceState?.getSerializable(LICENSE_KEY) as? License
activeSoftwareComponent = savedInstanceState?.getSerializable(SOFTWARE_COMPONENT_KEY) as? SoftwareComponent // Sort components by name
softwareComponents.sortBy { it.name }
} }
override fun onDestroy() { override fun onDestroy() {
@ -49,8 +39,9 @@ class LicenseFragment : Fragment() {
): View { ): View {
val binding = FragmentLicensesBinding.inflate(inflater, container, false) val binding = FragmentLicensesBinding.inflate(inflater, container, false)
binding.licensesAppReadLicense.setOnClickListener { binding.licensesAppReadLicense.setOnClickListener {
activeLicense = StandardLicenses.GPL3
compositeDisposable.add( compositeDisposable.add(
showLicense(NEWPIPE_SOFTWARE_COMPONENT) showLicense(activity, StandardLicenses.GPL3)
) )
} }
for (component in softwareComponents) { for (component in softwareComponents) {
@ -66,72 +57,27 @@ class LicenseFragment : Fragment() {
val root: View = componentBinding.root val root: View = componentBinding.root
root.tag = component root.tag = component
root.setOnClickListener { root.setOnClickListener {
activeLicense = component.license
compositeDisposable.add( compositeDisposable.add(
showLicense(component) showLicense(activity, component)
) )
} }
binding.licensesSoftwareComponents.addView(root) binding.licensesSoftwareComponents.addView(root)
registerForContextMenu(root) registerForContextMenu(root)
} }
activeSoftwareComponent?.let { compositeDisposable.add(showLicense(it)) } activeLicense?.let { compositeDisposable.add(showLicense(activity, it)) }
return binding.root return binding.root
} }
override fun onSaveInstanceState(savedInstanceState: Bundle) { override fun onSaveInstanceState(savedInstanceState: Bundle) {
super.onSaveInstanceState(savedInstanceState) super.onSaveInstanceState(savedInstanceState)
activeSoftwareComponent?.let { savedInstanceState.putSerializable(SOFTWARE_COMPONENT_KEY, it) } activeLicense?.let { savedInstanceState.putSerializable(LICENSE_KEY, it) }
}
private fun showLicense(
softwareComponent: SoftwareComponent
): Disposable {
return if (context == null) {
Disposable.empty()
} else {
val context = requireContext()
activeSoftwareComponent = softwareComponent
Observable.fromCallable { getFormattedLicense(context, softwareComponent.license) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { formattedLicense ->
val webViewData = Base64.encodeToString(
formattedLicense.toByteArray(), Base64.NO_PADDING
)
val webView = WebView(context)
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
Localization.assureCorrectAppLanguage(context)
val builder = AlertDialog.Builder(requireContext())
.setTitle(softwareComponent.name)
.setView(webView)
.setOnCancelListener { activeSoftwareComponent = null }
.setOnDismissListener { activeSoftwareComponent = null }
.setPositiveButton(R.string.done) { dialog, _ -> dialog.dismiss() }
if (softwareComponent != NEWPIPE_SOFTWARE_COMPONENT) {
builder.setNeutralButton(R.string.open_website_license) { _, _ ->
ShareUtils.openUrlInApp(requireContext(), softwareComponent.link)
}
}
builder.show()
}
}
} }
companion object { companion object {
private const val ARG_COMPONENTS = "components" private const val ARG_COMPONENTS = "components"
private const val SOFTWARE_COMPONENT_KEY = "ACTIVE_SOFTWARE_COMPONENT" private const val LICENSE_KEY = "ACTIVE_LICENSE"
private val NEWPIPE_SOFTWARE_COMPONENT = SoftwareComponent( fun newInstance(softwareComponents: Array<SoftwareComponent>): LicenseFragment {
"NewPipe",
"2014-2023",
"Team NewPipe",
"https://newpipe.net/",
StandardLicenses.GPL3,
BuildConfig.VERSION_NAME
)
fun newInstance(softwareComponents: ArrayList<SoftwareComponent>): LicenseFragment {
val fragment = LicenseFragment() val fragment = LicenseFragment()
fragment.arguments = bundleOf(ARG_COMPONENTS to softwareComponents) fragment.arguments = bundleOf(ARG_COMPONENTS to softwareComponents)
return fragment return fragment

View File

@ -1,8 +1,17 @@
package org.schabi.newpipe.about package org.schabi.newpipe.about
import android.content.Context 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.R
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.ThemeHelper import org.schabi.newpipe.util.ThemeHelper
import org.schabi.newpipe.util.external_communication.ShareUtils
import java.io.IOException import java.io.IOException
/** /**
@ -11,7 +20,7 @@ import java.io.IOException
* @return String which contains a HTML formatted license page * @return String which contains a HTML formatted license page
* styled according to the context's theme * styled according to the context's theme
*/ */
fun getFormattedLicense(context: Context, license: License): String { private fun getFormattedLicense(context: Context, license: License): String {
try { try {
return context.assets.open(license.filename).bufferedReader().use { it.readText() } return context.assets.open(license.filename).bufferedReader().use { it.readText() }
// split the HTML file and insert the stylesheet into the HEAD of the file // split the HTML file and insert the stylesheet into the HEAD of the file
@ -25,7 +34,7 @@ fun getFormattedLicense(context: Context, license: License): String {
* @param context the Android context * @param context the Android context
* @return String which is a CSS stylesheet according to the context's theme * @return String which is a CSS stylesheet according to the context's theme
*/ */
fun getLicenseStylesheet(context: Context): String { private fun getLicenseStylesheet(context: Context): String {
val isLightTheme = ThemeHelper.isLightThemeSelected(context) val isLightTheme = ThemeHelper.isLightThemeSelected(context)
val licenseBackgroundColor = getHexRGBColor( val licenseBackgroundColor = getHexRGBColor(
context, if (isLightTheme) R.color.light_license_background_color else R.color.dark_license_background_color context, if (isLightTheme) R.color.light_license_background_color else R.color.dark_license_background_color
@ -47,6 +56,48 @@ fun getLicenseStylesheet(context: Context): String {
* @param color the color number from R.color * @param color the color number from R.color
* @return a six characters long String with hexadecimal RGB values * @return a six characters long String with hexadecimal RGB values
*/ */
fun getHexRGBColor(context: Context, color: Int): String { private fun getHexRGBColor(context: Context, color: Int): String {
return context.getString(color).substring(3) return context.getString(color).substring(3)
} }
fun showLicense(context: Context?, component: SoftwareComponent): Disposable {
return showLicense(context, component.license) {
setPositiveButton(R.string.dismiss) { dialog, _ ->
dialog.dismiss()
}
setNeutralButton(R.string.open_website_license) { _, _ ->
ShareUtils.openUrlInApp(context!!, component.link)
}
}
}
fun showLicense(context: Context?, license: License) = showLicense(context, license) {
setPositiveButton(R.string.ok) { dialog, _ -> dialog.dismiss() }
}
private fun showLicense(
context: Context?,
license: License,
block: AlertDialog.Builder.() -> AlertDialog.Builder
): Disposable {
return if (context == null) {
Disposable.empty()
} else {
Observable.fromCallable { getFormattedLicense(context, license) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { formattedLicense ->
val webViewData =
Base64.encodeToString(formattedLicense.toByteArray(), Base64.NO_PADDING)
val webView = WebView(context)
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
Localization.assureCorrectAppLanguage(context)
AlertDialog.Builder(context)
.setTitle(license.name)
.setView(webView)
.block()
.show()
}
}
}

View File

@ -2,7 +2,6 @@ package org.schabi.newpipe.about
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.io.Serializable
@Parcelize @Parcelize
class SoftwareComponent class SoftwareComponent
@ -14,4 +13,4 @@ constructor(
val link: String, val link: String,
val license: License, val license: License,
val version: String? = null val version: String? = null
) : Parcelable, Serializable ) : Parcelable

View File

@ -1,6 +1,6 @@
package org.schabi.newpipe.database; package org.schabi.newpipe.database;
import static org.schabi.newpipe.database.Migrations.DB_VER_9; import static org.schabi.newpipe.database.Migrations.DB_VER_7;
import androidx.room.Database; import androidx.room.Database;
import androidx.room.RoomDatabase; import androidx.room.RoomDatabase;
@ -38,7 +38,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity;
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class, FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
FeedLastUpdatedEntity.class FeedLastUpdatedEntity.class
}, },
version = DB_VER_9 version = DB_VER_7
) )
public abstract class AppDatabase extends RoomDatabase { public abstract class AppDatabase extends RoomDatabase {
public static final String DATABASE_NAME = "newpipe.db"; public static final String DATABASE_NAME = "newpipe.db";

View File

@ -7,7 +7,7 @@ import java.time.Instant
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
class Converters { object Converters {
/** /**
* Convert a long value to a [OffsetDateTime]. * Convert a long value to a [OffsetDateTime].
* *
@ -47,6 +47,6 @@ class Converters {
@TypeConverter @TypeConverter
fun feedGroupIconOf(id: Int): FeedGroupIcon { fun feedGroupIconOf(id: Int): FeedGroupIcon {
return FeedGroupIcon.entries.first { it.id == id } return FeedGroupIcon.values().first { it.id == id }
} }
} }

View File

@ -25,8 +25,6 @@ public final class Migrations {
public static final int DB_VER_5 = 5; public static final int DB_VER_5 = 5;
public static final int DB_VER_6 = 6; public static final int DB_VER_6 = 6;
public static final int DB_VER_7 = 7; public static final int DB_VER_7 = 7;
public static final int DB_VER_8 = 8;
public static final int DB_VER_9 = 9;
private static final String TAG = Migrations.class.getName(); private static final String TAG = Migrations.class.getName();
public static final boolean DEBUG = MainActivity.DEBUG; public static final boolean DEBUG = MainActivity.DEBUG;
@ -188,7 +186,7 @@ public final class Migrations {
@Override @Override
public void migrate(@NonNull final SupportSQLiteDatabase database) { public void migrate(@NonNull final SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` " database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` "
+ "INTEGER NOT NULL DEFAULT 0"); + "INTEGER NOT NULL DEFAULT 0");
} }
}; };
@ -237,71 +235,6 @@ public final class Migrations {
} }
}; };
public static final Migration MIGRATION_7_8 = new Migration(DB_VER_7, DB_VER_8) {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
database.execSQL("DELETE FROM search_history WHERE id NOT IN (SELECT id FROM (SELECT "
+ "MIN(id) as id FROM search_history GROUP BY trim(search), service_id ) tmp)");
database.execSQL("UPDATE search_history SET search = trim(search)");
}
};
public static final Migration MIGRATION_8_9 = new Migration(DB_VER_8, DB_VER_9) {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
try {
database.beginTransaction();
// Update playlists.
// Create a temp table to initialize display_index.
database.execSQL("CREATE TABLE `playlists_tmp` "
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
+ "`name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, "
+ "`thumbnail_stream_id` INTEGER NOT NULL, "
+ "`display_index` INTEGER NOT NULL)");
database.execSQL("INSERT INTO `playlists_tmp` "
+ "(`uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, "
+ "`display_index`) "
+ "SELECT `uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, "
+ "-1 "
+ "FROM `playlists`");
// Replace the old table, note that this also removes the index on the name which
// we don't need anymore.
database.execSQL("DROP TABLE `playlists`");
database.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`");
// Update remote_playlists.
// Create a temp table to initialize display_index.
database.execSQL("CREATE TABLE `remote_playlists_tmp` "
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
+ "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, "
+ "`thumbnail_url` TEXT, `uploader` TEXT, "
+ "`display_index` INTEGER NOT NULL,"
+ "`stream_count` INTEGER)");
database.execSQL("INSERT INTO `remote_playlists_tmp` (`uid`, `service_id`, "
+ "`name`, `url`, `thumbnail_url`, `uploader`, `display_index`, "
+ "`stream_count`)"
+ "SELECT `uid`, `service_id`, `name`, `url`, `thumbnail_url`, `uploader`, "
+ "-1, `stream_count` FROM `remote_playlists`");
// Replace the old table, note that this also removes the index on the name which
// we don't need anymore.
database.execSQL("DROP TABLE `remote_playlists`");
database.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`");
// Create index on the new table.
database.execSQL("CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` "
+ "ON `remote_playlists` (`service_id`, `url`)");
database.setTransactionSuccessful();
} finally {
database.endTransaction();
}
}
};
private Migrations() { private Migrations() {
} }
} }

View File

@ -93,30 +93,18 @@ abstract class FeedDAO {
uploadDateBefore: OffsetDateTime? uploadDateBefore: OffsetDateTime?
): Maybe<List<StreamWithState>> ): Maybe<List<StreamWithState>>
/**
* Remove links to streams that are older than the given date
* **but keep at least one stream per uploader**.
*
* One stream per uploader is kept because it is needed as reference
* when fetching new streams to check if they are new or not.
* @param offsetDateTime the newest date to keep, older streams are removed
*/
@Query( @Query(
""" """
DELETE FROM feed DELETE FROM feed WHERE
WHERE feed.stream_id IN (SELECT uid from (
SELECT s.uid,
(SELECT MAX(upload_date)
FROM streams s1
INNER JOIN feed f1
ON s1.uid = f1.stream_id
WHERE f1.subscription_id = f.subscription_id) max_upload_date
FROM streams s
INNER JOIN feed f
ON s.uid = f.stream_id
WHERE s.upload_date < :offsetDateTime feed.stream_id IN (
AND s.upload_date <> max_upload_date)) SELECT s.uid FROM streams s
INNER JOIN feed f
ON s.uid = f.stream_id
WHERE s.upload_date < :offsetDateTime
)
""" """
) )
abstract fun unlinkStreamsOlderThan(offsetDateTime: OffsetDateTime) abstract fun unlinkStreamsOlderThan(offsetDateTime: OffsetDateTime)

View File

@ -3,6 +3,7 @@ package org.schabi.newpipe.database.feed.model
import androidx.room.ColumnInfo import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import androidx.room.ForeignKey import androidx.room.ForeignKey
import androidx.room.ForeignKey.CASCADE
import androidx.room.Index import androidx.room.Index
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.FEED_GROUP_SUBSCRIPTION_TABLE import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.FEED_GROUP_SUBSCRIPTION_TABLE
import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.GROUP_ID import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity.Companion.GROUP_ID
@ -18,14 +19,14 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity
entity = FeedGroupEntity::class, entity = FeedGroupEntity::class,
parentColumns = [FeedGroupEntity.ID], parentColumns = [FeedGroupEntity.ID],
childColumns = [GROUP_ID], childColumns = [GROUP_ID],
onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true onDelete = CASCADE, onUpdate = CASCADE, deferred = true
), ),
ForeignKey( ForeignKey(
entity = SubscriptionEntity::class, entity = SubscriptionEntity::class,
parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID], parentColumns = [SubscriptionEntity.SUBSCRIPTION_UID],
childColumns = [SUBSCRIPTION_ID], childColumns = [SUBSCRIPTION_ID],
onDelete = ForeignKey.CASCADE, onUpdate = ForeignKey.CASCADE, deferred = true onDelete = CASCADE, onUpdate = CASCADE, deferred = true
) )
] ]
) )

View File

@ -13,17 +13,12 @@ public class PlaylistDuplicatesEntry extends PlaylistMetadataEntry {
@ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED) @ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED)
public final long timesStreamIsContained; public final long timesStreamIsContained;
@SuppressWarnings("checkstyle:ParameterNumber")
public PlaylistDuplicatesEntry(final long uid, public PlaylistDuplicatesEntry(final long uid,
final String name, final String name,
final String thumbnailUrl, final String thumbnailUrl,
final boolean isThumbnailPermanent,
final long thumbnailStreamId,
final long displayIndex,
final long streamCount, final long streamCount,
final long timesStreamIsContained) { final long timesStreamIsContained) {
super(uid, name, thumbnailUrl, isThumbnailPermanent, thumbnailStreamId, displayIndex, super(uid, name, thumbnailUrl, streamCount);
streamCount);
this.timesStreamIsContained = timesStreamIsContained; this.timesStreamIsContained = timesStreamIsContained;
} }
} }

View File

@ -1,13 +1,22 @@
package org.schabi.newpipe.database.playlist; package org.schabi.newpipe.database.playlist;
import org.schabi.newpipe.database.LocalItem; import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public interface PlaylistLocalItem extends LocalItem { public interface PlaylistLocalItem extends LocalItem {
String getOrderingName(); String getOrderingName();
long getDisplayIndex(); static List<PlaylistLocalItem> merge(
final List<PlaylistMetadataEntry> localPlaylists,
long getUid(); final List<PlaylistRemoteEntity> remotePlaylists) {
return Stream.concat(localPlaylists.stream(), remotePlaylists.stream())
void setDisplayIndex(long displayIndex); .sorted(Comparator.comparing(PlaylistLocalItem::getOrderingName,
Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)))
.collect(Collectors.toList());
}
} }

View File

@ -2,40 +2,27 @@ package org.schabi.newpipe.database.playlist;
import androidx.room.ColumnInfo; import androidx.room.ColumnInfo;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_DISPLAY_INDEX;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL;
public class PlaylistMetadataEntry implements PlaylistLocalItem { public class PlaylistMetadataEntry implements PlaylistLocalItem {
public static final String PLAYLIST_STREAM_COUNT = "streamCount"; public static final String PLAYLIST_STREAM_COUNT = "streamCount";
@ColumnInfo(name = PLAYLIST_ID) @ColumnInfo(name = PLAYLIST_ID)
private final long uid; public final long uid;
@ColumnInfo(name = PLAYLIST_NAME) @ColumnInfo(name = PLAYLIST_NAME)
public final String name; public final String name;
@ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT)
private final boolean isThumbnailPermanent;
@ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID)
private final long thumbnailStreamId;
@ColumnInfo(name = PLAYLIST_THUMBNAIL_URL) @ColumnInfo(name = PLAYLIST_THUMBNAIL_URL)
public final String thumbnailUrl; public final String thumbnailUrl;
@ColumnInfo(name = PLAYLIST_DISPLAY_INDEX)
private long displayIndex;
@ColumnInfo(name = PLAYLIST_STREAM_COUNT) @ColumnInfo(name = PLAYLIST_STREAM_COUNT)
public final long streamCount; public final long streamCount;
public PlaylistMetadataEntry(final long uid, final String name, final String thumbnailUrl, public PlaylistMetadataEntry(final long uid, final String name, final String thumbnailUrl,
final boolean isThumbnailPermanent, final long thumbnailStreamId, final long streamCount) {
final long displayIndex, final long streamCount) {
this.uid = uid; this.uid = uid;
this.name = name; this.name = name;
this.thumbnailUrl = thumbnailUrl; this.thumbnailUrl = thumbnailUrl;
this.isThumbnailPermanent = isThumbnailPermanent;
this.thumbnailStreamId = thumbnailStreamId;
this.displayIndex = displayIndex;
this.streamCount = streamCount; this.streamCount = streamCount;
} }
@ -48,27 +35,4 @@ public class PlaylistMetadataEntry implements PlaylistLocalItem {
public String getOrderingName() { public String getOrderingName() {
return name; return name;
} }
public boolean isThumbnailPermanent() {
return isThumbnailPermanent;
}
public long getThumbnailStreamId() {
return thumbnailStreamId;
}
@Override
public long getDisplayIndex() {
return displayIndex;
}
@Override
public long getUid() {
return uid;
}
@Override
public void setDisplayIndex(final long displayIndex) {
this.displayIndex = displayIndex;
}
} }

View File

@ -7,7 +7,6 @@ import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamStateEntity import org.schabi.newpipe.database.stream.model.StreamStateEntity
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.util.image.ImageStrategy
data class PlaylistStreamEntry( data class PlaylistStreamEntry(
@Embedded @Embedded
@ -29,7 +28,7 @@ data class PlaylistStreamEntry(
item.duration = streamEntity.duration item.duration = streamEntity.duration
item.uploaderName = streamEntity.uploader item.uploaderName = streamEntity.uploader
item.uploaderUrl = streamEntity.uploaderUrl item.uploaderUrl = streamEntity.uploaderUrl
item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) item.thumbnailUrl = streamEntity.thumbnailUrl
return item return item
} }

View File

@ -2,7 +2,6 @@ package org.schabi.newpipe.database.playlist.dao;
import androidx.room.Dao; import androidx.room.Dao;
import androidx.room.Query; import androidx.room.Query;
import androidx.room.Transaction;
import org.schabi.newpipe.database.BasicDAO; import org.schabi.newpipe.database.BasicDAO;
import org.schabi.newpipe.database.playlist.model.PlaylistEntity; import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
@ -37,17 +36,4 @@ public interface PlaylistDAO extends BasicDAO<PlaylistEntity> {
@Query("SELECT COUNT(*) FROM " + PLAYLIST_TABLE) @Query("SELECT COUNT(*) FROM " + PLAYLIST_TABLE)
Flowable<Long> getCount(); Flowable<Long> getCount();
@Transaction
default long upsertPlaylist(final PlaylistEntity playlist) {
final long playlistId = playlist.getUid();
if (playlistId == -1) {
// This situation is probably impossible.
return insert(playlist);
} else {
update(playlist);
return playlistId;
}
}
} }

View File

@ -11,7 +11,6 @@ import java.util.List;
import io.reactivex.rxjava3.core.Flowable; import io.reactivex.rxjava3.core.Flowable;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_DISPLAY_INDEX;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_ID; import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID; import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE; import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE;
@ -32,18 +31,10 @@ public interface PlaylistRemoteDAO extends BasicDAO<PlaylistRemoteEntity> {
+ " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") + " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
Flowable<List<PlaylistRemoteEntity>> listByService(int serviceId); Flowable<List<PlaylistRemoteEntity>> listByService(int serviceId);
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
+ REMOTE_PLAYLIST_ID + " = :playlistId")
Flowable<List<PlaylistRemoteEntity>> getPlaylist(long playlistId);
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE " @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
+ REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") + REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
Flowable<List<PlaylistRemoteEntity>> getPlaylist(long serviceId, String url); Flowable<List<PlaylistRemoteEntity>> getPlaylist(long serviceId, String url);
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE
+ " ORDER BY " + REMOTE_PLAYLIST_DISPLAY_INDEX)
Flowable<List<PlaylistRemoteEntity>> getPlaylists();
@Query("SELECT " + REMOTE_PLAYLIST_ID + " FROM " + REMOTE_PLAYLIST_TABLE @Query("SELECT " + REMOTE_PLAYLIST_ID + " FROM " + REMOTE_PLAYLIST_TABLE
+ " WHERE " + REMOTE_PLAYLIST_URL + " = :url " + " WHERE " + REMOTE_PLAYLIST_URL + " = :url "
+ "AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId") + "AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")

View File

@ -18,12 +18,10 @@ import io.reactivex.rxjava3.core.Flowable;
import static org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry.PLAYLIST_TIMES_STREAM_IS_CONTAINED; import static org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry.PLAYLIST_TIMES_STREAM_IS_CONTAINED;
import static org.schabi.newpipe.database.playlist.PlaylistMetadataEntry.PLAYLIST_STREAM_COUNT; import static org.schabi.newpipe.database.playlist.PlaylistMetadataEntry.PLAYLIST_STREAM_COUNT;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_DISPLAY_INDEX;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.DEFAULT_THUMBNAIL; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.DEFAULT_THUMBNAIL;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL;
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_INDEX; import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_INDEX;
@ -93,9 +91,7 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
Flowable<List<PlaylistStreamEntry>> getOrderedStreamsOf(long playlistId); Flowable<List<PlaylistStreamEntry>> getOrderedStreamsOf(long playlistId);
@Transaction @Transaction
@Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", " @Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ","
+ PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", "
+ PLAYLIST_DISPLAY_INDEX + ", "
+ " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = " + " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = "
+ PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'" + PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'"
@ -109,7 +105,7 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
+ " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID + " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID
+ " GROUP BY " + PLAYLIST_ID + " GROUP BY " + PLAYLIST_ID
+ " ORDER BY " + PLAYLIST_DISPLAY_INDEX) + " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC")
Flowable<List<PlaylistMetadataEntry>> getPlaylistMetadata(); Flowable<List<PlaylistMetadataEntry>> getPlaylistMetadata();
@RewriteQueriesToDropUnusedColumns @RewriteQueriesToDropUnusedColumns
@ -130,9 +126,8 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
Flowable<List<PlaylistStreamEntry>> getStreamsWithoutDuplicates(long playlistId); Flowable<List<PlaylistStreamEntry>> getStreamsWithoutDuplicates(long playlistId);
@Transaction @Transaction
@Query("SELECT " + PLAYLIST_TABLE + "." + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", " @Query("SELECT " + PLAYLIST_TABLE + "." + PLAYLIST_ID + ", "
+ PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", " + PLAYLIST_NAME + ", "
+ PLAYLIST_DISPLAY_INDEX + ", "
+ " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = " + " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = "
+ PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'" + PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'"
@ -154,6 +149,6 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
+ " AND :streamUrl = :streamUrl" + " AND :streamUrl = :streamUrl"
+ " GROUP BY " + JOIN_PLAYLIST_ID + " GROUP BY " + JOIN_PLAYLIST_ID
+ " ORDER BY " + PLAYLIST_DISPLAY_INDEX + ", " + PLAYLIST_NAME) + " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC")
Flowable<List<PlaylistDuplicatesEntry>> getPlaylistDuplicatesMetadata(String streamUrl); Flowable<List<PlaylistDuplicatesEntry>> getPlaylistDuplicatesMetadata(String streamUrl);
} }

View File

@ -2,15 +2,16 @@ package org.schabi.newpipe.database.playlist.model;
import androidx.room.ColumnInfo; import androidx.room.ColumnInfo;
import androidx.room.Entity; import androidx.room.Entity;
import androidx.room.Ignore; import androidx.room.Index;
import androidx.room.PrimaryKey; import androidx.room.PrimaryKey;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE; import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
@Entity(tableName = PLAYLIST_TABLE) @Entity(tableName = PLAYLIST_TABLE,
indices = {@Index(value = {PLAYLIST_NAME})})
public class PlaylistEntity { public class PlaylistEntity {
public static final String DEFAULT_THUMBNAIL = "drawable://" public static final String DEFAULT_THUMBNAIL = "drawable://"
@ -21,7 +22,6 @@ public class PlaylistEntity {
public static final String PLAYLIST_ID = "uid"; public static final String PLAYLIST_ID = "uid";
public static final String PLAYLIST_NAME = "name"; public static final String PLAYLIST_NAME = "name";
public static final String PLAYLIST_THUMBNAIL_URL = "thumbnail_url"; public static final String PLAYLIST_THUMBNAIL_URL = "thumbnail_url";
public static final String PLAYLIST_DISPLAY_INDEX = "display_index";
public static final String PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent"; public static final String PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent";
public static final String PLAYLIST_THUMBNAIL_STREAM_ID = "thumbnail_stream_id"; public static final String PLAYLIST_THUMBNAIL_STREAM_ID = "thumbnail_stream_id";
@ -38,24 +38,11 @@ public class PlaylistEntity {
@ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID) @ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID)
private long thumbnailStreamId; private long thumbnailStreamId;
@ColumnInfo(name = PLAYLIST_DISPLAY_INDEX)
private long displayIndex;
public PlaylistEntity(final String name, final boolean isThumbnailPermanent, public PlaylistEntity(final String name, final boolean isThumbnailPermanent,
final long thumbnailStreamId, final long displayIndex) { final long thumbnailStreamId) {
this.name = name; this.name = name;
this.isThumbnailPermanent = isThumbnailPermanent; this.isThumbnailPermanent = isThumbnailPermanent;
this.thumbnailStreamId = thumbnailStreamId; this.thumbnailStreamId = thumbnailStreamId;
this.displayIndex = displayIndex;
}
@Ignore
public PlaylistEntity(final PlaylistMetadataEntry item) {
this.uid = item.getUid();
this.name = item.name;
this.isThumbnailPermanent = item.isThumbnailPermanent();
this.thumbnailStreamId = item.getThumbnailStreamId();
this.displayIndex = item.getDisplayIndex();
} }
public long getUid() { public long getUid() {
@ -90,11 +77,4 @@ public class PlaylistEntity {
this.isThumbnailPermanent = isThumbnailSet; this.isThumbnailPermanent = isThumbnailSet;
} }
public long getDisplayIndex() {
return displayIndex;
}
public void setDisplayIndex(final long displayIndex) {
this.displayIndex = displayIndex;
}
} }

View File

@ -11,7 +11,6 @@ import androidx.room.PrimaryKey;
import org.schabi.newpipe.database.playlist.PlaylistLocalItem; import org.schabi.newpipe.database.playlist.PlaylistLocalItem;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.image.ImageStrategy;
import static org.schabi.newpipe.database.LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM; import static org.schabi.newpipe.database.LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_NAME; import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_NAME;
@ -21,6 +20,7 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.RE
@Entity(tableName = REMOTE_PLAYLIST_TABLE, @Entity(tableName = REMOTE_PLAYLIST_TABLE,
indices = { indices = {
@Index(value = {REMOTE_PLAYLIST_NAME}),
@Index(value = {REMOTE_PLAYLIST_SERVICE_ID, REMOTE_PLAYLIST_URL}, unique = true) @Index(value = {REMOTE_PLAYLIST_SERVICE_ID, REMOTE_PLAYLIST_URL}, unique = true)
}) })
public class PlaylistRemoteEntity implements PlaylistLocalItem { public class PlaylistRemoteEntity implements PlaylistLocalItem {
@ -31,7 +31,6 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
public static final String REMOTE_PLAYLIST_URL = "url"; public static final String REMOTE_PLAYLIST_URL = "url";
public static final String REMOTE_PLAYLIST_THUMBNAIL_URL = "thumbnail_url"; public static final String REMOTE_PLAYLIST_THUMBNAIL_URL = "thumbnail_url";
public static final String REMOTE_PLAYLIST_UPLOADER_NAME = "uploader"; public static final String REMOTE_PLAYLIST_UPLOADER_NAME = "uploader";
public static final String REMOTE_PLAYLIST_DISPLAY_INDEX = "display_index";
public static final String REMOTE_PLAYLIST_STREAM_COUNT = "stream_count"; public static final String REMOTE_PLAYLIST_STREAM_COUNT = "stream_count";
@PrimaryKey(autoGenerate = true) @PrimaryKey(autoGenerate = true)
@ -53,9 +52,6 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
@ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME) @ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME)
private String uploader; private String uploader;
@ColumnInfo(name = REMOTE_PLAYLIST_DISPLAY_INDEX)
private long displayIndex = -1; // Make sure the new item is on the top
@ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT) @ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT)
private Long streamCount; private Long streamCount;
@ -70,25 +66,11 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
this.streamCount = streamCount; this.streamCount = streamCount;
} }
@Ignore
public PlaylistRemoteEntity(final int serviceId, final String name, final String url,
final String thumbnailUrl, final String uploader,
final long displayIndex, final Long streamCount) {
this.serviceId = serviceId;
this.name = name;
this.url = url;
this.thumbnailUrl = thumbnailUrl;
this.uploader = uploader;
this.displayIndex = displayIndex;
this.streamCount = streamCount;
}
@Ignore @Ignore
public PlaylistRemoteEntity(final PlaylistInfo info) { public PlaylistRemoteEntity(final PlaylistInfo info) {
this(info.getServiceId(), info.getName(), info.getUrl(), this(info.getServiceId(), info.getName(), info.getUrl(),
// use uploader avatar when no thumbnail is available info.getThumbnailUrl() == null
ImageStrategy.imageListToDbUrl(info.getThumbnails().isEmpty() ? info.getUploaderAvatarUrl() : info.getThumbnailUrl(),
? info.getUploaderAvatars() : info.getThumbnails()),
info.getUploaderName(), info.getStreamCount()); info.getUploaderName(), info.getStreamCount());
} }
@ -102,14 +84,10 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
&& getStreamCount() == info.getStreamCount() && getStreamCount() == info.getStreamCount()
&& TextUtils.equals(getName(), info.getName()) && TextUtils.equals(getName(), info.getName())
&& TextUtils.equals(getUrl(), info.getUrl()) && TextUtils.equals(getUrl(), info.getUrl())
// we want to update the local playlist data even when either the remote thumbnail && TextUtils.equals(getThumbnailUrl(), info.getThumbnailUrl())
// URL changes, or the preferred image quality setting is changed by the user
&& TextUtils.equals(getThumbnailUrl(),
ImageStrategy.imageListToDbUrl(info.getThumbnails()))
&& TextUtils.equals(getUploader(), info.getUploaderName()); && TextUtils.equals(getUploader(), info.getUploaderName());
} }
@Override
public long getUid() { public long getUid() {
return uid; return uid;
} }
@ -158,16 +136,6 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
this.uploader = uploader; this.uploader = uploader;
} }
@Override
public long getDisplayIndex() {
return displayIndex;
}
@Override
public void setDisplayIndex(final long displayIndex) {
this.displayIndex = displayIndex;
}
public Long getStreamCount() { public Long getStreamCount() {
return streamCount; return streamCount;
} }

View File

@ -7,7 +7,6 @@ import org.schabi.newpipe.database.history.model.StreamHistoryEntity
import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS import org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.util.image.ImageStrategy
import java.time.OffsetDateTime import java.time.OffsetDateTime
class StreamStatisticsEntry( class StreamStatisticsEntry(
@ -31,7 +30,7 @@ class StreamStatisticsEntry(
item.duration = streamEntity.duration item.duration = streamEntity.duration
item.uploaderName = streamEntity.uploader item.uploaderName = streamEntity.uploader
item.uploaderUrl = streamEntity.uploaderUrl item.uploaderUrl = streamEntity.uploaderUrl
item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl) item.thumbnailUrl = streamEntity.thumbnailUrl
return item return item
} }

View File

@ -13,7 +13,6 @@ import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.StreamType import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.player.playqueue.PlayQueueItem import org.schabi.newpipe.player.playqueue.PlayQueueItem
import org.schabi.newpipe.util.image.ImageStrategy
import java.io.Serializable import java.io.Serializable
import java.time.OffsetDateTime import java.time.OffsetDateTime
@ -68,8 +67,7 @@ data class StreamEntity(
constructor(item: StreamInfoItem) : this( constructor(item: StreamInfoItem) : this(
serviceId = item.serviceId, url = item.url, title = item.name, serviceId = item.serviceId, url = item.url, title = item.name,
streamType = item.streamType, duration = item.duration, uploader = item.uploaderName, streamType = item.streamType, duration = item.duration, uploader = item.uploaderName,
uploaderUrl = item.uploaderUrl, uploaderUrl = item.uploaderUrl, thumbnailUrl = item.thumbnailUrl, viewCount = item.viewCount,
thumbnailUrl = ImageStrategy.imageListToDbUrl(item.thumbnails), viewCount = item.viewCount,
textualUploadDate = item.textualUploadDate, uploadDate = item.uploadDate?.offsetDateTime(), textualUploadDate = item.textualUploadDate, uploadDate = item.uploadDate?.offsetDateTime(),
isUploadDateApproximation = item.uploadDate?.isApproximation isUploadDateApproximation = item.uploadDate?.isApproximation
) )
@ -78,8 +76,7 @@ data class StreamEntity(
constructor(info: StreamInfo) : this( constructor(info: StreamInfo) : this(
serviceId = info.serviceId, url = info.url, title = info.name, serviceId = info.serviceId, url = info.url, title = info.name,
streamType = info.streamType, duration = info.duration, uploader = info.uploaderName, streamType = info.streamType, duration = info.duration, uploader = info.uploaderName,
uploaderUrl = info.uploaderUrl, uploaderUrl = info.uploaderUrl, thumbnailUrl = info.thumbnailUrl, viewCount = info.viewCount,
thumbnailUrl = ImageStrategy.imageListToDbUrl(info.thumbnails), viewCount = info.viewCount,
textualUploadDate = info.textualUploadDate, uploadDate = info.uploadDate?.offsetDateTime(), textualUploadDate = info.textualUploadDate, uploadDate = info.uploadDate?.offsetDateTime(),
isUploadDateApproximation = info.uploadDate?.isApproximation isUploadDateApproximation = info.uploadDate?.isApproximation
) )
@ -88,8 +85,7 @@ data class StreamEntity(
constructor(item: PlayQueueItem) : this( constructor(item: PlayQueueItem) : this(
serviceId = item.serviceId, url = item.url, title = item.title, serviceId = item.serviceId, url = item.url, title = item.title,
streamType = item.streamType, duration = item.duration, uploader = item.uploader, streamType = item.streamType, duration = item.duration, uploader = item.uploader,
uploaderUrl = item.uploaderUrl, uploaderUrl = item.uploaderUrl, thumbnailUrl = item.thumbnailUrl
thumbnailUrl = ImageStrategy.imageListToDbUrl(item.thumbnails)
) )
fun toStreamInfoItem(): StreamInfoItem { fun toStreamInfoItem(): StreamInfoItem {
@ -97,7 +93,7 @@ data class StreamEntity(
item.duration = duration item.duration = duration
item.uploaderName = uploader item.uploaderName = uploader
item.uploaderUrl = uploaderUrl item.uploaderUrl = uploaderUrl
item.thumbnails = ImageStrategy.dbUrlToImageList(thumbnailUrl) item.thumbnailUrl = thumbnailUrl
if (viewCount != null) item.viewCount = viewCount as Long if (viewCount != null) item.viewCount = viewCount as Long
item.textualUploadDate = textualUploadDate item.textualUploadDate = textualUploadDate

View File

@ -1,7 +1,6 @@
package org.schabi.newpipe.database.subscription; package org.schabi.newpipe.database.subscription;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.room.ColumnInfo; import androidx.room.ColumnInfo;
import androidx.room.Entity; import androidx.room.Entity;
import androidx.room.Ignore; import androidx.room.Ignore;
@ -11,7 +10,6 @@ import androidx.room.PrimaryKey;
import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.image.ImageStrategy;
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID; import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID;
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE; import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE;
@ -59,8 +57,8 @@ public class SubscriptionEntity {
final SubscriptionEntity result = new SubscriptionEntity(); final SubscriptionEntity result = new SubscriptionEntity();
result.setServiceId(info.getServiceId()); result.setServiceId(info.getServiceId());
result.setUrl(info.getUrl()); result.setUrl(info.getUrl());
result.setData(info.getName(), ImageStrategy.imageListToDbUrl(info.getAvatars()), result.setData(info.getName(), info.getAvatarUrl(), info.getDescription(),
info.getDescription(), info.getSubscriberCount()); info.getSubscriberCount());
return result; return result;
} }
@ -96,12 +94,11 @@ public class SubscriptionEntity {
this.name = name; this.name = name;
} }
@Nullable
public String getAvatarUrl() { public String getAvatarUrl() {
return avatarUrl; return avatarUrl;
} }
public void setAvatarUrl(@Nullable final String avatarUrl) { public void setAvatarUrl(final String avatarUrl) {
this.avatarUrl = avatarUrl; this.avatarUrl = avatarUrl;
} }
@ -141,7 +138,7 @@ public class SubscriptionEntity {
@Ignore @Ignore
public ChannelInfoItem toChannelInfoItem() { public ChannelInfoItem toChannelInfoItem() {
final ChannelInfoItem item = new ChannelInfoItem(getServiceId(), getUrl(), getName()); final ChannelInfoItem item = new ChannelInfoItem(getServiceId(), getUrl(), getName());
item.setThumbnails(ImageStrategy.dbUrlToImageList(getAvatarUrl())); item.setThumbnailUrl(getAvatarUrl());
item.setSubscriberCount(getSubscriberCount()); item.setSubscriberCount(getSubscriberCount());
item.setDescription(getDescription()); item.setDescription(getDescription());
return item; return item;

View File

@ -7,6 +7,8 @@ import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.app.Activity; import android.app.Activity;
import android.content.ComponentName; import android.content.ComponentName;
import android.content.Context; import android.content.Context;
import android.content.DialogInterface;
import android.content.DialogInterface.OnDismissListener;
import android.content.Intent; import android.content.Intent;
import android.content.ServiceConnection; import android.content.ServiceConnection;
import android.content.SharedPreferences; import android.content.SharedPreferences;
@ -14,7 +16,6 @@ import android.net.Uri;
import android.os.Bundle; import android.os.Bundle;
import android.os.Environment; import android.os.Environment;
import android.os.IBinder; import android.os.IBinder;
import android.provider.Settings;
import android.util.Log; import android.util.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
@ -39,8 +40,6 @@ import androidx.documentfile.provider.DocumentFile;
import androidx.fragment.app.DialogFragment; import androidx.fragment.app.DialogFragment;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
import com.evernote.android.state.State;
import com.livefront.bridge.Bridge;
import com.nononsenseapps.filepicker.Utils; import com.nononsenseapps.filepicker.Utils;
import org.schabi.newpipe.MainActivity; import org.schabi.newpipe.MainActivity;
@ -61,8 +60,6 @@ import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard; import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard;
import org.schabi.newpipe.streams.io.StoredDirectoryHelper; import org.schabi.newpipe.streams.io.StoredDirectoryHelper;
import org.schabi.newpipe.streams.io.StoredFileHelper; import org.schabi.newpipe.streams.io.StoredFileHelper;
import org.schabi.newpipe.util.AudioTrackAdapter;
import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper;
import org.schabi.newpipe.util.FilePickerActivityHelper; import org.schabi.newpipe.util.FilePickerActivityHelper;
import org.schabi.newpipe.util.FilenameUtils; import org.schabi.newpipe.util.FilenameUtils;
import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.ListHelper;
@ -70,17 +67,20 @@ import org.schabi.newpipe.util.PermissionHelper;
import org.schabi.newpipe.util.SecondaryStreamHelper; import org.schabi.newpipe.util.SecondaryStreamHelper;
import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener; import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener;
import org.schabi.newpipe.util.StreamItemAdapter; import org.schabi.newpipe.util.StreamItemAdapter;
import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper; import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper;
import org.schabi.newpipe.util.AudioTrackAdapter;
import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper;
import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.ThemeHelper;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import icepick.Icepick;
import icepick.State;
import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.CompositeDisposable;
import us.shandian.giga.get.MissionRecoveryInfo; import us.shandian.giga.get.MissionRecoveryInfo;
import us.shandian.giga.postprocessing.Postprocessing; import us.shandian.giga.postprocessing.Postprocessing;
@ -97,9 +97,9 @@ public class DownloadDialog extends DialogFragment
@State @State
StreamInfo currentInfo; StreamInfo currentInfo;
@State @State
StreamInfoWrapper<VideoStream> wrappedVideoStreams; StreamSizeWrapper<VideoStream> wrappedVideoStreams;
@State @State
StreamInfoWrapper<SubtitlesStream> wrappedSubtitleStreams; StreamSizeWrapper<SubtitlesStream> wrappedSubtitleStreams;
@State @State
AudioTracksWrapper wrappedAudioTracks; AudioTracksWrapper wrappedAudioTracks;
@State @State
@ -111,11 +111,14 @@ public class DownloadDialog extends DialogFragment
@State @State
int selectedSubtitleIndex = 0; // default to the first item int selectedSubtitleIndex = 0; // default to the first item
@Nullable
private OnDismissListener onDismissListener = null;
private StoredDirectoryHelper mainStorageAudio = null; private StoredDirectoryHelper mainStorageAudio = null;
private StoredDirectoryHelper mainStorageVideo = null; private StoredDirectoryHelper mainStorageVideo = null;
private DownloadManager downloadManager = null; private DownloadManager downloadManager = null;
private ActionMenuItemView okButton = null; private ActionMenuItemView okButton = null;
private Context context = null; private Context context;
private boolean askForSavePath; private boolean askForSavePath;
private AudioTrackAdapter audioTrackAdapter; private AudioTrackAdapter audioTrackAdapter;
@ -143,6 +146,7 @@ public class DownloadDialog extends DialogFragment
registerForActivityResult( registerForActivityResult(
new StartActivityForResult(), this::requestDownloadPickVideoFolderResult); new StartActivityForResult(), this::requestDownloadPickVideoFolderResult);
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Instance creation // Instance creation
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@ -183,13 +187,20 @@ public class DownloadDialog extends DialogFragment
wrappedAudioTracks.size() > 1 wrappedAudioTracks.size() > 1
); );
this.wrappedVideoStreams = new StreamInfoWrapper<>(videoStreams, context); this.wrappedVideoStreams = new StreamSizeWrapper<>(videoStreams, context);
this.wrappedSubtitleStreams = new StreamInfoWrapper<>( this.wrappedSubtitleStreams = new StreamSizeWrapper<>(
getStreamsOfSpecifiedDelivery(info.getSubtitles(), PROGRESSIVE_HTTP), context); getStreamsOfSpecifiedDelivery(info.getSubtitles(), PROGRESSIVE_HTTP), context);
this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams); this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams);
} }
/**
* @param onDismissListener the listener to call in {@link #onDismiss(DialogInterface)}
*/
public void setOnDismissListener(@Nullable final OnDismissListener onDismissListener) {
this.onDismissListener = onDismissListener;
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Android lifecycle // Android lifecycle
@ -209,12 +220,10 @@ public class DownloadDialog extends DialogFragment
return; return;
} }
// context will remain null if dismiss() was called above, allowing to check whether the
// dialog is being dismissed in onViewCreated()
context = getContext(); context = getContext();
setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(context)); setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(context));
Bridge.restoreInstanceState(this, savedInstanceState); Icepick.restoreInstanceState(this, savedInstanceState);
this.audioTrackAdapter = new AudioTrackAdapter(wrappedAudioTracks); this.audioTrackAdapter = new AudioTrackAdapter(wrappedAudioTracks);
this.subtitleStreamsAdapter = new StreamItemAdapter<>(wrappedSubtitleStreams); this.subtitleStreamsAdapter = new StreamItemAdapter<>(wrappedSubtitleStreams);
@ -249,17 +258,17 @@ public class DownloadDialog extends DialogFragment
* Update the displayed video streams based on the selected audio track. * Update the displayed video streams based on the selected audio track.
*/ */
private void updateSecondaryStreams() { private void updateSecondaryStreams() {
final StreamInfoWrapper<AudioStream> audioStreams = getWrappedAudioStreams(); final StreamSizeWrapper<AudioStream> audioStreams = getWrappedAudioStreams();
final var secondaryStreams = new SparseArrayCompat<SecondaryStreamHelper<AudioStream>>(4); final var secondaryStreams = new SparseArrayCompat<SecondaryStreamHelper<AudioStream>>(4);
final List<VideoStream> videoStreams = wrappedVideoStreams.getStreamsList(); final List<VideoStream> videoStreams = wrappedVideoStreams.getStreamsList();
wrappedVideoStreams.resetInfo(); wrappedVideoStreams.resetSizes();
for (int i = 0; i < videoStreams.size(); i++) { for (int i = 0; i < videoStreams.size(); i++) {
if (!videoStreams.get(i).isVideoOnly()) { if (!videoStreams.get(i).isVideoOnly()) {
continue; continue;
} }
final AudioStream audioStream = SecondaryStreamHelper.getAudioStreamFor( final AudioStream audioStream = SecondaryStreamHelper
context, audioStreams.getStreamsList(), videoStreams.get(i)); .getAudioStreamFor(audioStreams.getStreamsList(), videoStreams.get(i));
if (audioStream != null) { if (audioStream != null) {
secondaryStreams.append(i, new SecondaryStreamHelper<>(audioStreams, audioStream)); secondaryStreams.append(i, new SecondaryStreamHelper<>(audioStreams, audioStream));
@ -295,9 +304,6 @@ public class DownloadDialog extends DialogFragment
@Nullable final Bundle savedInstanceState) { @Nullable final Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState); super.onViewCreated(view, savedInstanceState);
dialogBinding = DownloadDialogBinding.bind(view); dialogBinding = DownloadDialogBinding.bind(view);
if (context == null) {
return; // the dialog is being dismissed, see the call to dismiss() in onCreate()
}
dialogBinding.fileName.setText(FilenameUtils.createFilename(getContext(), dialogBinding.fileName.setText(FilenameUtils.createFilename(getContext(),
currentInfo.getName())); currentInfo.getName()));
@ -307,7 +313,6 @@ public class DownloadDialog extends DialogFragment
selectedSubtitleIndex = getSubtitleIndexBy(subtitleStreamsAdapter.getAll()); selectedSubtitleIndex = getSubtitleIndexBy(subtitleStreamsAdapter.getAll());
dialogBinding.qualitySpinner.setOnItemSelectedListener(this); dialogBinding.qualitySpinner.setOnItemSelectedListener(this);
dialogBinding.audioStreamSpinner.setOnItemSelectedListener(this);
dialogBinding.audioTrackSpinner.setOnItemSelectedListener(this); dialogBinding.audioTrackSpinner.setOnItemSelectedListener(this);
dialogBinding.videoAudioGroup.setOnCheckedChangeListener(this); dialogBinding.videoAudioGroup.setOnCheckedChangeListener(this);
@ -357,6 +362,14 @@ public class DownloadDialog extends DialogFragment
}); });
} }
@Override
public void onDismiss(@NonNull final DialogInterface dialog) {
super.onDismiss(dialog);
if (onDismissListener != null) {
onDismissListener.onDismiss(dialog);
}
}
@Override @Override
public void onDestroy() { public void onDestroy() {
super.onDestroy(); super.onDestroy();
@ -372,7 +385,7 @@ public class DownloadDialog extends DialogFragment
@Override @Override
public void onSaveInstanceState(@NonNull final Bundle outState) { public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState); super.onSaveInstanceState(outState);
Bridge.saveInstanceState(this, outState); Icepick.saveInstanceState(this, outState);
} }
@ -382,7 +395,7 @@ public class DownloadDialog extends DialogFragment
private void fetchStreamsSize() { private void fetchStreamsSize() {
disposables.clear(); disposables.clear();
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedVideoStreams) disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedVideoStreams)
.subscribe(result -> { .subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
== R.id.video_button) { == R.id.video_button) {
@ -392,7 +405,7 @@ public class DownloadDialog extends DialogFragment
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
"Downloading video stream size", "Downloading video stream size",
currentInfo.getServiceId())))); currentInfo.getServiceId()))));
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(getWrappedAudioStreams()) disposables.add(StreamSizeWrapper.fetchSizeForWrapper(getWrappedAudioStreams())
.subscribe(result -> { .subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
== R.id.audio_button) { == R.id.audio_button) {
@ -402,7 +415,7 @@ public class DownloadDialog extends DialogFragment
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
"Downloading audio stream size", "Downloading audio stream size",
currentInfo.getServiceId())))); currentInfo.getServiceId()))));
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedSubtitleStreams) disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedSubtitleStreams)
.subscribe(result -> { .subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
== R.id.subtitle_button) { == R.id.subtitle_button) {
@ -550,6 +563,7 @@ public class DownloadDialog extends DialogFragment
} }
} }
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Listeners // Listeners
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@ -709,9 +723,9 @@ public class DownloadDialog extends DialogFragment
dialogBinding.subtitleButton.setEnabled(enabled); dialogBinding.subtitleButton.setEnabled(enabled);
} }
private StreamInfoWrapper<AudioStream> getWrappedAudioStreams() { private StreamSizeWrapper<AudioStream> getWrappedAudioStreams() {
if (selectedAudioTrackIndex < 0 || selectedAudioTrackIndex > wrappedAudioTracks.size()) { if (selectedAudioTrackIndex < 0 || selectedAudioTrackIndex > wrappedAudioTracks.size()) {
return StreamInfoWrapper.empty(); return StreamSizeWrapper.empty();
} }
return wrappedAudioTracks.getTracksList().get(selectedAudioTrackIndex); return wrappedAudioTracks.getTracksList().get(selectedAudioTrackIndex);
} }
@ -751,7 +765,7 @@ public class DownloadDialog extends DialogFragment
} }
private void showFailedDialog(@StringRes final int msg) { private void showFailedDialog(@StringRes final int msg) {
assureCorrectAppLanguage(requireContext()); assureCorrectAppLanguage(getContext());
new AlertDialog.Builder(context) new AlertDialog.Builder(context)
.setTitle(R.string.general_error) .setTitle(R.string.general_error)
.setMessage(msg) .setMessage(msg)
@ -768,7 +782,6 @@ public class DownloadDialog extends DialogFragment
final StoredDirectoryHelper mainStorage; final StoredDirectoryHelper mainStorage;
final MediaFormat format; final MediaFormat format;
final String selectedMediaType; final String selectedMediaType;
final long size;
// first, build the filename and get the output folder (if possible) // first, build the filename and get the output folder (if possible)
// later, run a very very very large file checking logic // later, run a very very very large file checking logic
@ -780,38 +793,35 @@ public class DownloadDialog extends DialogFragment
selectedMediaType = getString(R.string.last_download_type_audio_key); selectedMediaType = getString(R.string.last_download_type_audio_key);
mainStorage = mainStorageAudio; mainStorage = mainStorageAudio;
format = audioStreamsAdapter.getItem(selectedAudioIndex).getFormat(); format = audioStreamsAdapter.getItem(selectedAudioIndex).getFormat();
size = getWrappedAudioStreams().getSizeInBytes(selectedAudioIndex);
if (format == MediaFormat.WEBMA_OPUS) { if (format == MediaFormat.WEBMA_OPUS) {
mimeTmp = "audio/ogg"; mimeTmp = "audio/ogg";
filenameTmp += "opus"; filenameTmp += "opus";
} else if (format != null) { } else if (format != null) {
mimeTmp = format.mimeType; mimeTmp = format.mimeType;
filenameTmp += format.getSuffix(); filenameTmp += format.suffix;
} }
break; break;
case R.id.video_button: case R.id.video_button:
selectedMediaType = getString(R.string.last_download_type_video_key); selectedMediaType = getString(R.string.last_download_type_video_key);
mainStorage = mainStorageVideo; mainStorage = mainStorageVideo;
format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat(); format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat();
size = wrappedVideoStreams.getSizeInBytes(selectedVideoIndex);
if (format != null) { if (format != null) {
mimeTmp = format.mimeType; mimeTmp = format.mimeType;
filenameTmp += format.getSuffix(); filenameTmp += format.suffix;
} }
break; break;
case R.id.subtitle_button: case R.id.subtitle_button:
selectedMediaType = getString(R.string.last_download_type_subtitle_key); selectedMediaType = getString(R.string.last_download_type_subtitle_key);
mainStorage = mainStorageVideo; // subtitle & video files go together mainStorage = mainStorageVideo; // subtitle & video files go together
format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat(); format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat();
size = wrappedSubtitleStreams.getSizeInBytes(selectedSubtitleIndex);
if (format != null) { if (format != null) {
mimeTmp = format.mimeType; mimeTmp = format.mimeType;
} }
if (format == MediaFormat.TTML) { if (format == MediaFormat.TTML) {
filenameTmp += MediaFormat.SRT.getSuffix(); filenameTmp += MediaFormat.SRT.suffix;
} else if (format != null) { } else if (format != null) {
filenameTmp += format.getSuffix(); filenameTmp += format.suffix;
} }
break; break;
default: default:
@ -859,21 +869,6 @@ public class DownloadDialog extends DialogFragment
return; return;
} }
// Check for free storage space
final long freeSpace = mainStorage.getFreeStorageSpace();
if (freeSpace <= size) {
Toast.makeText(context, getString(R.
string.error_insufficient_storage), Toast.LENGTH_LONG).show();
// move the user to storage setting tab
final Intent storageSettingsIntent = new Intent(Settings.
ACTION_INTERNAL_STORAGE_SETTINGS);
if (storageSettingsIntent.resolveActivity(context.getPackageManager())
!= null) {
startActivity(storageSettingsIntent);
}
return;
}
// check for existing file with the same name // check for existing file with the same name
checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp, checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp,
mimeTmp); mimeTmp);
@ -1056,7 +1051,7 @@ public class DownloadDialog extends DialogFragment
final char kind; final char kind;
int threads = dialogBinding.threads.getProgress() + 1; int threads = dialogBinding.threads.getProgress() + 1;
final String[] urls; final String[] urls;
final List<MissionRecoveryInfo> recoveryInfo; final MissionRecoveryInfo[] recoveryInfo;
String psName = null; String psName = null;
String[] psArgs = null; String[] psArgs = null;
long nearLength = 0; long nearLength = 0;
@ -1121,7 +1116,9 @@ public class DownloadDialog extends DialogFragment
urls = new String[] { urls = new String[] {
selectedStream.getContent() selectedStream.getContent()
}; };
recoveryInfo = List.of(new MissionRecoveryInfo(selectedStream)); recoveryInfo = new MissionRecoveryInfo[] {
new MissionRecoveryInfo(selectedStream)
};
} else { } else {
if (secondaryStream.getDeliveryMethod() != PROGRESSIVE_HTTP) { if (secondaryStream.getDeliveryMethod() != PROGRESSIVE_HTTP) {
throw new IllegalArgumentException("Unsupported stream delivery format" throw new IllegalArgumentException("Unsupported stream delivery format"
@ -1131,14 +1128,12 @@ public class DownloadDialog extends DialogFragment
urls = new String[] { urls = new String[] {
selectedStream.getContent(), secondaryStream.getContent() selectedStream.getContent(), secondaryStream.getContent()
}; };
recoveryInfo = List.of( recoveryInfo = new MissionRecoveryInfo[] {new MissionRecoveryInfo(selectedStream),
new MissionRecoveryInfo(selectedStream), new MissionRecoveryInfo(secondaryStream)};
new MissionRecoveryInfo(secondaryStream)
);
} }
DownloadManagerService.startMission(context, urls, storage, kind, threads, DownloadManagerService.startMission(context, urls, storage, kind, threads,
currentInfo.getUrl(), psName, psArgs, nearLength, new ArrayList<>(recoveryInfo)); currentInfo.getUrl(), psName, psArgs, nearLength, recoveryInfo);
Toast.makeText(context, getString(R.string.download_has_started), Toast.makeText(context, getString(R.string.download_has_started),
Toast.LENGTH_SHORT).show(); Toast.LENGTH_SHORT).show();

View File

@ -1,87 +0,0 @@
package org.schabi.newpipe.download;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.DialogFragment;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.DownloadLoadingDialogBinding;
/**
* This class contains a dialog which shows a loading indicator and has a customizable title.
*/
public class LoadingDialog extends DialogFragment {
private static final String TAG = "LoadingDialog";
private static final boolean DEBUG = MainActivity.DEBUG;
private DownloadLoadingDialogBinding dialogLoadingBinding;
private final @StringRes int title;
/**
* Create a new LoadingDialog.
*
* <p>
* The dialog contains a loading indicator and has a customizable title.
* <br/>
* Use {@code show()} to display the dialog to the user.
* </p>
*
* @param title an informative title shown in the dialog's toolbar
*/
public LoadingDialog(final @StringRes int title) {
this.title = title;
}
@Override
public void onCreate(@Nullable final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (DEBUG) {
Log.d(TAG, "onCreate() called with: "
+ "savedInstanceState = [" + savedInstanceState + "]");
}
this.setCancelable(false);
}
@Override
public View onCreateView(
@NonNull final LayoutInflater inflater,
final ViewGroup container,
final Bundle savedInstanceState) {
if (DEBUG) {
Log.d(TAG, "onCreateView() called with: "
+ "inflater = [" + inflater + "], container = [" + container + "], "
+ "savedInstanceState = [" + savedInstanceState + "]");
}
return inflater.inflate(R.layout.download_loading_dialog, container);
}
@Override
public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
dialogLoadingBinding = DownloadLoadingDialogBinding.bind(view);
initToolbar(dialogLoadingBinding.toolbarLayout.toolbar);
}
private void initToolbar(final Toolbar toolbar) {
if (DEBUG) {
Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]");
}
toolbar.setTitle(requireContext().getString(title));
toolbar.setNavigationOnClickListener(v -> dismiss());
}
@Override
public void onDestroyView() {
dialogLoadingBinding = null;
super.onDestroyView();
}
}

View File

@ -2,6 +2,7 @@ package org.schabi.newpipe.error;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage; import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.app.Activity;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.net.Uri; import android.net.Uri;
@ -12,21 +13,22 @@ import android.view.Menu;
import android.view.MenuInflater; import android.view.MenuInflater;
import android.view.MenuItem; import android.view.MenuItem;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.IntentCompat;
import com.grack.nanojson.JsonWriter; import com.grack.nanojson.JsonWriter;
import org.schabi.newpipe.BuildConfig; import org.schabi.newpipe.BuildConfig;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.ActivityErrorBinding; import org.schabi.newpipe.databinding.ActivityErrorBinding;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.time.ZonedDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.Arrays; import java.util.Arrays;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -67,6 +69,10 @@ public class ErrorActivity extends AppCompatActivity {
public static final String ERROR_GITHUB_ISSUE_URL = public static final String ERROR_GITHUB_ISSUE_URL =
"https://github.com/TeamNewPipe/NewPipe/issues"; "https://github.com/TeamNewPipe/NewPipe/issues";
public static final DateTimeFormatter CURRENT_TIMESTAMP_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private ErrorInfo errorInfo; private ErrorInfo errorInfo;
private String currentTimeStamp; private String currentTimeStamp;
@ -99,13 +105,11 @@ public class ErrorActivity extends AppCompatActivity {
actionBar.setDisplayShowTitleEnabled(true); actionBar.setDisplayShowTitleEnabled(true);
} }
errorInfo = IntentCompat.getParcelableExtra(intent, ERROR_INFO, ErrorInfo.class); errorInfo = intent.getParcelableExtra(ERROR_INFO);
// important add guru meditation // important add guru meditation
addGuruMeditation(); addGuruMeditation();
// print current time, as zoned ISO8601 timestamp currentTimeStamp = CURRENT_TIMESTAMP_FORMATTER.format(LocalDateTime.now());
final ZonedDateTime now = ZonedDateTime.now();
currentTimeStamp = now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
activityErrorBinding.errorReportEmailButton.setOnClickListener(v -> activityErrorBinding.errorReportEmailButton.setOnClickListener(v ->
openPrivacyPolicyDialog(this, "EMAIL")); openPrivacyPolicyDialog(this, "EMAIL"));
@ -182,6 +186,25 @@ public class ErrorActivity extends AppCompatActivity {
.collect(Collectors.joining(separator + "\n", separator + "\n", separator)); .collect(Collectors.joining(separator + "\n", separator + "\n", separator));
} }
/**
* Get the checked activity.
*
* @param returnActivity the activity to return to
* @return the casted return activity or null
*/
@Nullable
static Class<? extends Activity> getReturnActivity(final Class<?> returnActivity) {
Class<? extends Activity> checkedReturnActivity = null;
if (returnActivity != null) {
if (Activity.class.isAssignableFrom(returnActivity)) {
checkedReturnActivity = returnActivity.asSubclass(Activity.class);
} else {
checkedReturnActivity = MainActivity.class;
}
}
return checkedReturnActivity;
}
private void buildInfo(final ErrorInfo info) { private void buildInfo(final ErrorInfo info) {
String text = ""; String text = "";
@ -248,9 +271,6 @@ public class ErrorActivity extends AppCompatActivity {
.append("\n* __Content Language:__ ").append(getContentLanguageString()) .append("\n* __Content Language:__ ").append(getContentLanguageString())
.append("\n* __App Language:__ ").append(getAppLanguage()) .append("\n* __App Language:__ ").append(getAppLanguage())
.append("\n* __Service:__ ").append(errorInfo.getServiceName()) .append("\n* __Service:__ ").append(errorInfo.getServiceName())
.append("\n* __Timestamp:__ ").append(currentTimeStamp)
.append("\n* __Package:__ ").append(getPackageName())
.append("\n* __Service:__ ").append(errorInfo.getServiceName())
.append("\n* __Version:__ ").append(BuildConfig.VERSION_NAME) .append("\n* __Version:__ ").append(BuildConfig.VERSION_NAME)
.append("\n* __OS:__ ").append(getOsString()).append("\n"); .append("\n* __OS:__ ").append(getOsString()).append("\n");

View File

@ -11,6 +11,7 @@ import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
import org.schabi.newpipe.extractor.exceptions.ExtractionException import org.schabi.newpipe.extractor.exceptions.ExtractionException
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor.DeobfuscateException
import org.schabi.newpipe.ktx.isNetworkRelated import org.schabi.newpipe.ktx.isNetworkRelated
import org.schabi.newpipe.util.ServiceHelper import org.schabi.newpipe.util.ServiceHelper
@ -95,6 +96,7 @@ class ErrorInfo(
throwable is ContentNotAvailableException -> R.string.content_not_available throwable is ContentNotAvailableException -> R.string.content_not_available
throwable != null && throwable.isNetworkRelated -> R.string.network_error throwable != null && throwable.isNetworkRelated -> R.string.network_error
throwable is ContentNotSupportedException -> R.string.content_not_supported throwable is ContentNotSupportedException -> R.string.content_not_supported
throwable is DeobfuscateException -> R.string.youtube_signature_deobfuscation_error
throwable is ExtractionException -> R.string.parsing_error throwable is ExtractionException -> R.string.parsing_error
throwable is ExoPlaybackException -> { throwable is ExoPlaybackException -> {
when (throwable.type) { when (throwable.type) {

View File

@ -54,7 +54,7 @@ class ErrorUtil {
*/ */
@JvmStatic @JvmStatic
fun showSnackbar(context: Context, errorInfo: ErrorInfo) { fun showSnackbar(context: Context, errorInfo: ErrorInfo) {
val rootView = (context as? Activity)?.findViewById<View>(android.R.id.content) val rootView = if (context is Activity) context.findViewById<View>(R.id.content) else null
showSnackbar(context, rootView, errorInfo) showSnackbar(context, rootView, errorInfo)
} }
@ -71,7 +71,7 @@ class ErrorUtil {
fun showSnackbar(fragment: Fragment, errorInfo: ErrorInfo) { fun showSnackbar(fragment: Fragment, errorInfo: ErrorInfo) {
var rootView = fragment.view var rootView = fragment.view
if (rootView == null && fragment.activity != null) { if (rootView == null && fragment.activity != null) {
rootView = fragment.requireActivity().findViewById(android.R.id.content) rootView = fragment.requireActivity().findViewById(R.id.content)
} }
showSnackbar(fragment.requireContext(), rootView, errorInfo) showSnackbar(fragment.requireContext(), rootView, errorInfo)
} }

View File

@ -27,6 +27,8 @@ import org.schabi.newpipe.databinding.ActivityRecaptchaBinding;
import org.schabi.newpipe.extractor.utils.Utils; import org.schabi.newpipe.extractor.utils.Utils;
import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.ThemeHelper;
import java.io.UnsupportedEncodingException;
/* /*
* Created by beneth <bmauduit@beneth.fr> on 06.12.16. * Created by beneth <bmauduit@beneth.fr> on 06.12.16.
* *
@ -185,11 +187,14 @@ public class ReCaptchaActivity extends AppCompatActivity {
final int abuseEnd = url.indexOf("+path"); final int abuseEnd = url.indexOf("+path");
try { try {
handleCookies(Utils.decodeUrlUtf8(url.substring(abuseStart + 13, abuseEnd))); String abuseCookie = url.substring(abuseStart + 13, abuseEnd);
} catch (final StringIndexOutOfBoundsException e) { abuseCookie = Utils.decodeUrlUtf8(abuseCookie);
handleCookies(abuseCookie);
} catch (UnsupportedEncodingException | StringIndexOutOfBoundsException e) {
if (MainActivity.DEBUG) { if (MainActivity.DEBUG) {
Log.e(TAG, "handleCookiesFromUrl: invalid google abuse starting at " e.printStackTrace();
+ abuseStart + " and ending at " + abuseEnd + " for url " + url, e); Log.d(TAG, "handleCookiesFromUrl: invalid google abuse starting at "
+ abuseStart + " and ending at " + abuseEnd + " for url " + url);
} }
} }
} }

View File

@ -6,7 +6,6 @@ package org.schabi.newpipe.error;
public enum UserAction { public enum UserAction {
USER_REPORT("user report"), USER_REPORT("user report"),
UI_ERROR("ui error"), UI_ERROR("ui error"),
DATABASE_IMPORT_EXPORT("database import or export"),
SUBSCRIPTION_CHANGE("subscription change"), SUBSCRIPTION_CHANGE("subscription change"),
SUBSCRIPTION_UPDATE("subscription update"), SUBSCRIPTION_UPDATE("subscription update"),
SUBSCRIPTION_GET("get subscription"), SUBSCRIPTION_GET("get subscription"),
@ -20,7 +19,6 @@ public enum UserAction {
REQUESTED_PLAYLIST("requested playlist"), REQUESTED_PLAYLIST("requested playlist"),
REQUESTED_KIOSK("requested kiosk"), REQUESTED_KIOSK("requested kiosk"),
REQUESTED_COMMENTS("requested comments"), REQUESTED_COMMENTS("requested comments"),
REQUESTED_COMMENT_REPLIES("requested comment replies"),
REQUESTED_FEED("requested feed"), REQUESTED_FEED("requested feed"),
REQUESTED_BOOKMARK("bookmark"), REQUESTED_BOOKMARK("bookmark"),
DELETE_FROM_HISTORY("delete from history"), DELETE_FROM_HISTORY("delete from history"),

View File

@ -1,20 +1,14 @@
package org.schabi.newpipe.fragments; package org.schabi.newpipe.fragments;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log; import android.util.Log;
import android.view.View; import android.view.View;
import android.widget.ProgressBar; import android.widget.ProgressBar;
import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import com.evernote.android.state.State;
import org.schabi.newpipe.BaseFragment; import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
@ -24,15 +18,17 @@ import org.schabi.newpipe.util.InfoCache;
import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicBoolean;
import icepick.State;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
public abstract class BaseStateFragment<I> extends BaseFragment implements ViewContract<I> { public abstract class BaseStateFragment<I> extends BaseFragment implements ViewContract<I> {
@State @State
protected AtomicBoolean wasLoading = new AtomicBoolean(); protected AtomicBoolean wasLoading = new AtomicBoolean();
protected AtomicBoolean isLoading = new AtomicBoolean(); protected AtomicBoolean isLoading = new AtomicBoolean();
@Nullable @Nullable
protected View emptyStateView; private View emptyStateView;
@Nullable
protected TextView emptyStateMessageView;
@Nullable @Nullable
private ProgressBar loadingProgressBar; private ProgressBar loadingProgressBar;
@ -69,7 +65,6 @@ public abstract class BaseStateFragment<I> extends BaseFragment implements ViewC
protected void initViews(final View rootView, final Bundle savedInstanceState) { protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState); super.initViews(rootView, savedInstanceState);
emptyStateView = rootView.findViewById(R.id.empty_state_view); emptyStateView = rootView.findViewById(R.id.empty_state_view);
emptyStateMessageView = rootView.findViewById(R.id.empty_state_message);
loadingProgressBar = rootView.findViewById(R.id.loading_progress_bar); loadingProgressBar = rootView.findViewById(R.id.loading_progress_bar);
errorPanelHelper = new ErrorPanelHelper(this, rootView, this::onRetryButtonClicked); errorPanelHelper = new ErrorPanelHelper(this, rootView, this::onRetryButtonClicked);
} }
@ -80,8 +75,6 @@ public abstract class BaseStateFragment<I> extends BaseFragment implements ViewC
if (errorPanelHelper != null) { if (errorPanelHelper != null) {
errorPanelHelper.dispose(); errorPanelHelper.dispose();
} }
emptyStateView = null;
emptyStateMessageView = null;
} }
protected void onRetryButtonClicked() { protected void onRetryButtonClicked() {
@ -134,7 +127,6 @@ public abstract class BaseStateFragment<I> extends BaseFragment implements ViewC
hideErrorPanel(); hideErrorPanel();
} }
@Override
public void showEmptyState() { public void showEmptyState() {
isLoading.set(false); isLoading.set(false);
if (emptyStateView != null) { if (emptyStateView != null) {
@ -197,12 +189,6 @@ public abstract class BaseStateFragment<I> extends BaseFragment implements ViewC
errorPanelHelper.showTextError(errorString); errorPanelHelper.showTextError(errorString);
} }
protected void setEmptyStateMessage(@StringRes final int text) {
if (emptyStateMessageView != null) {
emptyStateMessageView.setText(text);
}
}
public final void hideErrorPanel() { public final void hideErrorPanel() {
errorPanelHelper.hide(); errorPanelHelper.hide();
lastPanelError = null; lastPanelError = null;

View File

@ -1,16 +1,6 @@
package org.schabi.newpipe.fragments; package org.schabi.newpipe.fragments;
import static android.widget.RelativeLayout.ABOVE;
import static android.widget.RelativeLayout.ALIGN_PARENT_BOTTOM;
import static android.widget.RelativeLayout.ALIGN_PARENT_TOP;
import static android.widget.RelativeLayout.BELOW;
import static com.google.android.material.tabs.TabLayout.INDICATOR_GRAVITY_BOTTOM;
import static com.google.android.material.tabs.TabLayout.INDICATOR_GRAVITY_TOP;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.ColorStateList;
import android.graphics.Color;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log; import android.util.Log;
import android.view.LayoutInflater; import android.view.LayoutInflater;
@ -19,9 +9,7 @@ import android.view.MenuInflater;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.RelativeLayout;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBar;
@ -29,7 +17,6 @@ import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentStatePagerAdapterMenuWorkaround; import androidx.fragment.app.FragmentStatePagerAdapterMenuWorkaround;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
import androidx.viewpager.widget.ViewPager;
import com.google.android.material.tabs.TabLayout; import com.google.android.material.tabs.TabLayout;
@ -38,13 +25,10 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.FragmentMainBinding; import org.schabi.newpipe.databinding.FragmentMainBinding;
import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
import org.schabi.newpipe.settings.tabs.Tab; import org.schabi.newpipe.settings.tabs.Tab;
import org.schabi.newpipe.settings.tabs.TabsManager; import org.schabi.newpipe.settings.tabs.TabsManager;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.views.ScrollableTabLayout;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -58,11 +42,8 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
private boolean hasTabsChanged = false; private boolean hasTabsChanged = false;
private SharedPreferences prefs; private boolean previousYoutubeRestrictedModeEnabled;
private boolean youtubeRestrictedModeEnabled;
private String youtubeRestrictedModeEnabledKey; private String youtubeRestrictedModeEnabledKey;
private boolean mainTabsPositionBottom;
private String mainTabsPositionKey;
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Fragment's LifeCycle // Fragment's LifeCycle
@ -85,11 +66,10 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
} }
}); });
prefs = PreferenceManager.getDefaultSharedPreferences(requireContext());
youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled); youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled);
youtubeRestrictedModeEnabled = prefs.getBoolean(youtubeRestrictedModeEnabledKey, false); previousYoutubeRestrictedModeEnabled =
mainTabsPositionKey = getString(R.string.main_tabs_position_key); PreferenceManager.getDefaultSharedPreferences(requireContext())
mainTabsPositionBottom = prefs.getBoolean(mainTabsPositionKey, false); .getBoolean(youtubeRestrictedModeEnabledKey, false);
} }
@Override @Override
@ -107,26 +87,24 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
binding.mainTabLayout.setupWithViewPager(binding.pager); binding.mainTabLayout.setupWithViewPager(binding.pager);
binding.mainTabLayout.addOnTabSelectedListener(this); binding.mainTabLayout.addOnTabSelectedListener(this);
binding.mainTabLayout.setTabRippleColor(binding.mainTabLayout.getTabRippleColor()
.withAlpha(32));
setupTabs(); setupTabs();
updateTabLayoutPosition();
} }
@Override @Override
public void onResume() { public void onResume() {
super.onResume(); super.onResume();
final boolean newYoutubeRestrictedModeEnabled = final boolean youtubeRestrictedModeEnabled =
prefs.getBoolean(youtubeRestrictedModeEnabledKey, false); PreferenceManager.getDefaultSharedPreferences(requireContext())
if (youtubeRestrictedModeEnabled != newYoutubeRestrictedModeEnabled || hasTabsChanged) { .getBoolean(youtubeRestrictedModeEnabledKey, false);
youtubeRestrictedModeEnabled = newYoutubeRestrictedModeEnabled; if (previousYoutubeRestrictedModeEnabled != youtubeRestrictedModeEnabled) {
previousYoutubeRestrictedModeEnabled = youtubeRestrictedModeEnabled;
setupTabs();
} else if (hasTabsChanged) {
setupTabs(); setupTabs();
}
final boolean newMainTabsPosition = prefs.getBoolean(mainTabsPositionKey, false);
if (mainTabsPositionBottom != newMainTabsPosition) {
mainTabsPositionBottom = newMainTabsPosition;
updateTabLayoutPosition();
} }
} }
@ -140,12 +118,6 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
} }
} }
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Menu // Menu
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@ -194,6 +166,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
} }
binding.pager.setAdapter(null); binding.pager.setAdapter(null);
binding.pager.setOffscreenPageLimit(tabsList.size());
binding.pager.setAdapter(pagerAdapter); binding.pager.setAdapter(pagerAdapter);
updateTabsIconAndDescription(); updateTabsIconAndDescription();
@ -217,44 +190,6 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
setTitle(tabsList.get(tabPosition).getTabName(requireContext())); setTitle(tabsList.get(tabPosition).getTabName(requireContext()));
} }
public void commitPlaylistTabs() {
pagerAdapter.getLocalPlaylistFragments()
.stream()
.forEach(LocalPlaylistFragment::saveImmediate);
}
private void updateTabLayoutPosition() {
final ScrollableTabLayout tabLayout = binding.mainTabLayout;
final ViewPager viewPager = binding.pager;
final boolean bottom = mainTabsPositionBottom;
// change layout params to make the tab layout appear either at the top or at the bottom
final var tabParams = (RelativeLayout.LayoutParams) tabLayout.getLayoutParams();
final var pagerParams = (RelativeLayout.LayoutParams) viewPager.getLayoutParams();
tabParams.removeRule(bottom ? ALIGN_PARENT_TOP : ALIGN_PARENT_BOTTOM);
tabParams.addRule(bottom ? ALIGN_PARENT_BOTTOM : ALIGN_PARENT_TOP);
pagerParams.removeRule(bottom ? BELOW : ABOVE);
pagerParams.addRule(bottom ? ABOVE : BELOW, R.id.main_tab_layout);
tabLayout.setSelectedTabIndicatorGravity(
bottom ? INDICATOR_GRAVITY_TOP : INDICATOR_GRAVITY_BOTTOM);
tabLayout.setLayoutParams(tabParams);
viewPager.setLayoutParams(pagerParams);
// change the background and icon color of the tab layout:
// service-colored at the top, app-background-colored at the bottom
tabLayout.setBackgroundColor(ThemeHelper.resolveColorFromAttr(requireContext(),
bottom ? android.R.attr.windowBackground : R.attr.colorPrimary));
@ColorInt final int iconColor = bottom
? ThemeHelper.resolveColorFromAttr(requireContext(), android.R.attr.colorAccent)
: Color.WHITE;
tabLayout.setTabRippleColor(ColorStateList.valueOf(iconColor).withAlpha(32));
tabLayout.setTabIconTint(ColorStateList.valueOf(iconColor));
tabLayout.setSelectedTabIndicatorColor(iconColor);
}
@Override @Override
public void onTabSelected(final TabLayout.Tab selectedTab) { public void onTabSelected(final TabLayout.Tab selectedTab) {
if (DEBUG) { if (DEBUG) {
@ -274,18 +209,10 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
updateTitleForTab(tab.getPosition()); updateTitleForTab(tab.getPosition());
} }
public static final class SelectedTabsPagerAdapter private static final class SelectedTabsPagerAdapter
extends FragmentStatePagerAdapterMenuWorkaround { extends FragmentStatePagerAdapterMenuWorkaround {
private final Context context; private final Context context;
private final List<Tab> internalTabsList; private final List<Tab> internalTabsList;
/**
* Keep reference to LocalPlaylistFragments, because their data can be modified by the user
* during runtime and changes are not committed immediately. However, in some cases,
* the changes need to be committed immediately by calling
* {@link LocalPlaylistFragment#saveImmediate()}.
* The fragments are removed when {@link LocalPlaylistFragment#onDestroy()} is called.
*/
private final List<LocalPlaylistFragment> localPlaylistFragments = new ArrayList<>();
private SelectedTabsPagerAdapter(final Context context, private SelectedTabsPagerAdapter(final Context context,
final FragmentManager fragmentManager, final FragmentManager fragmentManager,
@ -312,17 +239,9 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
((BaseFragment) fragment).useAsFrontPage(true); ((BaseFragment) fragment).useAsFrontPage(true);
} }
if (fragment instanceof LocalPlaylistFragment) {
localPlaylistFragments.add((LocalPlaylistFragment) fragment);
}
return fragment; return fragment;
} }
public List<LocalPlaylistFragment> getLocalPlaylistFragments() {
return localPlaylistFragments;
}
@Override @Override
public int getItemPosition(@NonNull final Object object) { public int getItemPosition(@NonNull final Object object) {
// Causes adapter to reload all Fragments when // Causes adapter to reload all Fragments when

View File

@ -1,281 +0,0 @@
package org.schabi.newpipe.fragments.detail;
import static android.text.TextUtils.isEmpty;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD;
import android.graphics.Typeface;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.StyleSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.widget.TooltipCompat;
import androidx.core.text.HtmlCompat;
import com.google.android.material.chip.Chip;
import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.FragmentDescriptionBinding;
import org.schabi.newpipe.databinding.ItemMetadataBinding;
import org.schabi.newpipe.databinding.ItemMetadataTagsBinding;
import org.schabi.newpipe.extractor.Image;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.text.TextLinkifier;
import java.util.List;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public abstract class BaseDescriptionFragment extends BaseFragment {
private final CompositeDisposable descriptionDisposables = new CompositeDisposable();
protected FragmentDescriptionBinding binding;
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
binding = FragmentDescriptionBinding.inflate(inflater, container, false);
setupDescription();
setupMetadata(inflater, binding.detailMetadataLayout);
addTagsMetadataItem(inflater, binding.detailMetadataLayout);
return binding.getRoot();
}
@Override
public void onDestroy() {
descriptionDisposables.clear();
super.onDestroy();
}
/**
* Get the description to display.
* @return description object, if available
*/
@Nullable
protected abstract Description getDescription();
/**
* Get the streaming service. Used for generating description links.
* @return streaming service
*/
@NonNull
protected abstract StreamingService getService();
/**
* Get the streaming service ID. Used for tag links.
* @return service ID
*/
protected abstract int getServiceId();
/**
* Get the URL of the described video or audio, used to generate description links.
* @return stream URL
*/
@Nullable
protected abstract String getStreamUrl();
/**
* Get the list of tags to display below the description.
* @return tag list
*/
@NonNull
public abstract List<String> getTags();
/**
* Add additional metadata to display.
* @param inflater LayoutInflater
* @param layout detailMetadataLayout
*/
protected abstract void setupMetadata(LayoutInflater inflater, LinearLayout layout);
private void setupDescription() {
final Description description = getDescription();
if (description == null || isEmpty(description.getContent())
|| description == Description.EMPTY_DESCRIPTION) {
binding.detailDescriptionView.setVisibility(View.GONE);
binding.detailSelectDescriptionButton.setVisibility(View.GONE);
return;
}
// start with disabled state. This also loads description content (!)
disableDescriptionSelection();
binding.detailSelectDescriptionButton.setOnClickListener(v -> {
if (binding.detailDescriptionNoteView.getVisibility() == View.VISIBLE) {
disableDescriptionSelection();
} else {
// enable selection only when button is clicked to prevent flickering
enableDescriptionSelection();
}
});
}
private void enableDescriptionSelection() {
binding.detailDescriptionNoteView.setVisibility(View.VISIBLE);
binding.detailDescriptionView.setTextIsSelectable(true);
final String buttonLabel = getString(R.string.description_select_disable);
binding.detailSelectDescriptionButton.setContentDescription(buttonLabel);
TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel);
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_close);
}
private void disableDescriptionSelection() {
// show description content again, otherwise some links are not clickable
final Description description = getDescription();
if (description != null) {
TextLinkifier.fromDescription(binding.detailDescriptionView,
description, HtmlCompat.FROM_HTML_MODE_LEGACY,
getService(), getStreamUrl(),
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
}
binding.detailDescriptionNoteView.setVisibility(View.GONE);
binding.detailDescriptionView.setTextIsSelectable(false);
final String buttonLabel = getString(R.string.description_select_enable);
binding.detailSelectDescriptionButton.setContentDescription(buttonLabel);
TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel);
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all);
}
protected void addMetadataItem(final LayoutInflater inflater,
final LinearLayout layout,
final boolean linkifyContent,
@StringRes final int type,
@NonNull final String content) {
if (isBlank(content)) {
return;
}
final ItemMetadataBinding itemBinding =
ItemMetadataBinding.inflate(inflater, layout, false);
itemBinding.metadataTypeView.setText(type);
itemBinding.metadataTypeView.setOnLongClickListener(v -> {
ShareUtils.copyToClipboard(requireContext(), content);
return true;
});
if (linkifyContent) {
TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null,
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
} else {
itemBinding.metadataContentView.setText(content);
}
itemBinding.metadataContentView.setClickable(true);
layout.addView(itemBinding.getRoot());
}
private String imageSizeToText(final int heightOrWidth) {
if (heightOrWidth < 0) {
return getString(R.string.question_mark);
} else {
return String.valueOf(heightOrWidth);
}
}
protected void addImagesMetadataItem(final LayoutInflater inflater,
final LinearLayout layout,
@StringRes final int type,
final List<Image> images) {
final String preferredImageUrl = ImageStrategy.choosePreferredImage(images);
if (preferredImageUrl == null) {
return; // null will be returned in case there is no image
}
final ItemMetadataBinding itemBinding =
ItemMetadataBinding.inflate(inflater, layout, false);
itemBinding.metadataTypeView.setText(type);
final SpannableStringBuilder urls = new SpannableStringBuilder();
for (final Image image : images) {
if (urls.length() != 0) {
urls.append(", ");
}
final int entryBegin = urls.length();
if (image.getHeight() != Image.HEIGHT_UNKNOWN
|| image.getWidth() != Image.WIDTH_UNKNOWN
// if even the resolution level is unknown, ?x? will be shown
|| image.getEstimatedResolutionLevel() == Image.ResolutionLevel.UNKNOWN) {
urls.append(imageSizeToText(image.getHeight()));
urls.append('x');
urls.append(imageSizeToText(image.getWidth()));
} else {
switch (image.getEstimatedResolutionLevel()) {
case LOW -> urls.append(getString(R.string.image_quality_low));
case MEDIUM -> urls.append(getString(R.string.image_quality_medium));
case HIGH -> urls.append(getString(R.string.image_quality_high));
default -> {
// unreachable, Image.ResolutionLevel.UNKNOWN is already filtered out
}
}
}
urls.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull final View widget) {
ShareUtils.openUrlInBrowser(requireContext(), image.getUrl());
}
}, entryBegin, urls.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (preferredImageUrl.equals(image.getUrl())) {
urls.setSpan(new StyleSpan(Typeface.BOLD), entryBegin, urls.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
itemBinding.metadataContentView.setText(urls);
itemBinding.metadataContentView.setMovementMethod(LinkMovementMethod.getInstance());
layout.addView(itemBinding.getRoot());
}
private void addTagsMetadataItem(final LayoutInflater inflater, final LinearLayout layout) {
final List<String> tags = getTags();
if (!tags.isEmpty()) {
final var itemBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false);
tags.stream().sorted(String.CASE_INSENSITIVE_ORDER).forEach(tag -> {
final Chip chip = (Chip) inflater.inflate(R.layout.chip,
itemBinding.metadataTagsChips, false);
chip.setText(tag);
chip.setOnClickListener(this::onTagClick);
chip.setOnLongClickListener(this::onTagLongClick);
itemBinding.metadataTagsChips.addView(chip);
});
layout.addView(itemBinding.getRoot());
}
}
private void onTagClick(final View chip) {
if (getParentFragment() != null) {
NavigationHelper.openSearchFragment(getParentFragment().getParentFragmentManager(),
getServiceId(), ((Chip) chip).getText().toString());
}
}
private boolean onTagLongClick(final View chip) {
ShareUtils.copyToClipboard(requireContext(), ((Chip) chip).getText().toString());
return true;
}
}

View File

@ -1,83 +1,134 @@
package org.schabi.newpipe.fragments.detail; package org.schabi.newpipe.fragments.detail;
import static android.text.TextUtils.isEmpty;
import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.util.Localization.getAppLocale; import static org.schabi.newpipe.util.Localization.getAppLocale;
import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD;
import android.os.Bundle;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.StringRes; import androidx.annotation.StringRes;
import androidx.appcompat.widget.TooltipCompat;
import androidx.core.text.HtmlCompat;
import com.evernote.android.state.State; import com.google.android.material.chip.Chip;
import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.StreamingService; 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.Description;
import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.text.TextLinkifier;
import java.util.List; import icepick.State;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public class DescriptionFragment extends BaseDescriptionFragment { public class DescriptionFragment extends BaseFragment {
@State @State
StreamInfo streamInfo; StreamInfo streamInfo = null;
final CompositeDisposable descriptionDisposables = new CompositeDisposable();
FragmentDescriptionBinding binding;
public DescriptionFragment() {
}
public DescriptionFragment(final StreamInfo streamInfo) { public DescriptionFragment(final StreamInfo streamInfo) {
this.streamInfo = streamInfo; this.streamInfo = streamInfo;
} }
public DescriptionFragment() {
// keep empty constructor for State when resuming fragment from memory
}
@Nullable
@Override @Override
protected Description getDescription() { public View onCreateView(@NonNull final LayoutInflater inflater,
return streamInfo.getDescription(); @Nullable final ViewGroup container,
} @Nullable final Bundle savedInstanceState) {
binding = FragmentDescriptionBinding.inflate(inflater, container, false);
@NonNull if (streamInfo != null) {
@Override setupUploadDate();
protected StreamingService getService() { setupDescription();
return streamInfo.getService(); setupMetadata(inflater, binding.detailMetadataLayout);
}
return binding.getRoot();
} }
@Override @Override
protected int getServiceId() { public void onDestroy() {
return streamInfo.getServiceId(); descriptionDisposables.clear();
super.onDestroy();
} }
@NonNull
@Override
protected String getStreamUrl() {
return streamInfo.getUrl();
}
@NonNull private void setupUploadDate() {
@Override if (streamInfo.getUploadDate() != null) {
public List<String> getTags() {
return streamInfo.getTags();
}
@Override
protected void setupMetadata(final LayoutInflater inflater,
final LinearLayout layout) {
if (streamInfo != null && streamInfo.getUploadDate() != null) {
binding.detailUploadDateView.setText(Localization binding.detailUploadDateView.setText(Localization
.localizeUploadDate(activity, streamInfo.getUploadDate().offsetDateTime())); .localizeUploadDate(activity, streamInfo.getUploadDate().offsetDateTime()));
} else { } else {
binding.detailUploadDateView.setVisibility(View.GONE); binding.detailUploadDateView.setVisibility(View.GONE);
} }
}
if (streamInfo == null) {
private void setupDescription() {
final Description description = streamInfo.getDescription();
if (description == null || isEmpty(description.getContent())
|| description == Description.EMPTY_DESCRIPTION) {
binding.detailDescriptionView.setVisibility(View.GONE);
binding.detailSelectDescriptionButton.setVisibility(View.GONE);
return; 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
TextLinkifier.fromDescription(binding.detailDescriptionView,
streamInfo.getDescription(), HtmlCompat.FROM_HTML_MODE_LEGACY,
streamInfo.getService(), streamInfo.getUrl(),
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
binding.detailDescriptionNoteView.setVisibility(View.GONE);
binding.detailDescriptionView.setTextIsSelectable(false);
final String buttonLabel = getString(R.string.description_select_enable);
binding.detailSelectDescriptionButton.setContentDescription(buttonLabel);
TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel);
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all);
}
private void setupMetadata(final LayoutInflater inflater,
final LinearLayout layout) {
addMetadataItem(inflater, layout, false, R.string.metadata_category, addMetadataItem(inflater, layout, false, R.string.metadata_category,
streamInfo.getCategory()); streamInfo.getCategory());
@ -100,13 +151,69 @@ public class DescriptionFragment extends BaseDescriptionFragment {
streamInfo.getSupportInfo()); streamInfo.getSupportInfo());
addMetadataItem(inflater, layout, true, R.string.metadata_host, addMetadataItem(inflater, layout, true, R.string.metadata_host,
streamInfo.getHost()); streamInfo.getHost());
addMetadataItem(inflater, layout, true, R.string.metadata_thumbnail_url,
streamInfo.getThumbnailUrl());
addImagesMetadataItem(inflater, layout, R.string.metadata_thumbnails, addTagsMetadataItem(inflater, layout);
streamInfo.getThumbnails()); }
addImagesMetadataItem(inflater, layout, R.string.metadata_uploader_avatars,
streamInfo.getUploaderAvatars()); private void addMetadataItem(final LayoutInflater inflater,
addImagesMetadataItem(inflater, layout, R.string.metadata_subchannel_avatars, final LinearLayout layout,
streamInfo.getSubChannelAvatars()); 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.fromPlainText(itemBinding.metadataContentView, content, null, null,
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
} else {
itemBinding.metadataContentView.setText(content);
}
itemBinding.metadataContentView.setClickable(true);
layout.addView(itemBinding.getRoot());
}
private void addTagsMetadataItem(final LayoutInflater inflater, final LinearLayout layout) {
if (streamInfo.getTags() != null && !streamInfo.getTags().isEmpty()) {
final var itemBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false);
streamInfo.getTags().stream().sorted(String.CASE_INSENSITIVE_ORDER).forEach(tag -> {
final Chip chip = (Chip) inflater.inflate(R.layout.chip,
itemBinding.metadataTagsChips, false);
chip.setText(tag);
chip.setOnClickListener(this::onTagClick);
chip.setOnLongClickListener(this::onTagLongClick);
itemBinding.metadataTagsChips.addView(chip);
});
layout.addView(itemBinding.getRoot());
}
}
private void onTagClick(final View chip) {
if (getParentFragment() != null) {
NavigationHelper.openSearchFragment(getParentFragment().getParentFragmentManager(),
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) { private void addPrivacyMetadataItem(final LayoutInflater inflater, final LinearLayout layout) {

View File

@ -24,6 +24,7 @@ import android.content.pm.ActivityInfo;
import android.database.ContentObserver; import android.database.ContentObserver;
import android.graphics.Color; import android.graphics.Color;
import android.graphics.Rect; import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
@ -53,10 +54,8 @@ import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.Toolbar; import androidx.appcompat.widget.Toolbar;
import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
import com.evernote.android.state.State;
import com.google.android.exoplayer2.PlaybackException; import com.google.android.exoplayer2.PlaybackException;
import com.google.android.exoplayer2.PlaybackParameters; import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.material.appbar.AppBarLayout; import com.google.android.material.appbar.AppBarLayout;
@ -72,9 +71,8 @@ import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.ReCaptchaActivity; import org.schabi.newpipe.error.ReCaptchaActivity;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.Image; import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.AudioStream;
@ -85,13 +83,11 @@ import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.BackPressable;
import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.BaseStateFragment;
import org.schabi.newpipe.fragments.EmptyFragment; import org.schabi.newpipe.fragments.EmptyFragment;
import org.schabi.newpipe.fragments.MainFragment;
import org.schabi.newpipe.fragments.list.comments.CommentsFragment; import org.schabi.newpipe.fragments.list.comments.CommentsFragment;
import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment; import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment;
import org.schabi.newpipe.ktx.AnimationType; import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.PlayerService; import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.player.PlayerType;
@ -107,17 +103,15 @@ import org.schabi.newpipe.player.ui.VideoPlayerUi;
import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.InfoCache;
import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.ListHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.PermissionHelper;
import org.schabi.newpipe.util.PlayButtonHelper; import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.StreamTypeUtil; import org.schabi.newpipe.util.StreamTypeUtil;
import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.util.external_communication.KoreUtils; import org.schabi.newpipe.util.external_communication.KoreUtils;
import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.image.PicassoHelper;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Iterator; import java.util.Iterator;
@ -128,6 +122,7 @@ import java.util.Optional;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.Consumer; import java.util.function.Consumer;
import icepick.State;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
@ -283,11 +278,11 @@ public final class VideoDetailFragment
/*////////////////////////////////////////////////////////////////////////*/ /*////////////////////////////////////////////////////////////////////////*/
public static VideoDetailFragment getInstance(final int serviceId, public static VideoDetailFragment getInstance(final int serviceId,
@Nullable final String url, @Nullable final String videoUrl,
@NonNull final String name, @NonNull final String name,
@Nullable final PlayQueue queue) { @Nullable final PlayQueue queue) {
final VideoDetailFragment instance = new VideoDetailFragment(); final VideoDetailFragment instance = new VideoDetailFragment();
instance.setInitialData(serviceId, url, name, queue); instance.setInitialData(serviceId, videoUrl, name, queue);
return instance; return instance;
} }
@ -475,23 +470,10 @@ public final class VideoDetailFragment
binding.detailControlsBackground.setOnClickListener(v -> openBackgroundPlayer(false)); binding.detailControlsBackground.setOnClickListener(v -> openBackgroundPlayer(false));
binding.detailControlsPopup.setOnClickListener(v -> openPopupPlayer(false)); binding.detailControlsPopup.setOnClickListener(v -> openPopupPlayer(false));
binding.detailControlsPlaylistAppend.setOnClickListener(makeOnClickListener(info -> { binding.detailControlsPlaylistAppend.setOnClickListener(makeOnClickListener(info ->
if (getFM() != null && currentInfo != null) {
final Fragment fragment = getParentFragmentManager().
findFragmentById(R.id.fragment_holder);
// commit previous pending changes to database
if (fragment instanceof LocalPlaylistFragment) {
((LocalPlaylistFragment) fragment).saveImmediate();
} else if (fragment instanceof MainFragment) {
((MainFragment) fragment).commitPlaylistTabs();
}
disposables.add(PlaylistDialog.createCorrespondingDialog(requireContext(), disposables.add(PlaylistDialog.createCorrespondingDialog(requireContext(),
List.of(new StreamEntity(info)), List.of(new StreamEntity(info)),
dialog -> dialog.show(getParentFragmentManager(), TAG))); dialog -> dialog.show(getParentFragmentManager(), TAG)))));
}
}));
binding.detailControlsDownload.setOnClickListener(v -> { binding.detailControlsDownload.setOnClickListener(v -> {
if (PermissionHelper.checkStoragePermissions(activity, if (PermissionHelper.checkStoragePermissions(activity,
PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) {
@ -500,7 +482,7 @@ public final class VideoDetailFragment
}); });
binding.detailControlsShare.setOnClickListener(makeOnClickListener(info -> binding.detailControlsShare.setOnClickListener(makeOnClickListener(info ->
ShareUtils.shareText(requireContext(), info.getName(), info.getUrl(), ShareUtils.shareText(requireContext(), info.getName(), info.getUrl(),
info.getThumbnails()))); info.getThumbnailUrl())));
binding.detailControlsOpenInBrowser.setOnClickListener(makeOnClickListener(info -> binding.detailControlsOpenInBrowser.setOnClickListener(makeOnClickListener(info ->
ShareUtils.openUrlInBrowser(requireContext(), info.getUrl()))); ShareUtils.openUrlInBrowser(requireContext(), info.getUrl())));
binding.detailControlsPlayWithKodi.setOnClickListener(makeOnClickListener(info -> binding.detailControlsPlayWithKodi.setOnClickListener(makeOnClickListener(info ->
@ -553,11 +535,9 @@ public final class VideoDetailFragment
})); }));
binding.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(info -> binding.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(info ->
openBackgroundPlayer(true) openBackgroundPlayer(true)));
));
binding.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(info -> binding.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(info ->
openPopupPlayer(true) openPopupPlayer(true)));
));
binding.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(info -> binding.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(info ->
NavigationHelper.openDownloads(activity))); NavigationHelper.openDownloads(activity)));
@ -640,7 +620,8 @@ public final class VideoDetailFragment
final View.OnTouchListener controlsTouchListener = (view, motionEvent) -> { final View.OnTouchListener controlsTouchListener = (view, motionEvent) -> {
if (motionEvent.getAction() == MotionEvent.ACTION_DOWN if (motionEvent.getAction() == MotionEvent.ACTION_DOWN
&& PlayButtonHelper.shouldShowHoldToAppendTip(activity)) { && PreferenceManager.getDefaultSharedPreferences(activity)
.getBoolean(getString(R.string.show_hold_to_append_key), true)) {
animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, 0, () -> animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, 0, () ->
animate(binding.touchAppendDetail, false, 1500, AnimationType.ALPHA, 1000)); animate(binding.touchAppendDetail, false, 1500, AnimationType.ALPHA, 1000));
@ -740,7 +721,7 @@ public final class VideoDetailFragment
final boolean isPlayerStopped = !isPlayerAvailable() || player.isStopped(); final boolean isPlayerStopped = !isPlayerAvailable() || player.isStopped();
if (playQueueItem != null && isPlayerStopped) { if (playQueueItem != null && isPlayerStopped) {
updateOverlayData(playQueueItem.getTitle(), updateOverlayData(playQueueItem.getTitle(),
playQueueItem.getUploader(), playQueueItem.getThumbnails()); playQueueItem.getUploader(), playQueueItem.getThumbnailUrl());
} }
} }
@ -1013,20 +994,6 @@ public final class VideoDetailFragment
updateTabLayoutVisibility(); updateTabLayoutVisibility();
} }
public void scrollToComment(final CommentsInfoItem comment) {
final int commentsTabPos = pageAdapter.getItemPositionByTitle(COMMENTS_TAB_TAG);
final Fragment fragment = pageAdapter.getItem(commentsTabPos);
if (!(fragment instanceof CommentsFragment)) {
return;
}
// unexpand the app bar only if scrolling to the comment succeeded
if (((CommentsFragment) fragment).scrollToComment(comment)) {
binding.appBarLayout.setExpanded(false, false);
binding.viewPager.setCurrentItem(commentsTabPos, false);
}
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Play Utils // Play Utils
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@ -1445,7 +1412,7 @@ public final class VideoDetailFragment
super.showLoading(); super.showLoading();
//if data is already cached, transition from VISIBLE -> INVISIBLE -> VISIBLE is not required //if data is already cached, transition from VISIBLE -> INVISIBLE -> VISIBLE is not required
if (!ExtractorHelper.isCached(serviceId, url, InfoCache.Type.STREAM)) { if (!ExtractorHelper.isCached(serviceId, url, InfoItem.InfoType.STREAM)) {
binding.detailContentRootHiding.setVisibility(View.INVISIBLE); binding.detailContentRootHiding.setVisibility(View.INVISIBLE);
} }
@ -1496,6 +1463,11 @@ public final class VideoDetailFragment
displayUploaderAsSubChannel(info); displayUploaderAsSubChannel(info);
} }
final Drawable buddyDrawable =
AppCompatResources.getDrawable(activity, R.drawable.placeholder_person);
binding.detailSubChannelThumbnailView.setImageDrawable(buddyDrawable);
binding.detailUploaderThumbnailView.setImageDrawable(buddyDrawable);
if (info.getViewCount() >= 0) { if (info.getViewCount() >= 0) {
if (info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { if (info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) {
binding.detailViewCountView.setText(Localization.listeningCount(activity, binding.detailViewCountView.setText(Localization.listeningCount(activity,
@ -1562,13 +1534,13 @@ public final class VideoDetailFragment
binding.detailSecondaryControlPanel.setVisibility(View.GONE); binding.detailSecondaryControlPanel.setVisibility(View.GONE);
checkUpdateProgressInfo(info); checkUpdateProgressInfo(info);
PicassoHelper.loadDetailsThumbnail(info.getThumbnails()).tag(PICASSO_VIDEO_DETAILS_TAG) PicassoHelper.loadDetailsThumbnail(info.getThumbnailUrl()).tag(PICASSO_VIDEO_DETAILS_TAG)
.into(binding.detailThumbnailImageView); .into(binding.detailThumbnailImageView);
showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView,
binding.detailMetaInfoSeparator, disposables); binding.detailMetaInfoSeparator, disposables);
if (!isPlayerAvailable() || player.isStopped()) { if (!isPlayerAvailable() || player.isStopped()) {
updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails()); updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnailUrl());
} }
if (!info.getErrors().isEmpty()) { if (!info.getErrors().isEmpty()) {
@ -1613,7 +1585,7 @@ public final class VideoDetailFragment
binding.detailUploaderTextView.setVisibility(View.GONE); binding.detailUploaderTextView.setVisibility(View.GONE);
} }
PicassoHelper.loadAvatar(info.getUploaderAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG) PicassoHelper.loadAvatar(info.getUploaderAvatarUrl()).tag(PICASSO_VIDEO_DETAILS_TAG)
.into(binding.detailSubChannelThumbnailView); .into(binding.detailSubChannelThumbnailView);
binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE);
binding.detailUploaderThumbnailView.setVisibility(View.GONE); binding.detailUploaderThumbnailView.setVisibility(View.GONE);
@ -1645,10 +1617,10 @@ public final class VideoDetailFragment
binding.detailUploaderTextView.setVisibility(View.GONE); binding.detailUploaderTextView.setVisibility(View.GONE);
} }
PicassoHelper.loadAvatar(info.getSubChannelAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG) PicassoHelper.loadAvatar(info.getSubChannelAvatarUrl()).tag(PICASSO_VIDEO_DETAILS_TAG)
.into(binding.detailSubChannelThumbnailView); .into(binding.detailSubChannelThumbnailView);
binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE);
PicassoHelper.loadAvatar(info.getUploaderAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG) PicassoHelper.loadAvatar(info.getUploaderAvatarUrl()).tag(PICASSO_VIDEO_DETAILS_TAG)
.into(binding.detailUploaderThumbnailView); .into(binding.detailUploaderThumbnailView);
binding.detailUploaderThumbnailView.setVisibility(View.VISIBLE); binding.detailUploaderThumbnailView.setVisibility(View.VISIBLE);
} }
@ -1736,7 +1708,7 @@ public final class VideoDetailFragment
playQueue = queue; playQueue = queue;
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "onQueueUpdate() called with: serviceId = [" Log.d(TAG, "onQueueUpdate() called with: serviceId = ["
+ serviceId + "], url = [" + url + "], name = [" + serviceId + "], videoUrl = [" + url + "], name = ["
+ title + "], playQueue = [" + playQueue + "]"); + title + "], playQueue = [" + playQueue + "]");
} }
@ -1823,7 +1795,7 @@ public final class VideoDetailFragment
return; return;
} }
updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails()); updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnailUrl());
if (currentInfo != null && info.getUrl().equals(currentInfo.getUrl())) { if (currentInfo != null && info.getUrl().equals(currentInfo.getUrl())) {
return; return;
} }
@ -1852,7 +1824,7 @@ public final class VideoDetailFragment
if (currentInfo != null) { if (currentInfo != null) {
updateOverlayData(currentInfo.getName(), updateOverlayData(currentInfo.getName(),
currentInfo.getUploaderName(), currentInfo.getUploaderName(),
currentInfo.getThumbnails()); currentInfo.getThumbnailUrl());
} }
updateOverlayPlayQueueButtonVisibility(); updateOverlayPlayQueueButtonVisibility();
} }
@ -2217,7 +2189,7 @@ public final class VideoDetailFragment
playerHolder.stopService(); playerHolder.stopService();
setInitialData(0, null, "", null); setInitialData(0, null, "", null);
currentInfo = null; currentInfo = null;
updateOverlayData(null, null, List.of()); updateOverlayData(null, null, null);
} }
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
@ -2399,11 +2371,11 @@ public final class VideoDetailFragment
private void updateOverlayData(@Nullable final String overlayTitle, private void updateOverlayData(@Nullable final String overlayTitle,
@Nullable final String uploader, @Nullable final String uploader,
@NonNull final List<Image> thumbnails) { @Nullable final String thumbnailUrl) {
binding.overlayTitleTextView.setText(isEmpty(overlayTitle) ? "" : overlayTitle); binding.overlayTitleTextView.setText(isEmpty(overlayTitle) ? "" : overlayTitle);
binding.overlayChannelTextView.setText(isEmpty(uploader) ? "" : uploader); binding.overlayChannelTextView.setText(isEmpty(uploader) ? "" : uploader);
binding.overlayThumbnail.setImageDrawable(null); binding.overlayThumbnail.setImageDrawable(null);
PicassoHelper.loadDetailsThumbnail(thumbnails).tag(PICASSO_VIDEO_DETAILS_TAG) PicassoHelper.loadDetailsThumbnail(thumbnailUrl).tag(PICASSO_VIDEO_DETAILS_TAG)
.into(binding.overlayThumbnail); .into(binding.overlayThumbnail);
} }

View File

@ -1,7 +1,5 @@
package org.schabi.newpipe.fragments.list; package org.schabi.newpipe.fragments.list;
import static org.schabi.newpipe.extractor.ServiceList.SoundCloud;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
@ -9,15 +7,13 @@ import android.view.View;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import com.evernote.android.state.State;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.ListInfo;
import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.Page;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.views.NewPipeRecyclerView; import org.schabi.newpipe.views.NewPipeRecyclerView;
@ -26,6 +22,7 @@ import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Queue; import java.util.Queue;
import icepick.State;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
@ -144,7 +141,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
currentWorker = loadResult(forceLoad) currentWorker = loadResult(forceLoad)
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe((@NonNull final L result) -> { .subscribe((@NonNull L result) -> {
isLoading.set(false); isLoading.set(false);
currentInfo = result; currentInfo = result;
currentNextPage = result.getNextPage(); currentNextPage = result.getNextPage();
@ -232,11 +229,13 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
if (!result.getRelatedItems().isEmpty()) { if (!result.getRelatedItems().isEmpty()) {
infoListAdapter.addInfoItemList(result.getRelatedItems()); infoListAdapter.addInfoItemList(result.getRelatedItems());
showListFooter(hasMoreItems()); showListFooter(hasMoreItems());
} else if (hasMoreItems()) {
loadMoreItems();
} else { } else {
infoListAdapter.clearStreamItemList(); 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();
}
} }
} }
@ -253,20 +252,6 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
} }
} }
@Override
public void showEmptyState() {
// show "no streams" for SoundCloud; otherwise "no videos"
// showing "no live streams" is handled in KioskFragment
if (emptyStateView != null) {
if (currentInfo.getService() == SoundCloud) {
setEmptyStateMessage(R.string.no_streams);
} else {
setEmptyStateMessage(R.string.no_videos);
}
}
super.showEmptyState();
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Utils // Utils
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/

View File

@ -1,94 +0,0 @@
package org.schabi.newpipe.fragments.list.channel;
import static org.schabi.newpipe.extractor.stream.StreamExtractor.UNKNOWN_SUBSCRIBER_COUNT;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.evernote.android.state.State;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.fragments.detail.BaseDescriptionFragment;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.Localization;
import java.util.List;
public class ChannelAboutFragment extends BaseDescriptionFragment {
@State
protected ChannelInfo channelInfo;
ChannelAboutFragment(@NonNull final ChannelInfo channelInfo) {
this.channelInfo = channelInfo;
}
public ChannelAboutFragment() {
// keep empty constructor for State when resuming fragment from memory
}
@Override
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
binding.constraintLayout.setPadding(0, DeviceUtils.dpToPx(8, requireContext()), 0, 0);
}
@Nullable
@Override
protected Description getDescription() {
return new Description(channelInfo.getDescription(), Description.PLAIN_TEXT);
}
@NonNull
@Override
protected StreamingService getService() {
return channelInfo.getService();
}
@Override
protected int getServiceId() {
return channelInfo.getServiceId();
}
@Nullable
@Override
protected String getStreamUrl() {
return null;
}
@NonNull
@Override
public List<String> getTags() {
return channelInfo.getTags();
}
@Override
protected void setupMetadata(final LayoutInflater inflater,
final LinearLayout layout) {
// There is no upload date available for channels, so hide the relevant UI element
binding.detailUploadDateView.setVisibility(View.GONE);
if (channelInfo == null) {
return;
}
if (channelInfo.getSubscriberCount() != UNKNOWN_SUBSCRIBER_COUNT) {
addMetadataItem(inflater, layout, false, R.string.metadata_subscribers,
Localization.localizeNumber(
requireContext(),
channelInfo.getSubscriberCount()));
}
addImagesMetadataItem(inflater, layout, R.string.metadata_avatars,
channelInfo.getAvatars());
addImagesMetadataItem(inflater, layout, R.string.metadata_banners,
channelInfo.getBanners());
}
}

View File

@ -5,7 +5,6 @@ import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor; import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor;
import android.content.Context; import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Color; import android.graphics.Color;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils; import android.text.TextUtils;
@ -17,51 +16,51 @@ import android.view.MenuInflater;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.Button;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.core.graphics.ColorUtils;
import androidx.core.view.MenuProvider;
import androidx.preference.PreferenceManager;
import com.evernote.android.state.State;
import com.google.android.material.snackbar.Snackbar; import com.google.android.material.snackbar.Snackbar;
import com.google.android.material.tabs.TabLayout;
import com.jakewharton.rxbinding4.view.RxView; import com.jakewharton.rxbinding4.view.RxView;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.NotificationMode; import org.schabi.newpipe.database.subscription.NotificationMode;
import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.databinding.ChannelHeaderBinding;
import org.schabi.newpipe.databinding.FragmentChannelBinding; import org.schabi.newpipe.databinding.FragmentChannelBinding;
import org.schabi.newpipe.databinding.PlaylistControlBinding;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler; import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.fragments.detail.TabAdapter;
import org.schabi.newpipe.ktx.AnimationType; import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.local.feed.notifications.NotificationHelper;
import org.schabi.newpipe.local.subscription.SubscriptionManager; import org.schabi.newpipe.local.subscription.SubscriptionManager;
import org.schabi.newpipe.util.ChannelTabHelper; import org.schabi.newpipe.local.feed.notifications.NotificationHelper;
import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.StateSaver; import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
import java.util.List; import java.util.List;
import java.util.Queue;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.functions.Action; import io.reactivex.rxjava3.functions.Action;
@ -69,38 +68,29 @@ import io.reactivex.rxjava3.functions.Consumer;
import io.reactivex.rxjava3.functions.Function; import io.reactivex.rxjava3.functions.Function;
import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.schedulers.Schedulers;
public class ChannelFragment extends BaseStateFragment<ChannelInfo> public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, ChannelInfo>
implements StateSaver.WriteRead { implements View.OnClickListener {
private static final int BUTTON_DEBOUNCE_INTERVAL = 100; private static final int BUTTON_DEBOUNCE_INTERVAL = 100;
private static final String PICASSO_CHANNEL_TAG = "PICASSO_CHANNEL_TAG"; private static final String PICASSO_CHANNEL_TAG = "PICASSO_CHANNEL_TAG";
@State
protected int serviceId = Constants.NO_SERVICE_ID;
@State
protected String name;
@State
protected String url;
private ChannelInfo currentInfo;
private Disposable currentWorker;
private final CompositeDisposable disposables = new CompositeDisposable(); private final CompositeDisposable disposables = new CompositeDisposable();
private Disposable subscribeButtonMonitor; private Disposable subscribeButtonMonitor;
private SubscriptionManager subscriptionManager;
private int lastTab;
private boolean channelContentNotSupported = false; private boolean channelContentNotSupported = false;
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Views // Views
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
private FragmentChannelBinding binding; private SubscriptionManager subscriptionManager;
private TabAdapter tabAdapter;
private FragmentChannelBinding channelBinding;
private ChannelHeaderBinding headerBinding;
private PlaylistControlBinding playlistControlBinding;
private MenuItem menuRssButton; private MenuItem menuRssButton;
private MenuItem menuNotifyButton; private MenuItem menuNotifyButton;
private SubscriptionEntity channelSubscription;
private MenuProvider menuProvider;
public static ChannelFragment getInstance(final int serviceId, final String url, public static ChannelFragment getInstance(final int serviceId, final String url,
final String name) { final String name) {
@ -109,78 +99,22 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
return instance; return instance;
} }
private void setInitialData(final int sid, final String u, final String title) { public ChannelFragment() {
this.serviceId = sid; super(UserAction.REQUESTED_CHANNEL);
this.url = u;
this.name = !TextUtils.isEmpty(title) ? title : "";
} }
@Override
public void onResume() {
super.onResume();
if (activity != null && useAsFrontPage) {
setTitle(currentInfo != null ? currentInfo.getName() : name);
}
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// LifeCycle // LifeCycle
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
menuProvider = new MenuProvider() {
@Override
public void onCreateMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) {
inflater.inflate(R.menu.menu_channel, menu);
if (DEBUG) {
Log.d(TAG, "onCreateOptionsMenu() called with: "
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
}
}
@Override
public void onPrepareMenu(@NonNull final Menu menu) {
menuRssButton = menu.findItem(R.id.menu_item_rss);
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
updateRssButton();
updateNotifyButton(channelSubscription);
}
@Override
public boolean onMenuItemSelected(@NonNull final MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_item_notify:
final boolean value = !item.isChecked();
item.setEnabled(false);
setNotify(value);
break;
case R.id.action_settings:
NavigationHelper.openSettings(requireContext());
break;
case R.id.menu_item_rss:
if (currentInfo != null) {
ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl());
}
break;
case R.id.menu_item_openInBrowser:
if (currentInfo != null) {
ShareUtils.openUrlInBrowser(requireContext(),
currentInfo.getOriginalUrl());
}
break;
case R.id.menu_item_share:
if (currentInfo != null) {
ShareUtils.shareText(requireContext(), name,
currentInfo.getOriginalUrl(), currentInfo.getAvatars());
}
break;
default:
return false;
}
return true;
}
};
activity.addMenuProvider(menuProvider);
}
@Override @Override
public void onAttach(@NonNull final Context context) { public void onAttach(@NonNull final Context context) {
super.onAttach(context); super.onAttach(context);
@ -191,57 +125,103 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
public View onCreateView(@NonNull final LayoutInflater inflater, public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container, @Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) { @Nullable final Bundle savedInstanceState) {
binding = FragmentChannelBinding.inflate(inflater, container, false); return inflater.inflate(R.layout.fragment_channel, container, false);
return binding.getRoot();
} }
@Override // called from onViewCreated in BaseFragment.onViewCreated @Override
protected void initViews(final View rootView, final Bundle savedInstanceState) { public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState); super.onViewCreated(rootView, savedInstanceState);
channelBinding = FragmentChannelBinding.bind(rootView);
showContentNotSupportedIfNeeded();
}
tabAdapter = new TabAdapter(getChildFragmentManager()); @Override
binding.viewPager.setAdapter(tabAdapter); public void onDestroy() {
binding.tabLayout.setupWithViewPager(binding.viewPager); super.onDestroy();
disposables.clear();
setTitle(name); if (subscribeButtonMonitor != null) {
binding.channelTitleView.setText(name); subscribeButtonMonitor.dispose();
if (!ImageStrategy.shouldLoadImages()) {
// do not waste space for the banner if it is not going to be loaded
binding.channelBannerImage.setImageDrawable(null);
} }
channelBinding = null;
headerBinding = null;
playlistControlBinding = null;
}
/*//////////////////////////////////////////////////////////////////////////
// Init
//////////////////////////////////////////////////////////////////////////*/
@Override
protected Supplier<View> getListHeaderSupplier() {
headerBinding = ChannelHeaderBinding
.inflate(activity.getLayoutInflater(), itemsList, false);
playlistControlBinding = headerBinding.playlistControl;
return headerBinding::getRoot;
} }
@Override @Override
protected void initListeners() { protected void initListeners() {
super.initListeners(); super.initListeners();
final View.OnClickListener openSubChannel = v -> { headerBinding.subChannelTitleView.setOnClickListener(this);
if (!TextUtils.isEmpty(currentInfo.getParentChannelUrl())) { headerBinding.subChannelAvatarView.setOnClickListener(this);
try { }
NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(),
currentInfo.getParentChannelUrl(), /*//////////////////////////////////////////////////////////////////////////
currentInfo.getParentChannelName()); // Menu
} catch (final Exception e) { //////////////////////////////////////////////////////////////////////////*/
ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e);
} @Override
} else if (DEBUG) { public void onCreateOptionsMenu(@NonNull final Menu menu,
Log.i(TAG, "Can't open parent channel because we got no channel URL"); @NonNull final MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater);
final ActionBar supportActionBar = activity.getSupportActionBar();
if (useAsFrontPage && supportActionBar != null) {
supportActionBar.setDisplayHomeAsUpEnabled(false);
} else {
inflater.inflate(R.menu.menu_channel, menu);
if (DEBUG) {
Log.d(TAG, "onCreateOptionsMenu() called with: "
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
} }
}; menuRssButton = menu.findItem(R.id.menu_item_rss);
binding.subChannelAvatarView.setOnClickListener(openSubChannel); menuNotifyButton = menu.findItem(R.id.menu_item_notify);
binding.subChannelTitleView.setOnClickListener(openSubChannel); }
} }
@Override @Override
public void onDestroy() { public boolean onOptionsItemSelected(final MenuItem item) {
super.onDestroy(); switch (item.getItemId()) {
if (currentWorker != null) { case R.id.action_settings:
currentWorker.dispose(); NavigationHelper.openSettings(requireContext());
break;
case R.id.menu_item_notify:
final boolean value = !item.isChecked();
item.setEnabled(false);
setNotify(value);
break;
case R.id.menu_item_rss:
if (currentInfo != null) {
ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl());
}
break;
case R.id.menu_item_openInBrowser:
if (currentInfo != null) {
ShareUtils.openUrlInBrowser(requireContext(), currentInfo.getOriginalUrl());
}
break;
case R.id.menu_item_share:
if (currentInfo != null) {
ShareUtils.shareText(requireContext(), name, currentInfo.getOriginalUrl(),
currentInfo.getAvatarUrl());
}
break;
default:
return super.onOptionsItemSelected(item);
} }
disposables.clear(); return true;
binding = null;
activity.removeMenuProvider(menuProvider);
menuProvider = null;
} }
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
@ -249,8 +229,8 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
private void monitorSubscription(final ChannelInfo info) { private void monitorSubscription(final ChannelInfo info) {
final Consumer<Throwable> onError = (final Throwable throwable) -> { final Consumer<Throwable> onError = (Throwable throwable) -> {
animate(binding.channelSubscribeButton, false, 100); animate(headerBinding.channelSubscribeButton, false, 100);
showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_GET, showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_GET,
"Get subscription status", currentInfo)); "Get subscription status", currentInfo));
}; };
@ -283,15 +263,16 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
}, onError)); }, onError));
} }
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription) { private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription,
return (@NonNull final Object o) -> { final ChannelInfo info) {
subscriptionManager.insertSubscription(subscription); return (@NonNull Object o) -> {
subscriptionManager.insertSubscription(subscription, info);
return o; return o;
}; };
} }
private Function<Object, Object> mapOnUnsubscribe(final SubscriptionEntity subscription) { private Function<Object, Object> mapOnUnsubscribe(final SubscriptionEntity subscription) {
return (@NonNull final Object o) -> { return (@NonNull Object o) -> {
subscriptionManager.deleteSubscription(subscription); subscriptionManager.deleteSubscription(subscription);
return o; return o;
}; };
@ -317,8 +298,9 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
.subscribe(onComplete, onError)); .subscribe(onComplete, onError));
} }
private Disposable monitorSubscribeButton(final Function<Object, Object> action) { private Disposable monitorSubscribeButton(final Button subscribeButton,
final Consumer<Object> onNext = (@NonNull final Object o) -> { final Function<Object, Object> action) {
final Consumer<Object> onNext = (@NonNull Object o) -> {
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "Changed subscription status to this channel!"); Log.d(TAG, "Changed subscription status to this channel!");
} }
@ -329,7 +311,7 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
"Changing subscription for " + currentInfo.getUrl(), currentInfo)); "Changing subscription for " + currentInfo.getUrl(), currentInfo));
/* Emit clicks from main thread unto io thread */ /* Emit clicks from main thread unto io thread */
return RxView.clicks(binding.channelSubscribeButton) return RxView.clicks(subscribeButton)
.subscribeOn(AndroidSchedulers.mainThread()) .subscribeOn(AndroidSchedulers.mainThread())
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
.debounce(BUTTON_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS) // Ignore rapid clicks .debounce(BUTTON_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS) // Ignore rapid clicks
@ -338,7 +320,7 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
} }
private Consumer<List<SubscriptionEntity>> getSubscribeUpdateMonitor(final ChannelInfo info) { private Consumer<List<SubscriptionEntity>> getSubscribeUpdateMonitor(final ChannelInfo info) {
return (final List<SubscriptionEntity> subscriptionEntities) -> { return (List<SubscriptionEntity> subscriptionEntities) -> {
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "subscriptionManager.subscriptionTable.doOnNext() called with: " Log.d(TAG, "subscriptionManager.subscriptionTable.doOnNext() called with: "
+ "subscriptionEntities = [" + subscriptionEntities + "]"); + "subscriptionEntities = [" + subscriptionEntities + "]");
@ -355,20 +337,20 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
channel.setServiceId(info.getServiceId()); channel.setServiceId(info.getServiceId());
channel.setUrl(info.getUrl()); channel.setUrl(info.getUrl());
channel.setData(info.getName(), channel.setData(info.getName(),
ImageStrategy.imageListToDbUrl(info.getAvatars()), info.getAvatarUrl(),
info.getDescription(), info.getDescription(),
info.getSubscriberCount()); info.getSubscriberCount());
channelSubscription = null;
updateNotifyButton(null); updateNotifyButton(null);
subscribeButtonMonitor = monitorSubscribeButton(mapOnSubscribe(channel)); subscribeButtonMonitor = monitorSubscribeButton(
headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info));
} else { } else {
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "Found subscription to this channel!"); Log.d(TAG, "Found subscription to this channel!");
} }
channelSubscription = subscriptionEntities.get(0); final SubscriptionEntity subscription = subscriptionEntities.get(0);
updateNotifyButton(channelSubscription); updateNotifyButton(subscription);
subscribeButtonMonitor = subscribeButtonMonitor = monitorSubscribeButton(
monitorSubscribeButton(mapOnUnsubscribe(channelSubscription)); headerBinding.channelSubscribeButton, mapOnUnsubscribe(subscription));
} }
}; };
} }
@ -379,40 +361,34 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
+ "isSubscribed = [" + isSubscribed + "]"); + "isSubscribed = [" + isSubscribed + "]");
} }
final boolean isButtonVisible = binding.channelSubscribeButton.getVisibility() final boolean isButtonVisible = headerBinding.channelSubscribeButton.getVisibility()
== View.VISIBLE; == View.VISIBLE;
final int backgroundDuration = isButtonVisible ? 300 : 0; final int backgroundDuration = isButtonVisible ? 300 : 0;
final int textDuration = isButtonVisible ? 200 : 0; final int textDuration = isButtonVisible ? 200 : 0;
final int subscribeBackground = ThemeHelper
.resolveColorFromAttr(activity, R.attr.colorPrimary);
final int subscribeText = ContextCompat.getColor(activity, R.color.subscribe_text_color);
final int subscribedBackground = ContextCompat final int subscribedBackground = ContextCompat
.getColor(activity, R.color.subscribed_background_color); .getColor(activity, R.color.subscribed_background_color);
final int subscribedText = ContextCompat.getColor(activity, R.color.subscribed_text_color); final int subscribedText = ContextCompat.getColor(activity, R.color.subscribed_text_color);
final int subscribeBackground = ColorUtils.blendARGB(ThemeHelper
.resolveColorFromAttr(activity, R.attr.colorPrimary), subscribedBackground, 0.35f);
final int subscribeText = ContextCompat.getColor(activity, R.color.subscribe_text_color);
if (isSubscribed) { if (!isSubscribed) {
binding.channelSubscribeButton.setText(R.string.subscribed_button_title); headerBinding.channelSubscribeButton.setText(R.string.subscribe_button_title);
animateBackgroundColor(binding.channelSubscribeButton, backgroundDuration, animateBackgroundColor(headerBinding.channelSubscribeButton, backgroundDuration,
subscribeBackground, subscribedBackground);
animateTextColor(binding.channelSubscribeButton, textDuration, subscribeText,
subscribedText);
} else {
binding.channelSubscribeButton.setText(R.string.subscribe_button_title);
animateBackgroundColor(binding.channelSubscribeButton, backgroundDuration,
subscribedBackground, subscribeBackground); subscribedBackground, subscribeBackground);
animateTextColor(binding.channelSubscribeButton, textDuration, subscribedText, animateTextColor(headerBinding.channelSubscribeButton, textDuration, subscribedText,
subscribeText); subscribeText);
} else {
headerBinding.channelSubscribeButton.setText(R.string.subscribed_button_title);
animateBackgroundColor(headerBinding.channelSubscribeButton, backgroundDuration,
subscribeBackground, subscribedBackground);
animateTextColor(headerBinding.channelSubscribeButton, textDuration, subscribeText,
subscribedText);
} }
animate(binding.channelSubscribeButton, true, 100, AnimationType.LIGHT_SCALE_AND_ALPHA); animate(headerBinding.channelSubscribeButton, true, 100,
} AnimationType.LIGHT_SCALE_AND_ALPHA);
private void updateRssButton() {
if (menuRssButton == null || currentInfo == null) {
return;
}
menuRssButton.setVisible(!TextUtils.isEmpty(currentInfo.getFeedUrl()));
} }
private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) { private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) {
@ -448,176 +424,107 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
* Show a snackbar with the option to enable notifications on new streams for this channel. * Show a snackbar with the option to enable notifications on new streams for this channel.
*/ */
private void showNotifySnackbar() { private void showNotifySnackbar() {
Snackbar.make(binding.getRoot(), R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG) Snackbar.make(itemsList, R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG)
.setAction(R.string.get_notified, v -> setNotify(true)) .setAction(R.string.get_notified, v -> setNotify(true))
.setActionTextColor(Color.YELLOW) .setActionTextColor(Color.YELLOW)
.show(); .show();
} }
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Init // Load and handle
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
private void updateTabs() { @Override
tabAdapter.clearAllItems(); protected Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextPage);
}
if (currentInfo != null && !channelContentNotSupported) { @Override
final Context context = requireContext(); protected Single<ChannelInfo> loadResult(final boolean forceLoad) {
final SharedPreferences preferences = PreferenceManager return ExtractorHelper.getChannelInfo(serviceId, url, forceLoad);
.getDefaultSharedPreferences(context); }
for (final ListLinkHandler linkHandler : currentInfo.getTabs()) { /*//////////////////////////////////////////////////////////////////////////
final String tab = linkHandler.getContentFilters().get(0); // OnClick
if (ChannelTabHelper.showChannelTab(context, preferences, tab)) { //////////////////////////////////////////////////////////////////////////*/
final ChannelTabFragment channelTabFragment =
ChannelTabFragment.getInstance(serviceId, linkHandler, name); @Override
channelTabFragment.useAsFrontPage(useAsFrontPage); public void onClick(final View v) {
tabAdapter.addFragment(channelTabFragment, if (isLoading.get() || currentInfo == null) {
context.getString(ChannelTabHelper.getTranslationKey(tab))); return;
}
switch (v.getId()) {
case R.id.sub_channel_avatar_view:
case R.id.sub_channel_title_view:
if (!TextUtils.isEmpty(currentInfo.getParentChannelUrl())) {
try {
NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(),
currentInfo.getParentChannelUrl(),
currentInfo.getParentChannelName());
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e);
}
} else if (DEBUG) {
Log.i(TAG, "Can't open parent channel because we got no channel URL");
} }
} break;
if (ChannelTabHelper.showChannelTab(
context, preferences, R.string.show_channel_tabs_about)) {
tabAdapter.addFragment(
new ChannelAboutFragment(currentInfo),
context.getString(R.string.channel_tab_about));
}
}
tabAdapter.notifyDataSetUpdate();
for (int i = 0; i < tabAdapter.getCount(); i++) {
binding.tabLayout.getTabAt(i).setText(tabAdapter.getItemTitle(i));
}
// Restore previously selected tab
final TabLayout.Tab ltab = binding.tabLayout.getTabAt(lastTab);
if (ltab != null) {
binding.tabLayout.selectTab(ltab);
} }
} }
/*//////////////////////////////////////////////////////////////////////////
// State Saving
//////////////////////////////////////////////////////////////////////////*/
@Override
public String generateSuffix() {
return null;
}
@Override
public void writeTo(final Queue<Object> objectsToSave) {
objectsToSave.add(currentInfo);
objectsToSave.add(binding == null ? 0 : binding.tabLayout.getSelectedTabPosition());
}
@Override
public void readFrom(@NonNull final Queue<Object> savedObjects) {
currentInfo = (ChannelInfo) savedObjects.poll();
lastTab = (Integer) savedObjects.poll();
}
@Override
public void onSaveInstanceState(final @NonNull Bundle outState) {
super.onSaveInstanceState(outState);
if (binding != null) {
outState.putInt("LastTab", binding.tabLayout.getSelectedTabPosition());
}
}
@Override
protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
lastTab = savedInstanceState.getInt("LastTab", 0);
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Contract // Contract
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override
protected void doInitialLoadLogic() {
if (currentInfo == null) {
startLoading(false);
} else {
handleResult(currentInfo);
}
}
@Override
public void startLoading(final boolean forceLoad) {
super.startLoading(forceLoad);
currentInfo = null;
updateTabs();
if (currentWorker != null) {
currentWorker.dispose();
}
runWorker(forceLoad);
}
private void runWorker(final boolean forceLoad) {
currentWorker = ExtractorHelper.getChannelInfo(serviceId, url, forceLoad)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(result -> {
isLoading.set(false);
handleResult(result);
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_CHANNEL,
url == null ? "No URL" : url, serviceId)));
}
@Override @Override
public void showLoading() { public void showLoading() {
super.showLoading(); super.showLoading();
PicassoHelper.cancelTag(PICASSO_CHANNEL_TAG); PicassoHelper.cancelTag(PICASSO_CHANNEL_TAG);
animate(binding.channelSubscribeButton, false, 100); animate(headerBinding.channelSubscribeButton, false, 100);
} }
@Override @Override
public void handleResult(@NonNull final ChannelInfo result) { public void handleResult(@NonNull final ChannelInfo result) {
super.handleResult(result); super.handleResult(result);
currentInfo = result;
setInitialData(result.getServiceId(), result.getOriginalUrl(), result.getName());
if (ImageStrategy.shouldLoadImages() && !result.getBanners().isEmpty()) { headerBinding.getRoot().setVisibility(View.VISIBLE);
PicassoHelper.loadBanner(result.getBanners()).tag(PICASSO_CHANNEL_TAG) PicassoHelper.loadBanner(result.getBannerUrl()).tag(PICASSO_CHANNEL_TAG)
.into(binding.channelBannerImage); .into(headerBinding.channelBannerImage);
} else { PicassoHelper.loadAvatar(result.getAvatarUrl()).tag(PICASSO_CHANNEL_TAG)
// do not waste space for the banner, if the user disabled images or there is not one .into(headerBinding.channelAvatarView);
binding.channelBannerImage.setImageDrawable(null); PicassoHelper.loadAvatar(result.getParentChannelAvatarUrl()).tag(PICASSO_CHANNEL_TAG)
} .into(headerBinding.subChannelAvatarView);
PicassoHelper.loadAvatar(result.getAvatars()).tag(PICASSO_CHANNEL_TAG) headerBinding.channelSubscriberView.setVisibility(View.VISIBLE);
.into(binding.channelAvatarView);
PicassoHelper.loadAvatar(result.getParentChannelAvatars()).tag(PICASSO_CHANNEL_TAG)
.into(binding.subChannelAvatarView);
binding.channelTitleView.setText(result.getName());
binding.channelSubscriberView.setVisibility(View.VISIBLE);
if (result.getSubscriberCount() >= 0) { if (result.getSubscriberCount() >= 0) {
binding.channelSubscriberView.setText(Localization headerBinding.channelSubscriberView.setText(Localization
.shortSubscriberCount(activity, result.getSubscriberCount())); .shortSubscriberCount(activity, result.getSubscriberCount()));
} else { } else {
binding.channelSubscriberView.setText(R.string.subscribers_count_not_available); headerBinding.channelSubscriberView.setText(R.string.subscribers_count_not_available);
} }
if (!TextUtils.isEmpty(currentInfo.getParentChannelName())) { if (!TextUtils.isEmpty(currentInfo.getParentChannelName())) {
binding.subChannelTitleView.setText(String.format( headerBinding.subChannelTitleView.setText(String.format(
getString(R.string.channel_created_by), getString(R.string.channel_created_by),
currentInfo.getParentChannelName()) currentInfo.getParentChannelName())
); );
binding.subChannelTitleView.setVisibility(View.VISIBLE); headerBinding.subChannelTitleView.setVisibility(View.VISIBLE);
binding.subChannelAvatarView.setVisibility(View.VISIBLE); headerBinding.subChannelAvatarView.setVisibility(View.VISIBLE);
} else {
headerBinding.subChannelTitleView.setVisibility(View.GONE);
} }
updateRssButton(); if (menuRssButton != null) {
menuRssButton.setVisible(!TextUtils.isEmpty(result.getFeedUrl()));
}
// PlaylistControls should be visible only if there is some item in
// infoListAdapter other than header
if (infoListAdapter.getItemCount() != 1) {
playlistControlBinding.getRoot().setVisibility(View.VISIBLE);
} else {
playlistControlBinding.getRoot().setVisibility(View.GONE);
}
channelContentNotSupported = false; channelContentNotSupported = false;
for (final Throwable throwable : result.getErrors()) { for (final Throwable throwable : result.getErrors()) {
@ -632,21 +539,62 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
if (subscribeButtonMonitor != null) { if (subscribeButtonMonitor != null) {
subscribeButtonMonitor.dispose(); subscribeButtonMonitor.dispose();
} }
updateTabs();
updateSubscription(result); updateSubscription(result);
monitorSubscription(result); monitorSubscription(result);
playlistControlBinding.playlistCtrlPlayAllButton
.setOnClickListener(view -> NavigationHelper
.playOnMainPlayer(activity, getPlayQueue()));
playlistControlBinding.playlistCtrlPlayPopupButton
.setOnClickListener(view -> NavigationHelper
.playOnPopupPlayer(activity, getPlayQueue(), false));
playlistControlBinding.playlistCtrlPlayBgButton
.setOnClickListener(view -> NavigationHelper
.playOnBackgroundPlayer(activity, getPlayQueue(), false));
playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.POPUP);
return true;
});
playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.AUDIO);
return true;
});
} }
private void showContentNotSupportedIfNeeded() { private void showContentNotSupportedIfNeeded() {
// channelBinding might not be initialized when handleResult() is called // channelBinding might not be initialized when handleResult() is called
// (e.g. after rotating the screen, #6696) // (e.g. after rotating the screen, #6696)
if (!channelContentNotSupported || binding == null) { if (!channelContentNotSupported || channelBinding == null) {
return; return;
} }
binding.errorContentNotSupported.setVisibility(View.VISIBLE); channelBinding.errorContentNotSupported.setVisibility(View.VISIBLE);
binding.channelKaomoji.setText("(︶︹︺)"); channelBinding.channelKaomoji.setText("(︶︹︺)");
binding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f); channelBinding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f);
channelBinding.channelNoVideos.setVisibility(View.GONE);
}
private PlayQueue getPlayQueue() {
final List<StreamInfoItem> streamItems = infoListAdapter.getItemsList().stream()
.filter(StreamInfoItem.class::isInstance)
.map(StreamInfoItem.class::cast)
.collect(Collectors.toList());
return new ChannelPlayQueue(currentInfo.getServiceId(), currentInfo.getUrl(),
currentInfo.getNextPage(), streamItems, 0);
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
@Override
public void setTitle(final String title) {
super.setTitle(title);
if (!useAsFrontPage) {
headerBinding.channelTitleView.setText(title);
}
} }
} }

View File

@ -1,170 +0,0 @@
package org.schabi.newpipe.fragments.list.channel;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.evernote.android.state.State;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.PlaylistControlBinding;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory;
import org.schabi.newpipe.extractor.linkhandler.ReadyChannelTabListLinkHandler;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.util.ChannelTabHelper;
import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.PlayButtonHelper;
import java.util.List;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import io.reactivex.rxjava3.core.Single;
public class ChannelTabFragment extends BaseListInfoFragment<InfoItem, ChannelTabInfo>
implements PlaylistControlViewHolder {
// states must be protected and not private for State being able to access them
@State
protected ListLinkHandler tabHandler;
@State
protected String channelName;
private PlaylistControlBinding playlistControlBinding;
@NonNull
public static ChannelTabFragment getInstance(final int serviceId,
final ListLinkHandler tabHandler,
final String channelName) {
final ChannelTabFragment instance = new ChannelTabFragment();
instance.serviceId = serviceId;
instance.tabHandler = tabHandler;
instance.channelName = channelName;
return instance;
}
public ChannelTabFragment() {
super(UserAction.REQUESTED_CHANNEL);
}
/*//////////////////////////////////////////////////////////////////////////
// LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setHasOptionsMenu(false);
}
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_channel_tab, container, false);
}
@Override
public void onDestroyView() {
super.onDestroyView();
playlistControlBinding = null;
}
@Override
protected Supplier<View> getListHeaderSupplier() {
if (ChannelTabHelper.isStreamsTab(tabHandler)) {
playlistControlBinding = PlaylistControlBinding
.inflate(activity.getLayoutInflater(), itemsList, false);
return playlistControlBinding::getRoot;
}
return null;
}
@Override
protected Single<ChannelTabInfo> loadResult(final boolean forceLoad) {
return ExtractorHelper.getChannelTab(serviceId, tabHandler, forceLoad);
}
@Override
protected Single<ListExtractor.InfoItemsPage<InfoItem>> loadMoreItemsLogic() {
return ExtractorHelper.getMoreChannelTabItems(serviceId, tabHandler, currentNextPage);
}
@Override
public void setTitle(final String title) {
// The channel name is displayed as title in the toolbar.
// The title is always a description of the content of the tab fragment.
// It should be unique for each channel because multiple channel tabs
// can be added to the main page. Therefore, the channel name is used.
// Using the title variable would cause the title to be the same for all channel tabs.
super.setTitle(channelName);
}
@Override
public void handleResult(@NonNull final ChannelTabInfo result) {
super.handleResult(result);
// FIXME this is a really hacky workaround, to avoid storing useless data in the fragment
// state. The problem is, `ReadyChannelTabListLinkHandler` might contain raw JSON data that
// uses a lot of memory (e.g. ~800KB for YouTube). While 800KB doesn't seem much, if
// you combine just a couple of channel tab fragments you easily go over the 1MB
// save&restore transaction limit, and get `TransactionTooLargeException`s. A proper
// solution would require rethinking about `ReadyChannelTabListLinkHandler`s.
if (tabHandler instanceof ReadyChannelTabListLinkHandler) {
try {
// once `handleResult` is called, the parsed data was already saved to cache, so
// we can discard any raw data in ReadyChannelTabListLinkHandler and create a
// link handler with identical properties, but without any raw data
final ListLinkHandlerFactory channelTabLHFactory = result.getService()
.getChannelTabLHFactory();
if (channelTabLHFactory != null) {
// some services do not not have a ChannelTabLHFactory
tabHandler = channelTabLHFactory.fromQuery(tabHandler.getId(),
tabHandler.getContentFilters(), tabHandler.getSortFilter());
}
} catch (final ParsingException e) {
// silently ignore the error, as the app can continue to function normally
Log.w(TAG, "Could not recreate channel tab handler", e);
}
}
if (playlistControlBinding != null) {
// PlaylistControls should be visible only if there is some item in
// infoListAdapter other than header
if (infoListAdapter.getItemCount() > 1) {
playlistControlBinding.getRoot().setVisibility(View.VISIBLE);
} else {
playlistControlBinding.getRoot().setVisibility(View.GONE);
}
PlayButtonHelper.initPlaylistControlClickListener(
activity, playlistControlBinding, this);
}
}
@Override
public PlayQueue getPlayQueue() {
final List<StreamInfoItem> streamItems = infoListAdapter.getItemsList().stream()
.filter(StreamInfoItem.class::isInstance)
.map(StreamInfoItem.class::cast)
.collect(Collectors.toList());
return new ChannelTabPlayQueue(currentInfo.getServiceId(), tabHandler,
currentInfo.getNextPage(), streamItems, 0);
}
}

View File

@ -1,171 +0,0 @@
package org.schabi.newpipe.fragments.list.comments;
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.core.text.HtmlCompat;
import com.evernote.android.state.State;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.CommentRepliesHeaderBinding;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.text.TextLinkifier;
import java.util.Queue;
import java.util.function.Supplier;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public final class CommentRepliesFragment
extends BaseListInfoFragment<CommentsInfoItem, CommentRepliesInfo> {
public static final String TAG = CommentRepliesFragment.class.getSimpleName();
@State
CommentsInfoItem commentsInfoItem; // the comment to show replies of
private final CompositeDisposable disposables = new CompositeDisposable();
/*//////////////////////////////////////////////////////////////////////////
// Constructors and lifecycle
//////////////////////////////////////////////////////////////////////////*/
// only called by the Android framework, after which readFrom is called and restores all data
public CommentRepliesFragment() {
super(UserAction.REQUESTED_COMMENT_REPLIES);
}
public CommentRepliesFragment(@NonNull final CommentsInfoItem commentsInfoItem) {
this();
this.commentsInfoItem = commentsInfoItem;
// setting "" as title since the title will be properly set right after
setInitialData(commentsInfoItem.getServiceId(), commentsInfoItem.getUrl(), "");
}
@Nullable
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_comments, container, false);
}
@Override
public void onDestroyView() {
disposables.clear();
super.onDestroyView();
}
@Override
protected Supplier<View> getListHeaderSupplier() {
return () -> {
final CommentRepliesHeaderBinding binding = CommentRepliesHeaderBinding
.inflate(activity.getLayoutInflater(), itemsList, false);
final CommentsInfoItem item = commentsInfoItem;
// load the author avatar
PicassoHelper.loadAvatar(item.getUploaderAvatars()).into(binding.authorAvatar);
binding.authorAvatar.setVisibility(ImageStrategy.shouldLoadImages()
? View.VISIBLE : View.GONE);
// setup author name and comment date
binding.authorName.setText(item.getUploaderName());
binding.uploadDate.setText(Localization.relativeTimeOrTextual(
getContext(), item.getUploadDate(), item.getTextualUploadDate()));
binding.authorTouchArea.setOnClickListener(
v -> NavigationHelper.openCommentAuthorIfPresent(requireActivity(), item));
// setup like count, hearted and pinned
binding.thumbsUpCount.setText(
Localization.likeCount(requireContext(), item.getLikeCount()));
// for heartImage goneMarginEnd was used, but there is no way to tell ConstraintLayout
// not to use a different margin only when both the next two views are gone
((ConstraintLayout.LayoutParams) binding.thumbsUpCount.getLayoutParams())
.setMarginEnd(DeviceUtils.dpToPx(
(item.isHeartedByUploader() || item.isPinned() ? 8 : 16),
requireContext()));
binding.heartImage.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE);
binding.pinnedImage.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
// setup comment content
TextLinkifier.fromDescription(binding.commentContent, item.getCommentText(),
HtmlCompat.FROM_HTML_MODE_LEGACY, getServiceById(item.getServiceId()),
item.getUrl(), disposables, null);
return binding.getRoot();
};
}
/*//////////////////////////////////////////////////////////////////////////
// State saving
//////////////////////////////////////////////////////////////////////////*/
@Override
public void writeTo(final Queue<Object> objectsToSave) {
super.writeTo(objectsToSave);
objectsToSave.add(commentsInfoItem);
}
@Override
public void readFrom(@NonNull final Queue<Object> savedObjects) throws Exception {
super.readFrom(savedObjects);
commentsInfoItem = (CommentsInfoItem) savedObjects.poll();
}
/*//////////////////////////////////////////////////////////////////////////
// Data loading
//////////////////////////////////////////////////////////////////////////*/
@Override
protected Single<CommentRepliesInfo> loadResult(final boolean forceLoad) {
return Single.fromCallable(() -> new CommentRepliesInfo(commentsInfoItem,
// the reply count string will be shown as the activity title
Localization.replyCount(requireContext(), commentsInfoItem.getReplyCount())));
}
@Override
protected Single<ListExtractor.InfoItemsPage<CommentsInfoItem>> loadMoreItemsLogic() {
// commentsInfoItem.getUrl() should contain the url of the original
// ListInfo<CommentsInfoItem>, which should be the stream url
return ExtractorHelper.getMoreCommentItems(
serviceId, commentsInfoItem.getUrl(), currentNextPage);
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
@Override
protected ItemViewMode getItemViewMode() {
return ItemViewMode.LIST;
}
/**
* @return the comment to which the replies are shown
*/
public CommentsInfoItem getCommentsInfoItem() {
return commentsInfoItem;
}
}

View File

@ -1,22 +0,0 @@
package org.schabi.newpipe.fragments.list.comments;
import org.schabi.newpipe.extractor.ListInfo;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import java.util.Collections;
public final class CommentRepliesInfo extends ListInfo<CommentsInfoItem> {
/**
* This class is used to wrap the comment replies page into a ListInfo object.
*
* @param comment the comment from which to get replies
* @param name will be shown as the fragment title
*/
public CommentRepliesInfo(final CommentsInfoItem comment, final String name) {
super(comment.getServiceId(),
new ListLinkHandler("", "", "", Collections.emptyList(), null), name);
setNextPage(comment.getReplies());
setRelatedItems(Collections.emptyList()); // since it must be non-null
}
}

View File

@ -110,14 +110,4 @@ public class CommentsFragment extends BaseListInfoFragment<CommentsInfoItem, Com
protected ItemViewMode getItemViewMode() { protected ItemViewMode getItemViewMode() {
return ItemViewMode.LIST; return ItemViewMode.LIST;
} }
public boolean scrollToComment(final CommentsInfoItem comment) {
final int position = infoListAdapter.getItemsList().indexOf(comment);
if (position < 0) {
return false;
}
itemsList.scrollToPosition(position);
return true;
}
} }

View File

@ -11,26 +11,23 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBar;
import com.evernote.android.state.State;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.kiosk.KioskInfo; import org.schabi.newpipe.extractor.kiosk.KioskInfo;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory;
import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.localization.ContentCountry;
import org.schabi.newpipe.extractor.services.media_ccc.extractors.MediaCCCLiveStreamKiosk;
import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.KioskTranslator; import org.schabi.newpipe.util.KioskTranslator;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import icepick.State;
import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.core.Single;
/** /**
@ -164,14 +161,4 @@ public class KioskFragment extends BaseListInfoFragment<StreamInfoItem, KioskInf
name = kioskTranslatedName; name = kioskTranslatedName;
setTitle(kioskTranslatedName); setTitle(kioskTranslatedName);
} }
@Override
public void showEmptyState() {
// show "no live streams" for live stream kiosk
super.showEmptyState();
if (MediaCCCLiveStreamKiosk.KIOSK_ID.equals(currentInfo.getId())
&& ServiceList.MediaCCC.getServiceId() == currentInfo.getServiceId()) {
setEmptyStateMessage(R.string.no_live_streams);
}
}
} }

View File

@ -1,11 +0,0 @@
package org.schabi.newpipe.fragments.list.playlist;
import org.schabi.newpipe.player.playqueue.PlayQueue;
/**
* Interface for {@code R.layout.playlist_control} view holders
* to give access to the play queue.
*/
public interface PlaylistControlViewHolder {
PlayQueue getPlayQueue();
}

View File

@ -1,9 +1,7 @@
package org.schabi.newpipe.fragments.list.playlist; package org.schabi.newpipe.fragments.list.playlist;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling; import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
import android.content.Context; import android.content.Context;
import android.os.Bundle; import android.os.Bundle;
@ -39,22 +37,20 @@ import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.ServiceList; import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper; import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog; import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.local.playlist.RemotePlaylistManager; import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PlayButtonHelper; import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.text.TextEllipsizer;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -68,8 +64,7 @@ import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, PlaylistInfo> public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, PlaylistInfo> {
implements PlaylistControlViewHolder {
private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG"; private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG";
@ -89,9 +84,6 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
private MenuItem playlistBookmarkButton; private MenuItem playlistBookmarkButton;
private long streamCount;
private long playlistOverallDurationSeconds;
public static PlaylistFragment getInstance(final int serviceId, final String url, public static PlaylistFragment getInstance(final int serviceId, final String url,
final String name) { final String name) {
final PlaylistFragment instance = new PlaylistFragment(); final PlaylistFragment instance = new PlaylistFragment();
@ -241,7 +233,7 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
break; break;
case R.id.menu_item_share: case R.id.menu_item_share:
ShareUtils.shareText(requireContext(), name, url, ShareUtils.shareText(requireContext(), name, url,
currentInfo == null ? List.of() : currentInfo.getThumbnails()); currentInfo == null ? null : currentInfo.getThumbnailUrl());
break; break;
case R.id.menu_item_bookmark: case R.id.menu_item_bookmark:
onBookmarkClicked(); onBookmarkClicked();
@ -280,12 +272,6 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
animate(headerBinding.uploaderLayout, false, 200); animate(headerBinding.uploaderLayout, false, 200);
} }
@Override
public void handleNextItems(final ListExtractor.InfoItemsPage result) {
super.handleNextItems(result);
setStreamCountAndOverallDuration(result.getItems(), !result.hasNextPage());
}
@Override @Override
public void handleResult(@NonNull final PlaylistInfo result) { public void handleResult(@NonNull final PlaylistInfo result) {
super.handleResult(result); super.handleResult(result);
@ -312,6 +298,7 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
playlistControlBinding.getRoot().setVisibility(View.VISIBLE); playlistControlBinding.getRoot().setVisibility(View.VISIBLE);
final String avatarUrl = result.getUploaderAvatarUrl();
if (result.getServiceId() == ServiceList.YouTube.getServiceId() if (result.getServiceId() == ServiceList.YouTube.getServiceId()
&& (YoutubeParsingHelper.isYoutubeMixId(result.getId()) && (YoutubeParsingHelper.isYoutubeMixId(result.getId())
|| YoutubeParsingHelper.isYoutubeMusicMixId(result.getId()))) { || YoutubeParsingHelper.isYoutubeMusicMixId(result.getId()))) {
@ -327,36 +314,12 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
R.drawable.ic_radio) R.drawable.ic_radio)
); );
} else { } else {
PicassoHelper.loadAvatar(result.getUploaderAvatars()).tag(PICASSO_PLAYLIST_TAG) PicassoHelper.loadAvatar(avatarUrl).tag(PICASSO_PLAYLIST_TAG)
.into(headerBinding.uploaderAvatarView); .into(headerBinding.uploaderAvatarView);
} }
streamCount = result.getStreamCount(); headerBinding.playlistStreamCount.setText(Localization
setStreamCountAndOverallDuration(result.getRelatedItems(), !result.hasNextPage()); .localizeStreamCount(getContext(), result.getStreamCount()));
final Description description = result.getDescription();
if (description != null && description != Description.EMPTY_DESCRIPTION
&& !isBlank(description.getContent())) {
final TextEllipsizer ellipsizer = new TextEllipsizer(
headerBinding.playlistDescription, 5, getServiceById(result.getServiceId()));
ellipsizer.setStateChangeListener(isEllipsized ->
headerBinding.playlistDescriptionReadMore.setText(
Boolean.TRUE.equals(isEllipsized) ? R.string.show_more : R.string.show_less
));
ellipsizer.setOnContentChanged(canBeEllipsized -> {
headerBinding.playlistDescriptionReadMore.setVisibility(
Boolean.TRUE.equals(canBeEllipsized) ? View.VISIBLE : View.GONE);
if (Boolean.TRUE.equals(canBeEllipsized)) {
ellipsizer.ellipsize();
}
});
ellipsizer.setContent(description);
headerBinding.playlistDescriptionReadMore.setOnClickListener(v -> ellipsizer.toggle());
headerBinding.playlistDescription.setOnClickListener(v -> ellipsizer.toggle());
} else {
headerBinding.playlistDescription.setVisibility(View.GONE);
headerBinding.playlistDescriptionReadMore.setVisibility(View.GONE);
}
if (!result.getErrors().isEmpty()) { if (!result.getErrors().isEmpty()) {
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.REQUESTED_PLAYLIST, showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.REQUESTED_PLAYLIST,
@ -369,10 +332,25 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe(getPlaylistBookmarkSubscriber()); .subscribe(getPlaylistBookmarkSubscriber());
PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this); playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view ->
NavigationHelper.playOnMainPlayer(activity, getPlayQueue()));
playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view ->
NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false));
playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view ->
NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false));
playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.POPUP);
return true;
});
playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.AUDIO);
return true;
});
} }
public PlayQueue getPlayQueue() { private PlayQueue getPlayQueue() {
return getPlayQueue(0); return getPlayQueue(0);
} }
@ -496,20 +474,4 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
playlistBookmarkButton.setIcon(drawable); playlistBookmarkButton.setIcon(drawable);
playlistBookmarkButton.setTitle(titleRes); playlistBookmarkButton.setTitle(titleRes);
} }
private void setStreamCountAndOverallDuration(final List<StreamInfoItem> list,
final boolean isDurationComplete) {
if (activity != null && headerBinding != null) {
playlistOverallDurationSeconds += list.stream()
.mapToLong(x -> x.getDuration())
.sum();
headerBinding.playlistStreamCount.setText(
Localization.concatenateStrings(
Localization.localizeStreamCount(activity, streamCount),
Localization.getDurationString(playlistOverallDurationSeconds,
isDurationComplete, true))
);
}
}
} }

View File

@ -1,7 +1,6 @@
package org.schabi.newpipe.fragments.list.search; package org.schabi.newpipe.fragments.list.search;
import static androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags; import static androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.ktx.ViewUtils.animate; import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView; import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
import static java.util.Arrays.asList; import static java.util.Arrays.asList;
@ -40,8 +39,6 @@ import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import com.evernote.android.state.State;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.FragmentSearchBinding; import org.schabi.newpipe.databinding.FragmentSearchBinding;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
@ -79,6 +76,7 @@ import java.util.Queue;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import icepick.State;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.core.Single;
@ -169,10 +167,6 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
/*////////////////////////////////////////////////////////////////////////*/ /*////////////////////////////////////////////////////////////////////////*/
/**
* TextWatcher to remove rich-text formatting on the search EditText when pasting content
* from the clipboard.
*/
private TextWatcher textWatcher; private TextWatcher textWatcher;
public static SearchFragment getInstance(final int serviceId, final String searchString) { public static SearchFragment getInstance(final int serviceId, final String searchString) {
@ -391,7 +385,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
@Override @Override
public void onSaveInstanceState(@NonNull final Bundle bundle) { public void onSaveInstanceState(@NonNull final Bundle bundle) {
searchString = searchEditText != null searchString = searchEditText != null
? getSearchEditString().trim() ? searchEditText.getText().toString()
: searchString; : searchString;
super.onSaveInstanceState(bundle); super.onSaveInstanceState(bundle);
} }
@ -402,11 +396,11 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
@Override @Override
public void reloadContent() { public void reloadContent() {
if (!TextUtils.isEmpty(searchString) || (searchEditText != null if (!TextUtils.isEmpty(searchString)
&& !isSearchEditBlank())) { || (searchEditText != null && !TextUtils.isEmpty(searchEditText.getText()))) {
search(!TextUtils.isEmpty(searchString) search(!TextUtils.isEmpty(searchString)
? searchString ? searchString
: getSearchEditString(), this.contentFilter, ""); : searchEditText.getText().toString(), this.contentFilter, "");
} else { } else {
if (searchEditText != null) { if (searchEditText != null) {
searchEditText.setText(""); searchEditText.setText("");
@ -500,8 +494,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
} }
searchEditText.setText(searchString); searchEditText.setText(searchString);
if (TextUtils.isEmpty(searchString) if (TextUtils.isEmpty(searchString) || TextUtils.isEmpty(searchEditText.getText())) {
|| isSearchEditBlank()) {
searchToolbarContainer.setTranslationX(100); searchToolbarContainer.setTranslationX(100);
searchToolbarContainer.setAlpha(0.0f); searchToolbarContainer.setAlpha(0.0f);
searchToolbarContainer.setVisibility(View.VISIBLE); searchToolbarContainer.setVisibility(View.VISIBLE);
@ -525,7 +518,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "onClick() called with: v = [" + v + "]"); Log.d(TAG, "onClick() called with: v = [" + v + "]");
} }
if (isSearchEditBlank()) { if (TextUtils.isEmpty(searchEditText.getText())) {
NavigationHelper.gotoMainFragment(getFM()); NavigationHelper.gotoMainFragment(getFM());
return; return;
} }
@ -551,7 +544,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
} }
}); });
searchEditText.setOnFocusChangeListener((final View v, final boolean hasFocus) -> { searchEditText.setOnFocusChangeListener((View v, boolean hasFocus) -> {
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "onFocusChange() called with: " Log.d(TAG, "onFocusChange() called with: "
+ "v = [" + v + "], hasFocus = [" + hasFocus + "]"); + "v = [" + v + "], hasFocus = [" + hasFocus + "]");
@ -590,13 +583,11 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
@Override @Override
public void beforeTextChanged(final CharSequence s, final int start, public void beforeTextChanged(final CharSequence s, final int start,
final int count, final int after) { final int count, final int after) {
// Do nothing, old text is already clean
} }
@Override @Override
public void onTextChanged(final CharSequence s, final int start, public void onTextChanged(final CharSequence s, final int start,
final int before, final int count) { final int before, final int count) {
// Changes are handled in afterTextChanged; CharSequence cannot be changed here.
} }
@Override @Override
@ -606,13 +597,13 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
s.removeSpan(span); s.removeSpan(span);
} }
final String newText = getSearchEditString().trim(); final String newText = searchEditText.getText().toString();
suggestionPublisher.onNext(newText); suggestionPublisher.onNext(newText);
} }
}; };
searchEditText.addTextChangedListener(textWatcher); searchEditText.addTextChangedListener(textWatcher);
searchEditText.setOnEditorActionListener( searchEditText.setOnEditorActionListener(
(final TextView v, final int actionId, final KeyEvent event) -> { (TextView v, int actionId, KeyEvent event) -> {
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "onEditorAction() called with: v = [" + v + "], " Log.d(TAG, "onEditorAction() called with: v = [" + v + "], "
+ "actionId = [" + actionId + "], event = [" + event + "]"); + "actionId = [" + actionId + "], event = [" + event + "]");
@ -622,8 +613,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
} else if (event != null } else if (event != null
&& (event.getKeyCode() == KeyEvent.KEYCODE_ENTER && (event.getKeyCode() == KeyEvent.KEYCODE_ENTER
|| event.getAction() == EditorInfo.IME_ACTION_SEARCH)) { || event.getAction() == EditorInfo.IME_ACTION_SEARCH)) {
searchEditText.setText(getSearchEditString().trim()); search(searchEditText.getText().toString(), new String[0], "");
search(getSearchEditString(), new String[0], "");
return true; return true;
} }
return false; return false;
@ -698,7 +688,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
howManyDeleted -> suggestionPublisher howManyDeleted -> suggestionPublisher
.onNext(getSearchEditString()), .onNext(searchEditText.getText().toString()),
throwable -> showSnackBarError(new ErrorInfo(throwable, throwable -> showSnackBarError(new ErrorInfo(throwable,
UserAction.DELETE_FROM_HISTORY, UserAction.DELETE_FROM_HISTORY,
"Deleting item failed"))); "Deleting item failed")));
@ -727,9 +717,9 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
.getRelatedSearches(query, similarQueryLimit, 25) .getRelatedSearches(query, similarQueryLimit, 25)
.toObservable() .toObservable()
.map(searchHistoryEntries -> .map(searchHistoryEntries ->
searchHistoryEntries.stream() searchHistoryEntries.stream()
.map(entry -> new SuggestionItem(true, entry)) .map(entry -> new SuggestionItem(true, entry))
.collect(Collectors.toList())); .collect(Collectors.toList()));
} }
private Observable<List<SuggestionItem>> getRemoteSuggestionsObservable(final String query) { private Observable<List<SuggestionItem>> getRemoteSuggestionsObservable(final String query) {
@ -796,12 +786,12 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
} else if (listNotification.isOnError() } else if (listNotification.isOnError()
&& listNotification.getError() != null && listNotification.getError() != null
&& !ExceptionUtils.isInterruptedCaused( && !ExceptionUtils.isInterruptedCaused(
listNotification.getError())) { listNotification.getError())) {
showSnackBarError(new ErrorInfo(listNotification.getError(), showSnackBarError(new ErrorInfo(listNotification.getError(),
UserAction.GET_SUGGESTIONS, searchString, serviceId)); UserAction.GET_SUGGESTIONS, searchString, serviceId));
} }
}, throwable -> showSnackBarError(new ErrorInfo( }, throwable -> showSnackBarError(new ErrorInfo(
throwable, UserAction.GET_SUGGESTIONS, searchString, serviceId))); throwable, UserAction.GET_SUGGESTIONS, searchString, serviceId)));
} }
@Override @Override
@ -809,13 +799,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
// no-op // no-op
} }
/** private void search(final String theSearchString,
* Perform a search.
* @param theSearchString the trimmed search string
* @param theContentFilter the content filter to use. FIXME: unused param
* @param theSortFilter FIXME: unused param
*/
private void search(@NonNull final String theSearchString,
final String[] theContentFilter, final String[] theContentFilter,
final String theSortFilter) { final String theSortFilter) {
if (DEBUG) { if (DEBUG) {
@ -825,26 +809,25 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
return; return;
} }
// Check if theSearchString is a URL which can be opened by NewPipe directly
// and open it if possible.
try { try {
final StreamingService streamingService = NewPipe.getServiceByUrl(theSearchString); final StreamingService streamingService = NewPipe.getServiceByUrl(theSearchString);
showLoading(); if (streamingService != null) {
disposables.add(Observable showLoading();
.fromCallable(() -> NavigationHelper.getIntentByLink(activity, disposables.add(Observable
streamingService, theSearchString)) .fromCallable(() -> NavigationHelper.getIntentByLink(activity,
.subscribeOn(Schedulers.io()) streamingService, theSearchString))
.observeOn(AndroidSchedulers.mainThread()) .subscribeOn(Schedulers.io())
.subscribe(intent -> { .observeOn(AndroidSchedulers.mainThread())
getFM().popBackStackImmediate(); .subscribe(intent -> {
activity.startActivity(intent); getFM().popBackStackImmediate();
}, throwable -> showTextError(getString(R.string.unsupported_url)))); activity.startActivity(intent);
return; }, throwable -> showTextError(getString(R.string.unsupported_url))));
return;
}
} catch (final Exception ignored) { } catch (final Exception ignored) {
// Exception occurred, it's not a url // Exception occurred, it's not a url
} }
// prepare search
lastSearchedString = this.searchString; lastSearchedString = this.searchString;
this.searchString = theSearchString; this.searchString = theSearchString;
infoListAdapter.clearStreamItemList(); infoListAdapter.clearStreamItemList();
@ -853,17 +836,13 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
searchBinding.searchMetaInfoSeparator, disposables); searchBinding.searchMetaInfoSeparator, disposables);
hideKeyboardSearch(); hideKeyboardSearch();
// store search query if search history is enabled
disposables.add(historyRecordManager.onSearched(serviceId, theSearchString) disposables.add(historyRecordManager.onSearched(serviceId, theSearchString)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
ignored -> { ignored -> { },
},
throwable -> showSnackBarError(new ErrorInfo(throwable, UserAction.SEARCHED, throwable -> showSnackBarError(new ErrorInfo(throwable, UserAction.SEARCHED,
theSearchString, serviceId)) theSearchString, serviceId))
)); ));
// load search results
suggestionPublisher.onNext(theSearchString); suggestionPublisher.onNext(theSearchString);
startLoading(false); startLoading(false);
} }
@ -953,14 +932,6 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
sortFilter = theSortFilter; sortFilter = theSortFilter;
} }
private String getSearchEditString() {
return searchEditText.getText().toString();
}
private boolean isSearchEditBlank() {
return isBlank(getSearchEditString());
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Suggestion Results // Suggestion Results
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@ -1002,9 +973,6 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
} }
searchSuggestion = result.getSearchSuggestion(); searchSuggestion = result.getSearchSuggestion();
if (searchSuggestion != null) {
searchSuggestion = searchSuggestion.trim();
}
isCorrectedSearch = result.isCorrectedSearch(); isCorrectedSearch = result.isCorrectedSearch();
// List<MetaInfo> cannot be bundled without creating some containers // List<MetaInfo> cannot be bundled without creating some containers
@ -1106,7 +1074,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe( .subscribe(
howManyDeleted -> suggestionPublisher howManyDeleted -> suggestionPublisher
.onNext(getSearchEditString()), .onNext(searchEditText.getText().toString()),
throwable -> showSnackBarError(new ErrorInfo(throwable, throwable -> showSnackBarError(new ErrorInfo(throwable,
UserAction.DELETE_FROM_HISTORY, "Deleting item failed"))); UserAction.DELETE_FROM_HISTORY, "Deleting item failed")));
disposables.add(onDelete); disposables.add(onDelete);

View File

@ -10,7 +10,6 @@ import android.view.ViewGroup;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
@ -19,22 +18,21 @@ import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.info_list.ItemViewMode; import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.ktx.ViewUtils; import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.util.RelatedItemInfo;
import java.io.Serializable; import java.io.Serializable;
import java.util.function.Supplier; import java.util.function.Supplier;
import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.core.Single;
public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, RelatedItemsInfo> public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, RelatedItemInfo>
implements SharedPreferences.OnSharedPreferenceChangeListener { implements SharedPreferences.OnSharedPreferenceChangeListener {
private static final String INFO_KEY = "related_info_key"; private static final String INFO_KEY = "related_info_key";
private RelatedItemsInfo relatedItemsInfo; private RelatedItemInfo relatedItemInfo;
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Views // Views
@ -71,7 +69,7 @@ public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, Related
@Override @Override
protected Supplier<View> getListHeaderSupplier() { protected Supplier<View> getListHeaderSupplier() {
if (relatedItemsInfo == null || relatedItemsInfo.getRelatedItems() == null) { if (relatedItemInfo == null || relatedItemInfo.getRelatedItems() == null) {
return null; return null;
} }
@ -99,8 +97,8 @@ public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, Related
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override @Override
protected Single<RelatedItemsInfo> loadResult(final boolean forceLoad) { protected Single<RelatedItemInfo> loadResult(final boolean forceLoad) {
return Single.fromCallable(() -> relatedItemsInfo); return Single.fromCallable(() -> relatedItemInfo);
} }
@Override @Override
@ -112,7 +110,7 @@ public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, Related
} }
@Override @Override
public void handleResult(@NonNull final RelatedItemsInfo result) { public void handleResult(@NonNull final RelatedItemInfo result) {
super.handleResult(result); super.handleResult(result);
if (headerBinding != null) { if (headerBinding != null) {
@ -139,23 +137,23 @@ public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, Related
private void setInitialData(final StreamInfo info) { private void setInitialData(final StreamInfo info) {
super.setInitialData(info.getServiceId(), info.getUrl(), info.getName()); super.setInitialData(info.getServiceId(), info.getUrl(), info.getName());
if (this.relatedItemsInfo == null) { if (this.relatedItemInfo == null) {
this.relatedItemsInfo = new RelatedItemsInfo(info); this.relatedItemInfo = RelatedItemInfo.getInfo(info);
} }
} }
@Override @Override
public void onSaveInstanceState(@NonNull final Bundle outState) { public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState); super.onSaveInstanceState(outState);
outState.putSerializable(INFO_KEY, relatedItemsInfo); outState.putSerializable(INFO_KEY, relatedItemInfo);
} }
@Override @Override
protected void onRestoreInstanceState(@NonNull final Bundle savedState) { protected void onRestoreInstanceState(@NonNull final Bundle savedState) {
super.onRestoreInstanceState(savedState); super.onRestoreInstanceState(savedState);
final Serializable serializable = savedState.getSerializable(INFO_KEY); final Serializable serializable = savedState.getSerializable(INFO_KEY);
if (serializable instanceof RelatedItemsInfo) { if (serializable instanceof RelatedItemInfo) {
this.relatedItemsInfo = (RelatedItemsInfo) serializable; this.relatedItemInfo = (RelatedItemInfo) serializable;
} }
} }
@ -176,27 +174,4 @@ public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, Related
} }
return mode; return mode;
} }
@Override
protected void showInfoItemDialog(final StreamInfoItem item) {
// Try and attach the InfoItemDialog to the parent fragment of the RelatedItemsFragment
// so that its context is not lost when the RelatedItemsFragment is reinitialized,
// e.g. when a new stream is loaded in a parent VideoDetailFragment.
final Fragment parentFragment = getParentFragment();
if (parentFragment != null) {
try {
new InfoItemDialog.Builder(
parentFragment.getActivity(),
parentFragment.getContext(),
parentFragment,
item
).create().show();
} catch (final IllegalArgumentException e) {
InfoItemDialog.Builder.reportErrorDuringInitialization(e, item);
}
} else {
super.showInfoItemDialog(item);
}
}
} }

View File

@ -1,22 +0,0 @@
package org.schabi.newpipe.fragments.list.videos;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListInfo;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import java.util.ArrayList;
import java.util.Collections;
public final class RelatedItemsInfo extends ListInfo<InfoItem> {
/**
* This class is used to wrap the related items of a StreamInfo into a ListInfo object.
*
* @param info the stream info from which to get related items
*/
public RelatedItemsInfo(final StreamInfo info) {
super(info.getServiceId(), new ListLinkHandler(info.getOriginalUrl(), info.getUrl(),
info.getId(), Collections.emptyList(), null), info.getName());
setRelatedItems(new ArrayList<>(info.getRelatedItems()));
}
}

View File

@ -13,7 +13,8 @@ import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder;
import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder;
import org.schabi.newpipe.info_list.holder.CommentInfoItemHolder; import org.schabi.newpipe.info_list.holder.CommentsInfoItemHolder;
import org.schabi.newpipe.info_list.holder.CommentsMiniInfoItemHolder;
import org.schabi.newpipe.info_list.holder.InfoItemHolder; import org.schabi.newpipe.info_list.holder.InfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder;
@ -86,7 +87,8 @@ public class InfoItemBuilder {
return useMiniVariant ? new PlaylistMiniInfoItemHolder(this, parent) return useMiniVariant ? new PlaylistMiniInfoItemHolder(this, parent)
: new PlaylistInfoItemHolder(this, parent); : new PlaylistInfoItemHolder(this, parent);
case COMMENT: case COMMENT:
return new CommentInfoItemHolder(this, parent); return useMiniVariant ? new CommentsMiniInfoItemHolder(this, parent)
: new CommentsInfoItemHolder(this, parent);
default: default:
throw new RuntimeException("InfoType not expected = " + infoType.name()); throw new RuntimeException("InfoType not expected = " + infoType.name());
} }

View File

@ -21,7 +21,8 @@ import org.schabi.newpipe.info_list.holder.ChannelCardInfoItemHolder;
import org.schabi.newpipe.info_list.holder.ChannelGridInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelGridInfoItemHolder;
import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder;
import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder; import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder;
import org.schabi.newpipe.info_list.holder.CommentInfoItemHolder; import org.schabi.newpipe.info_list.holder.CommentsInfoItemHolder;
import org.schabi.newpipe.info_list.holder.CommentsMiniInfoItemHolder;
import org.schabi.newpipe.info_list.holder.InfoItemHolder; import org.schabi.newpipe.info_list.holder.InfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistCardInfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistCardInfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder;
@ -78,7 +79,8 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
private static final int PLAYLIST_HOLDER_TYPE = 0x301; private static final int PLAYLIST_HOLDER_TYPE = 0x301;
private static final int GRID_PLAYLIST_HOLDER_TYPE = 0x302; private static final int GRID_PLAYLIST_HOLDER_TYPE = 0x302;
private static final int CARD_PLAYLIST_HOLDER_TYPE = 0x303; private static final int CARD_PLAYLIST_HOLDER_TYPE = 0x303;
private static final int COMMENT_HOLDER_TYPE = 0x400; private static final int MINI_COMMENT_HOLDER_TYPE = 0x400;
private static final int COMMENT_HOLDER_TYPE = 0x401;
private final LayoutInflater layoutInflater; private final LayoutInflater layoutInflater;
private final InfoItemBuilder infoItemBuilder; private final InfoItemBuilder infoItemBuilder;
@ -269,7 +271,7 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
return PLAYLIST_HOLDER_TYPE; return PLAYLIST_HOLDER_TYPE;
} }
case COMMENT: case COMMENT:
return COMMENT_HOLDER_TYPE; return useMiniVariant ? MINI_COMMENT_HOLDER_TYPE : COMMENT_HOLDER_TYPE;
default: default:
return -1; return -1;
} }
@ -318,8 +320,10 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
return new PlaylistGridInfoItemHolder(infoItemBuilder, parent); return new PlaylistGridInfoItemHolder(infoItemBuilder, parent);
case CARD_PLAYLIST_HOLDER_TYPE: case CARD_PLAYLIST_HOLDER_TYPE:
return new PlaylistCardInfoItemHolder(infoItemBuilder, parent); return new PlaylistCardInfoItemHolder(infoItemBuilder, parent);
case MINI_COMMENT_HOLDER_TYPE:
return new CommentsMiniInfoItemHolder(infoItemBuilder, parent);
case COMMENT_HOLDER_TYPE: case COMMENT_HOLDER_TYPE:
return new CommentInfoItemHolder(infoItemBuilder, parent); return new CommentsInfoItemHolder(infoItemBuilder, parent);
default: default:
return new FallbackViewHolder(new View(parent.getContext())); return new FallbackViewHolder(new View(parent.getContext()));
} }

View File

@ -8,7 +8,7 @@ import com.xwray.groupie.Item
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.stream.StreamSegment import org.schabi.newpipe.extractor.stream.StreamSegment
import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.image.PicassoHelper import org.schabi.newpipe.util.PicassoHelper
class StreamSegmentItem( class StreamSegmentItem(
private val item: StreamSegment, private val item: StreamSegment,

View File

@ -104,7 +104,7 @@ public enum StreamDialogDefaultEntry {
SHARE(R.string.share, (fragment, item) -> SHARE(R.string.share, (fragment, item) ->
ShareUtils.shareText(fragment.requireContext(), item.getName(), item.getUrl(), ShareUtils.shareText(fragment.requireContext(), item.getName(), item.getUrl(),
item.getThumbnails())), item.getThumbnailUrl())),
/** /**
* Opens a {@link DownloadDialog} after fetching some stream info. * Opens a {@link DownloadDialog} after fetching some stream info.
@ -113,10 +113,7 @@ public enum StreamDialogDefaultEntry {
DOWNLOAD(R.string.download, (fragment, item) -> DOWNLOAD(R.string.download, (fragment, item) ->
fetchStreamInfoAndSaveToDatabase(fragment.requireContext(), item.getServiceId(), fetchStreamInfoAndSaveToDatabase(fragment.requireContext(), item.getServiceId(),
item.getUrl(), info -> { item.getUrl(), info -> {
// Ensure the fragment is attached and its state hasn't been saved to avoid if (fragment.getContext() != null) {
// showing dialog during lifecycle changes or when the activity is paused,
// e.g. by selecting the download option and opening a different fragment.
if (fragment.isAdded() && !fragment.isStateSaved()) {
final DownloadDialog downloadDialog = final DownloadDialog downloadDialog =
new DownloadDialog(fragment.requireContext(), info); new DownloadDialog(fragment.requireContext(), info);
downloadDialog.show(fragment.getChildFragmentManager(), downloadDialog.show(fragment.getChildFragmentManager(),

View File

@ -13,7 +13,7 @@ import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
import org.schabi.newpipe.extractor.utils.Utils; import org.schabi.newpipe.extractor.utils.Utils;
import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
public class ChannelMiniInfoItemHolder extends InfoItemHolder { public class ChannelMiniInfoItemHolder extends InfoItemHolder {
@ -56,7 +56,7 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder {
itemAdditionalDetailView.setText(getDetailLine(item)); itemAdditionalDetailView.setText(getDetailLine(item));
} }
PicassoHelper.loadAvatar(item.getThumbnails()).into(itemThumbnailView); PicassoHelper.loadAvatar(item.getThumbnailUrl()).into(itemThumbnailView);
itemView.setOnClickListener(view -> { itemView.setOnClickListener(view -> {
if (itemBuilder.getOnChannelSelectedListener() != null) { if (itemBuilder.getOnChannelSelectedListener() != null) {

View File

@ -1,210 +0,0 @@
package org.schabi.newpipe.info_list.holder;
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.fragment.app.FragmentActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.text.TextEllipsizer;
public class CommentInfoItemHolder extends InfoItemHolder {
private static final int COMMENT_DEFAULT_LINES = 2;
private final int commentHorizontalPadding;
private final int commentVerticalPadding;
private final RelativeLayout itemRoot;
private final ImageView itemThumbnailView;
private final TextView itemContentView;
private final ImageView itemThumbsUpView;
private final TextView itemLikesCountView;
private final TextView itemTitleView;
private final ImageView itemHeartView;
private final ImageView itemPinnedView;
private final Button repliesButton;
@NonNull
private final TextEllipsizer textEllipsizer;
public CommentInfoItemHolder(final InfoItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_comment_item, parent);
itemRoot = itemView.findViewById(R.id.itemRoot);
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
itemContentView = itemView.findViewById(R.id.itemCommentContentView);
itemThumbsUpView = itemView.findViewById(R.id.detail_thumbs_up_img_view);
itemLikesCountView = itemView.findViewById(R.id.detail_thumbs_up_count_view);
itemTitleView = itemView.findViewById(R.id.itemTitleView);
itemHeartView = itemView.findViewById(R.id.detail_heart_image_view);
itemPinnedView = itemView.findViewById(R.id.detail_pinned_view);
repliesButton = itemView.findViewById(R.id.replies_button);
commentHorizontalPadding = (int) infoItemBuilder.getContext()
.getResources().getDimension(R.dimen.comments_horizontal_padding);
commentVerticalPadding = (int) infoItemBuilder.getContext()
.getResources().getDimension(R.dimen.comments_vertical_padding);
textEllipsizer = new TextEllipsizer(itemContentView, COMMENT_DEFAULT_LINES, null);
textEllipsizer.setStateChangeListener(isEllipsized -> {
if (Boolean.TRUE.equals(isEllipsized)) {
denyLinkFocus();
} else {
determineMovementMethod();
}
});
}
@Override
public void updateFromItem(final InfoItem infoItem,
final HistoryRecordManager historyRecordManager) {
if (!(infoItem instanceof CommentsInfoItem)) {
return;
}
final CommentsInfoItem item = (CommentsInfoItem) infoItem;
// load the author avatar
PicassoHelper.loadAvatar(item.getUploaderAvatars()).into(itemThumbnailView);
if (ImageStrategy.shouldLoadImages()) {
itemThumbnailView.setVisibility(View.VISIBLE);
itemRoot.setPadding(commentVerticalPadding, commentVerticalPadding,
commentVerticalPadding, commentVerticalPadding);
} else {
itemThumbnailView.setVisibility(View.GONE);
itemRoot.setPadding(commentHorizontalPadding, commentVerticalPadding,
commentHorizontalPadding, commentVerticalPadding);
}
itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item));
// setup the top row, with pinned icon, author name and comment date
itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
itemTitleView.setText(Localization.concatenateStrings(item.getUploaderName(),
Localization.relativeTimeOrTextual(itemBuilder.getContext(), item.getUploadDate(),
item.getTextualUploadDate())));
// setup bottom row, with likes, heart and replies button
itemLikesCountView.setText(
Localization.likeCount(itemBuilder.getContext(), item.getLikeCount()));
itemHeartView.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE);
final boolean hasReplies = item.getReplies() != null;
repliesButton.setOnClickListener(hasReplies ? v -> openCommentReplies(item) : null);
repliesButton.setVisibility(hasReplies ? View.VISIBLE : View.GONE);
repliesButton.setText(hasReplies
? Localization.replyCount(itemBuilder.getContext(), item.getReplyCount()) : "");
((RelativeLayout.LayoutParams) itemThumbsUpView.getLayoutParams()).topMargin =
hasReplies ? 0 : DeviceUtils.dpToPx(6, itemBuilder.getContext());
// setup comment content and click listeners to expand/ellipsize it
textEllipsizer.setStreamingService(getServiceById(item.getServiceId()));
textEllipsizer.setStreamUrl(item.getUrl());
textEllipsizer.setContent(item.getCommentText());
textEllipsizer.ellipsize();
//noinspection ClickableViewAccessibility
itemContentView.setOnTouchListener((v, event) -> {
final CharSequence text = itemContentView.getText();
if (text instanceof Spanned buffer) {
final int action = event.getAction();
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
final int offset = getOffsetForHorizontalLine(itemContentView, event);
final var links = buffer.getSpans(offset, offset, ClickableSpan.class);
if (links.length != 0) {
if (action == MotionEvent.ACTION_UP) {
links[0].onClick(itemContentView);
}
// we handle events that intersect links, so return true
return true;
}
}
}
return false;
});
itemView.setOnClickListener(view -> {
textEllipsizer.toggle();
if (itemBuilder.getOnCommentsSelectedListener() != null) {
itemBuilder.getOnCommentsSelectedListener().selected(item);
}
});
itemView.setOnLongClickListener(view -> {
if (DeviceUtils.isTv(itemBuilder.getContext())) {
openCommentAuthor(item);
} else {
final CharSequence text = itemContentView.getText();
if (text != null) {
ShareUtils.copyToClipboard(itemBuilder.getContext(), text.toString());
}
}
return true;
});
}
private void openCommentAuthor(@NonNull final CommentsInfoItem item) {
NavigationHelper.openCommentAuthorIfPresent((FragmentActivity) itemBuilder.getContext(),
item);
}
private void openCommentReplies(@NonNull final CommentsInfoItem item) {
NavigationHelper.openCommentRepliesFragment((FragmentActivity) itemBuilder.getContext(),
item);
}
private void allowLinkFocus() {
itemContentView.setMovementMethod(LinkMovementMethod.getInstance());
}
private void denyLinkFocus() {
itemContentView.setMovementMethod(null);
}
private boolean shouldFocusLinks() {
if (itemView.isInTouchMode()) {
return false;
}
final URLSpan[] urls = itemContentView.getUrls();
return urls != null && urls.length != 0;
}
private void determineMovementMethod() {
if (shouldFocusLinks()) {
allowLinkFocus();
} else {
denyLinkFocus();
}
}
}

View File

@ -0,0 +1,63 @@
package org.schabi.newpipe.info_list.holder;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
/*
* Created by Christian Schabesberger on 12.02.17.
*
* Copyright (C) Christian Schabesberger 2016 <chris.schabesberger@mailbox.org>
* ChannelInfoItemHolder .java is part of NewPipe.
*
* NewPipe is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* NewPipe is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
public class CommentsInfoItemHolder extends CommentsMiniInfoItemHolder {
public final TextView itemTitleView;
private final ImageView itemHeartView;
private final ImageView itemPinnedView;
public CommentsInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_comments_item, parent);
itemTitleView = itemView.findViewById(R.id.itemTitleView);
itemHeartView = itemView.findViewById(R.id.detail_heart_image_view);
itemPinnedView = itemView.findViewById(R.id.detail_pinned_view);
}
@Override
public void updateFromItem(final InfoItem infoItem,
final HistoryRecordManager historyRecordManager) {
super.updateFromItem(infoItem, historyRecordManager);
if (!(infoItem instanceof CommentsInfoItem)) {
return;
}
final CommentsInfoItem item = (CommentsInfoItem) infoItem;
itemTitleView.setText(item.getUploaderName());
itemHeartView.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE);
itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
}
}

View File

@ -0,0 +1,279 @@
package org.schabi.newpipe.info_list.holder;
import static android.text.TextUtils.isEmpty;
import android.graphics.Paint;
import android.text.Layout;
import android.text.method.LinkMovementMethod;
import android.text.style.URLSpan;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.text.HtmlCompat;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.text.CommentTextOnTouchListener;
import org.schabi.newpipe.util.text.TextLinkifier;
import java.util.function.Consumer;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public class CommentsMiniInfoItemHolder extends InfoItemHolder {
private static final String TAG = "CommentsMiniIIHolder";
private static final String ELLIPSIS = "";
private static final int COMMENT_DEFAULT_LINES = 2;
private static final int COMMENT_EXPANDED_LINES = 1000;
private final int commentHorizontalPadding;
private final int commentVerticalPadding;
private final Paint paintAtContentSize;
private final float ellipsisWidthPx;
private final RelativeLayout itemRoot;
private final ImageView itemThumbnailView;
private final TextView itemContentView;
private final TextView itemLikesCountView;
private final TextView itemPublishedTime;
private final CompositeDisposable disposables = new CompositeDisposable();
@Nullable private Description commentText;
@Nullable private StreamingService streamService;
@Nullable private String streamUrl;
CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId,
final ViewGroup parent) {
super(infoItemBuilder, layoutId, parent);
itemRoot = itemView.findViewById(R.id.itemRoot);
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
itemLikesCountView = itemView.findViewById(R.id.detail_thumbs_up_count_view);
itemPublishedTime = itemView.findViewById(R.id.itemPublishedTime);
itemContentView = itemView.findViewById(R.id.itemCommentContentView);
commentHorizontalPadding = (int) infoItemBuilder.getContext()
.getResources().getDimension(R.dimen.comments_horizontal_padding);
commentVerticalPadding = (int) infoItemBuilder.getContext()
.getResources().getDimension(R.dimen.comments_vertical_padding);
paintAtContentSize = new Paint();
paintAtContentSize.setTextSize(itemContentView.getTextSize());
ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS);
}
public CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder,
final ViewGroup parent) {
this(infoItemBuilder, R.layout.list_comments_mini_item, parent);
}
@Override
public void updateFromItem(final InfoItem infoItem,
final HistoryRecordManager historyRecordManager) {
if (!(infoItem instanceof CommentsInfoItem)) {
return;
}
final CommentsInfoItem item = (CommentsInfoItem) infoItem;
PicassoHelper.loadAvatar(item.getUploaderAvatarUrl()).into(itemThumbnailView);
if (PicassoHelper.getShouldLoadImages()) {
itemThumbnailView.setVisibility(View.VISIBLE);
itemRoot.setPadding(commentVerticalPadding, commentVerticalPadding,
commentVerticalPadding, commentVerticalPadding);
} else {
itemThumbnailView.setVisibility(View.GONE);
itemRoot.setPadding(commentHorizontalPadding, commentVerticalPadding,
commentHorizontalPadding, commentVerticalPadding);
}
itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item));
try {
streamService = NewPipe.getService(item.getServiceId());
} catch (final ExtractionException e) {
// should never happen
ErrorUtil.showUiErrorSnackbar(itemBuilder.getContext(), "Getting StreamingService", e);
Log.w(TAG, "Cannot obtain service from comment service id, defaulting to YouTube", e);
streamService = ServiceList.YouTube;
}
streamUrl = item.getUrl();
commentText = item.getCommentText();
ellipsize();
//noinspection ClickableViewAccessibility
itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE);
if (item.getLikeCount() >= 0) {
itemLikesCountView.setText(
Localization.shortCount(
itemBuilder.getContext(),
item.getLikeCount()));
} else {
itemLikesCountView.setText("-");
}
if (item.getUploadDate() != null) {
itemPublishedTime.setText(Localization.relativeTime(item.getUploadDate()
.offsetDateTime()));
} else {
itemPublishedTime.setText(item.getTextualUploadDate());
}
itemView.setOnClickListener(view -> {
toggleEllipsize();
if (itemBuilder.getOnCommentsSelectedListener() != null) {
itemBuilder.getOnCommentsSelectedListener().selected(item);
}
});
itemView.setOnLongClickListener(view -> {
if (DeviceUtils.isTv(itemBuilder.getContext())) {
openCommentAuthor(item);
} else {
final CharSequence text = itemContentView.getText();
if (text != null) {
ShareUtils.copyToClipboard(itemBuilder.getContext(), text.toString());
}
}
return true;
});
}
private void openCommentAuthor(final CommentsInfoItem item) {
if (isEmpty(item.getUploaderUrl())) {
return;
}
final AppCompatActivity activity = (AppCompatActivity) itemBuilder.getContext();
try {
NavigationHelper.openChannelFragment(
activity.getSupportFragmentManager(),
item.getServiceId(),
item.getUploaderUrl(),
item.getUploaderName());
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(activity, "Opening channel fragment", e);
}
}
private void allowLinkFocus() {
itemContentView.setMovementMethod(LinkMovementMethod.getInstance());
}
private void denyLinkFocus() {
itemContentView.setMovementMethod(null);
}
private boolean shouldFocusLinks() {
if (itemView.isInTouchMode()) {
return false;
}
final URLSpan[] urls = itemContentView.getUrls();
return urls != null && urls.length != 0;
}
private void determineMovementMethod() {
if (shouldFocusLinks()) {
allowLinkFocus();
} else {
denyLinkFocus();
}
}
private void ellipsize() {
itemContentView.setMaxLines(COMMENT_EXPANDED_LINES);
linkifyCommentContentView(v -> {
boolean hasEllipsis = false;
final CharSequence charSeqText = itemContentView.getText();
if (charSeqText != null && itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
// Note that converting to String removes spans (i.e. links), but that's something
// we actually want since when the text is ellipsized we want all clicks on the
// comment to expand the comment, not to open links.
final String text = charSeqText.toString();
final Layout layout = itemContentView.getLayout();
final float lineWidth = layout.getLineWidth(COMMENT_DEFAULT_LINES - 1);
final float layoutWidth = layout.getWidth();
final int lineStart = layout.getLineStart(COMMENT_DEFAULT_LINES - 1);
final int lineEnd = layout.getLineEnd(COMMENT_DEFAULT_LINES - 1);
// remove characters up until there is enough space for the ellipsis
// (also summing 2 more pixels, just to be sure to avoid float rounding errors)
int end = lineEnd;
float removedCharactersWidth = 0.0f;
while (lineWidth - removedCharactersWidth + ellipsisWidthPx + 2.0f > layoutWidth
&& end >= lineStart) {
end -= 1;
// recalculate each time to account for ligatures or other similar things
removedCharactersWidth = paintAtContentSize.measureText(
text.substring(end, lineEnd));
}
// remove trailing spaces and newlines
while (end > 0 && Character.isWhitespace(text.charAt(end - 1))) {
end -= 1;
}
final String newVal = text.substring(0, end) + ELLIPSIS;
itemContentView.setText(newVal);
hasEllipsis = true;
}
itemContentView.setMaxLines(COMMENT_DEFAULT_LINES);
if (hasEllipsis) {
denyLinkFocus();
} else {
determineMovementMethod();
}
});
}
private void toggleEllipsize() {
final CharSequence text = itemContentView.getText();
if (!isEmpty(text) && text.charAt(text.length() - 1) == ELLIPSIS.charAt(0)) {
expand();
} else if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
ellipsize();
}
}
private void expand() {
itemContentView.setMaxLines(COMMENT_EXPANDED_LINES);
linkifyCommentContentView(v -> determineMovementMethod());
}
private void linkifyCommentContentView(@Nullable final Consumer<TextView> onCompletion) {
disposables.clear();
if (commentText != null) {
TextLinkifier.fromDescription(itemContentView, commentText,
HtmlCompat.FROM_HTML_MODE_LEGACY, streamService, streamUrl, disposables,
onCompletion);
}
}
}

View File

@ -9,7 +9,7 @@ import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
public class PlaylistMiniInfoItemHolder extends InfoItemHolder { public class PlaylistMiniInfoItemHolder extends InfoItemHolder {
@ -46,7 +46,7 @@ public class PlaylistMiniInfoItemHolder extends InfoItemHolder {
.localizeStreamCountMini(itemStreamCountView.getContext(), item.getStreamCount())); .localizeStreamCountMini(itemStreamCountView.getContext(), item.getStreamCount()));
itemUploaderView.setText(item.getUploaderName()); itemUploaderView.setText(item.getUploaderName());
PicassoHelper.loadPlaylistThumbnail(item.getThumbnails()).into(itemThumbnailView); PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView);
itemView.setOnClickListener(view -> { itemView.setOnClickListener(view -> {
if (itemBuilder.getOnPlaylistSelectedListener() != null) { if (itemBuilder.getOnPlaylistSelectedListener() != null) {

View File

@ -12,6 +12,10 @@ import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import androidx.preference.PreferenceManager;
import static org.schabi.newpipe.MainActivity.DEBUG;
/* /*
* Created by Christian Schabesberger on 01.08.16. * Created by Christian Schabesberger on 01.08.16.
* <p> * <p>
@ -77,9 +81,7 @@ public class StreamInfoItemHolder extends StreamMiniInfoItemHolder {
} }
} }
final String uploadDate = Localization.relativeTimeOrTextual(itemBuilder.getContext(), final String uploadDate = getFormattedRelativeUploadDate(infoItem);
infoItem.getUploadDate(),
infoItem.getTextualUploadDate());
if (!TextUtils.isEmpty(uploadDate)) { if (!TextUtils.isEmpty(uploadDate)) {
if (viewsAndDate.isEmpty()) { if (viewsAndDate.isEmpty()) {
return uploadDate; return uploadDate;
@ -90,4 +92,20 @@ public class StreamInfoItemHolder extends StreamMiniInfoItemHolder {
return viewsAndDate; return viewsAndDate;
} }
private String getFormattedRelativeUploadDate(final StreamInfoItem infoItem) {
if (infoItem.getUploadDate() != null) {
String formattedRelativeTime = Localization
.relativeTime(infoItem.getUploadDate().offsetDateTime());
if (DEBUG && PreferenceManager.getDefaultSharedPreferences(itemBuilder.getContext())
.getBoolean(itemBuilder.getContext()
.getString(R.string.show_original_time_ago_key), false)) {
formattedRelativeTime += " (" + infoItem.getTextualUploadDate() + ")";
}
return formattedRelativeTime;
} else {
return infoItem.getTextualUploadDate();
}
}
} }

View File

@ -16,7 +16,7 @@ import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.DependentPreferenceHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.image.PicassoHelper; import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.StreamTypeUtil; import org.schabi.newpipe.util.StreamTypeUtil;
import org.schabi.newpipe.views.AnimatedProgressBar; import org.schabi.newpipe.views.AnimatedProgressBar;
@ -87,7 +87,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
} }
// Default thumbnail is shown on error, while loading and if the url is empty // Default thumbnail is shown on error, while loading and if the url is empty
PicassoHelper.loadThumbnail(item.getThumbnails()).into(itemThumbnailView); PicassoHelper.loadThumbnail(item.getThumbnailUrl()).into(itemThumbnailView);
itemView.setOnClickListener(view -> { itemView.setOnClickListener(view -> {
if (itemBuilder.getOnStreamSelectedListener() != null) { if (itemBuilder.getOnStreamSelectedListener() != null) {

View File

@ -1,9 +0,0 @@
package org.schabi.newpipe.ktx
import android.os.Bundle
import android.os.Parcelable
import androidx.core.os.BundleCompat
inline fun <reified T : Parcelable> Bundle.parcelableArrayList(key: String?): ArrayList<T>? {
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
}

View File

@ -1,7 +0,0 @@
package org.schabi.newpipe.ktx
import android.content.SharedPreferences
fun SharedPreferences.getStringSafe(key: String, defValue: String): String {
return getString(key, null) ?: defValue
}

View File

@ -14,7 +14,6 @@ import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.stream.model.StreamStateEntity; import org.schabi.newpipe.database.stream.model.StreamStateEntity;
import org.schabi.newpipe.info_list.ItemViewMode; import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder;
import org.schabi.newpipe.local.holder.LocalItemHolder; import org.schabi.newpipe.local.holder.LocalItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistCardItemHolder; import org.schabi.newpipe.local.holder.LocalPlaylistCardItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistGridItemHolder; import org.schabi.newpipe.local.holder.LocalPlaylistGridItemHolder;
@ -25,7 +24,6 @@ import org.schabi.newpipe.local.holder.LocalPlaylistStreamItemHolder;
import org.schabi.newpipe.local.holder.LocalStatisticStreamCardItemHolder; import org.schabi.newpipe.local.holder.LocalStatisticStreamCardItemHolder;
import org.schabi.newpipe.local.holder.LocalStatisticStreamGridItemHolder; import org.schabi.newpipe.local.holder.LocalStatisticStreamGridItemHolder;
import org.schabi.newpipe.local.holder.LocalStatisticStreamItemHolder; import org.schabi.newpipe.local.holder.LocalStatisticStreamItemHolder;
import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistCardItemHolder; import org.schabi.newpipe.local.holder.RemotePlaylistCardItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder; import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistItemHolder; import org.schabi.newpipe.local.holder.RemotePlaylistItemHolder;
@ -75,12 +73,10 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
private static final int LOCAL_PLAYLIST_HOLDER_TYPE = 0x2000; private static final int LOCAL_PLAYLIST_HOLDER_TYPE = 0x2000;
private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2001; private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2001;
private static final int LOCAL_PLAYLIST_CARD_HOLDER_TYPE = 0x2002; private static final int LOCAL_PLAYLIST_CARD_HOLDER_TYPE = 0x2002;
private static final int LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE = 0x2003;
private static final int REMOTE_PLAYLIST_HOLDER_TYPE = 0x3000; private static final int REMOTE_PLAYLIST_HOLDER_TYPE = 0x3000;
private static final int REMOTE_PLAYLIST_GRID_HOLDER_TYPE = 0x3001; private static final int REMOTE_PLAYLIST_GRID_HOLDER_TYPE = 0x3001;
private static final int REMOTE_PLAYLIST_CARD_HOLDER_TYPE = 0x3002; private static final int REMOTE_PLAYLIST_CARD_HOLDER_TYPE = 0x3002;
private static final int REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE = 0x3003;
private final LocalItemBuilder localItemBuilder; private final LocalItemBuilder localItemBuilder;
private final ArrayList<LocalItem> localItems; private final ArrayList<LocalItem> localItems;
@ -91,7 +87,6 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
private View header = null; private View header = null;
private View footer = null; private View footer = null;
private ItemViewMode itemViewMode = ItemViewMode.LIST; private ItemViewMode itemViewMode = ItemViewMode.LIST;
private boolean useItemHandle = false;
public LocalItemListAdapter(final Context context) { public LocalItemListAdapter(final Context context) {
recordManager = new HistoryRecordManager(context); recordManager = new HistoryRecordManager(context);
@ -185,10 +180,6 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
this.itemViewMode = itemViewMode; this.itemViewMode = itemViewMode;
} }
public void setUseItemHandle(final boolean useItemHandle) {
this.useItemHandle = useItemHandle;
}
public void setHeader(final View header) { public void setHeader(final View header) {
final boolean changed = header != this.header; final boolean changed = header != this.header;
this.header = header; this.header = header;
@ -266,9 +257,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
final LocalItem item = localItems.get(position); final LocalItem item = localItems.get(position);
switch (item.getLocalItemType()) { switch (item.getLocalItemType()) {
case PLAYLIST_LOCAL_ITEM: case PLAYLIST_LOCAL_ITEM:
if (useItemHandle) { if (itemViewMode == ItemViewMode.CARD) {
return LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.CARD) {
return LOCAL_PLAYLIST_CARD_HOLDER_TYPE; return LOCAL_PLAYLIST_CARD_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.GRID) { } else if (itemViewMode == ItemViewMode.GRID) {
return LOCAL_PLAYLIST_GRID_HOLDER_TYPE; return LOCAL_PLAYLIST_GRID_HOLDER_TYPE;
@ -276,9 +265,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
return LOCAL_PLAYLIST_HOLDER_TYPE; return LOCAL_PLAYLIST_HOLDER_TYPE;
} }
case PLAYLIST_REMOTE_ITEM: case PLAYLIST_REMOTE_ITEM:
if (useItemHandle) { if (itemViewMode == ItemViewMode.CARD) {
return REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.CARD) {
return REMOTE_PLAYLIST_CARD_HOLDER_TYPE; return REMOTE_PLAYLIST_CARD_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.GRID) { } else if (itemViewMode == ItemViewMode.GRID) {
return REMOTE_PLAYLIST_GRID_HOLDER_TYPE; return REMOTE_PLAYLIST_GRID_HOLDER_TYPE;
@ -327,16 +314,12 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
return new LocalPlaylistGridItemHolder(localItemBuilder, parent); return new LocalPlaylistGridItemHolder(localItemBuilder, parent);
case LOCAL_PLAYLIST_CARD_HOLDER_TYPE: case LOCAL_PLAYLIST_CARD_HOLDER_TYPE:
return new LocalPlaylistCardItemHolder(localItemBuilder, parent); return new LocalPlaylistCardItemHolder(localItemBuilder, parent);
case LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE:
return new LocalBookmarkPlaylistItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_HOLDER_TYPE: case REMOTE_PLAYLIST_HOLDER_TYPE:
return new RemotePlaylistItemHolder(localItemBuilder, parent); return new RemotePlaylistItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_GRID_HOLDER_TYPE: case REMOTE_PLAYLIST_GRID_HOLDER_TYPE:
return new RemotePlaylistGridItemHolder(localItemBuilder, parent); return new RemotePlaylistGridItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_CARD_HOLDER_TYPE: case REMOTE_PLAYLIST_CARD_HOLDER_TYPE:
return new RemotePlaylistCardItemHolder(localItemBuilder, parent); return new RemotePlaylistCardItemHolder(localItemBuilder, parent);
case REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE:
return new RemoteBookmarkPlaylistItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_HOLDER_TYPE: case STREAM_PLAYLIST_HOLDER_TYPE:
return new LocalPlaylistStreamItemHolder(localItemBuilder, parent); return new LocalPlaylistStreamItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_GRID_HOLDER_TYPE: case STREAM_PLAYLIST_GRID_HOLDER_TYPE:

View File

@ -1,13 +1,10 @@
package org.schabi.newpipe.local.bookmark; package org.schabi.newpipe.local.bookmark;
import static org.schabi.newpipe.local.bookmark.MergedPlaylistManager.getMergedOrderedPlaylists;
import android.content.DialogInterface; import android.content.DialogInterface;
import android.os.Bundle; import android.os.Bundle;
import android.os.Parcelable; import android.os.Parcelable;
import android.text.InputType; import android.text.InputType;
import android.util.Log; import android.util.Log;
import android.util.Pair;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
@ -16,10 +13,6 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.FragmentManager; import androidx.fragment.app.FragmentManager;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import com.evernote.android.state.State;
import org.reactivestreams.Subscriber; import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription; import org.reactivestreams.Subscription;
@ -34,44 +27,29 @@ import org.schabi.newpipe.databinding.DialogEditTextBinding;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder;
import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder;
import org.schabi.newpipe.local.playlist.LocalPlaylistManager; import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
import org.schabi.newpipe.local.playlist.RemotePlaylistManager; import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.OnClickGesture;
import org.schabi.newpipe.util.debounce.DebounceSavable;
import org.schabi.newpipe.util.debounce.DebounceSaver;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import icepick.State;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistLocalItem>, Void> public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistLocalItem>, Void> {
implements DebounceSavable {
private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12;
@State @State
Parcelable itemsListState; protected Parcelable itemsListState;
private Subscription databaseSubscription; private Subscription databaseSubscription;
private CompositeDisposable disposables = new CompositeDisposable(); private CompositeDisposable disposables = new CompositeDisposable();
private LocalPlaylistManager localPlaylistManager; private LocalPlaylistManager localPlaylistManager;
private RemotePlaylistManager remotePlaylistManager; private RemotePlaylistManager remotePlaylistManager;
private ItemTouchHelper itemTouchHelper;
/* Have the bookmarked playlists been fully loaded from db */
private AtomicBoolean isLoadingComplete;
/* Gives enough time to avoid interrupting user sorting operations */
@Nullable
private DebounceSaver debounceSaver;
private List<Pair<Long, LocalItem.LocalItemType>> deletedItems;
/////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////
// Fragment LifeCycle - Creation // Fragment LifeCycle - Creation
@ -87,11 +65,6 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
localPlaylistManager = new LocalPlaylistManager(database); localPlaylistManager = new LocalPlaylistManager(database);
remotePlaylistManager = new RemotePlaylistManager(database); remotePlaylistManager = new RemotePlaylistManager(database);
disposables = new CompositeDisposable(); disposables = new CompositeDisposable();
isLoadingComplete = new AtomicBoolean();
debounceSaver = new DebounceSaver(3000, this);
deletedItems = new ArrayList<>();
} }
@Nullable @Nullable
@ -121,17 +94,12 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
@Override @Override
protected void initViews(final View rootView, final Bundle savedInstanceState) { protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState); super.initViews(rootView, savedInstanceState);
itemListAdapter.setUseItemHandle(true);
} }
@Override @Override
protected void initListeners() { protected void initListeners() {
super.initListeners(); super.initListeners();
itemTouchHelper = new ItemTouchHelper(getItemTouchCallback());
itemTouchHelper.attachToRecyclerView(itemsList);
itemListAdapter.setSelectedListener(new OnClickGesture<>() { itemListAdapter.setSelectedListener(new OnClickGesture<>() {
@Override @Override
public void selected(final LocalItem selectedItem) { public void selected(final LocalItem selectedItem) {
@ -139,7 +107,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
if (selectedItem instanceof PlaylistMetadataEntry) { if (selectedItem instanceof PlaylistMetadataEntry) {
final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem); final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem);
NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.getUid(), NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.uid,
entry.name); entry.name);
} else if (selectedItem instanceof PlaylistRemoteEntity) { } else if (selectedItem instanceof PlaylistRemoteEntity) {
@ -160,14 +128,6 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
showRemoteDeleteDialog((PlaylistRemoteEntity) selectedItem); showRemoteDeleteDialog((PlaylistRemoteEntity) selectedItem);
} }
} }
@Override
public void drag(final LocalItem selectedItem,
final RecyclerView.ViewHolder viewHolder) {
if (itemTouchHelper != null) {
itemTouchHelper.startDrag(viewHolder);
}
}
}); });
} }
@ -179,13 +139,8 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
public void startLoading(final boolean forceLoad) { public void startLoading(final boolean forceLoad) {
super.startLoading(forceLoad); super.startLoading(forceLoad);
if (debounceSaver != null) { Flowable.combineLatest(localPlaylistManager.getPlaylists(),
disposables.add(debounceSaver.getDebouncedSaver()); remotePlaylistManager.getPlaylists(), PlaylistLocalItem::merge)
debounceSaver.setNoChangesToSave();
}
isLoadingComplete.set(false);
getMergedOrderedPlaylists(localPlaylistManager, remotePlaylistManager)
.onBackpressureLatest() .onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe(getPlaylistsSubscriber()); .subscribe(getPlaylistsSubscriber());
@ -199,9 +154,6 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
public void onPause() { public void onPause() {
super.onPause(); super.onPause();
itemsListState = itemsList.getLayoutManager().onSaveInstanceState(); itemsListState = itemsList.getLayoutManager().onSaveInstanceState();
// Save on exit
saveImmediate();
} }
@Override @Override
@ -216,27 +168,19 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
} }
databaseSubscription = null; databaseSubscription = null;
itemTouchHelper = null;
} }
@Override @Override
public void onDestroy() { public void onDestroy() {
super.onDestroy(); super.onDestroy();
if (debounceSaver != null) {
debounceSaver.getDebouncedSaveSignal().onComplete();
}
if (disposables != null) { if (disposables != null) {
disposables.dispose(); disposables.dispose();
} }
debounceSaver = null;
disposables = null; disposables = null;
localPlaylistManager = null; localPlaylistManager = null;
remotePlaylistManager = null; remotePlaylistManager = null;
itemsListState = null; itemsListState = null;
isLoadingComplete = null;
deletedItems = null;
} }
/////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////
@ -244,12 +188,10 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
/////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////
private Subscriber<List<PlaylistLocalItem>> getPlaylistsSubscriber() { private Subscriber<List<PlaylistLocalItem>> getPlaylistsSubscriber() {
return new Subscriber<>() { return new Subscriber<List<PlaylistLocalItem>>() {
@Override @Override
public void onSubscribe(final Subscription s) { public void onSubscribe(final Subscription s) {
showLoading(); showLoading();
isLoadingComplete.set(false);
if (databaseSubscription != null) { if (databaseSubscription != null) {
databaseSubscription.cancel(); databaseSubscription.cancel();
} }
@ -259,10 +201,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
@Override @Override
public void onNext(final List<PlaylistLocalItem> subscriptions) { public void onNext(final List<PlaylistLocalItem> subscriptions) {
if (debounceSaver == null || !debounceSaver.getIsModified()) { handleResult(subscriptions);
handleResult(subscriptions);
isLoadingComplete.set(true);
}
if (databaseSubscription != null) { if (databaseSubscription != null) {
databaseSubscription.request(1); databaseSubscription.request(1);
} }
@ -275,8 +214,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
} }
@Override @Override
public void onComplete() { public void onComplete() { }
}
}; };
} }
@ -311,183 +249,12 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
} }
} }
/*//////////////////////////////////////////////////////////////////////////
// Playlist Metadata Manipulation
//////////////////////////////////////////////////////////////////////////*/
private void changeLocalPlaylistName(final long id, final String name) {
if (localPlaylistManager == null) {
return;
}
if (DEBUG) {
Log.d(TAG, "Updating playlist id=[" + id + "] "
+ "with new name=[" + name + "] items");
}
final Disposable disposable = localPlaylistManager.renamePlaylist(id, name)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(longs -> { /*Do nothing on success*/ }, throwable -> showError(
new ErrorInfo(throwable,
UserAction.REQUESTED_BOOKMARK,
"Changing playlist name")));
disposables.add(disposable);
}
private void deleteItem(final PlaylistLocalItem item) {
if (itemListAdapter == null) {
return;
}
itemListAdapter.removeItem(item);
if (item instanceof PlaylistMetadataEntry) {
deletedItems.add(new Pair<>(item.getUid(),
LocalItem.LocalItemType.PLAYLIST_LOCAL_ITEM));
} else if (item instanceof PlaylistRemoteEntity) {
deletedItems.add(new Pair<>(item.getUid(),
LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM));
}
if (debounceSaver != null) {
debounceSaver.setHasChangesToSave();
saveImmediate();
}
}
@Override
public void saveImmediate() {
if (itemListAdapter == null) {
return;
}
// List must be loaded and modified in order to save
if (isLoadingComplete == null || debounceSaver == null
|| !isLoadingComplete.get() || !debounceSaver.getIsModified()) {
return;
}
final List<LocalItem> items = itemListAdapter.getItemsList();
final List<PlaylistMetadataEntry> localItemsUpdate = new ArrayList<>();
final List<Long> localItemsDeleteUid = new ArrayList<>();
final List<PlaylistRemoteEntity> remoteItemsUpdate = new ArrayList<>();
final List<Long> remoteItemsDeleteUid = new ArrayList<>();
// Calculate display index
for (int i = 0; i < items.size(); i++) {
final LocalItem item = items.get(i);
if (item instanceof PlaylistMetadataEntry
&& ((PlaylistMetadataEntry) item).getDisplayIndex() != i) {
((PlaylistMetadataEntry) item).setDisplayIndex(i);
localItemsUpdate.add((PlaylistMetadataEntry) item);
} else if (item instanceof PlaylistRemoteEntity
&& ((PlaylistRemoteEntity) item).getDisplayIndex() != i) {
((PlaylistRemoteEntity) item).setDisplayIndex(i);
remoteItemsUpdate.add((PlaylistRemoteEntity) item);
}
}
// Find deleted items
for (final Pair<Long, LocalItem.LocalItemType> item : deletedItems) {
if (item.second.equals(LocalItem.LocalItemType.PLAYLIST_LOCAL_ITEM)) {
localItemsDeleteUid.add(item.first);
} else if (item.second.equals(LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM)) {
remoteItemsDeleteUid.add(item.first);
}
}
deletedItems.clear();
// 1. Update local playlists
// 2. Update remote playlists
// 3. Set NoChangesToSave
disposables.add(localPlaylistManager.updatePlaylists(localItemsUpdate, localItemsDeleteUid)
.mergeWith(remotePlaylistManager.updatePlaylists(
remoteItemsUpdate, remoteItemsDeleteUid))
.observeOn(AndroidSchedulers.mainThread())
.subscribe(() -> {
if (debounceSaver != null) {
debounceSaver.setNoChangesToSave();
}
},
throwable -> showError(new ErrorInfo(throwable,
UserAction.REQUESTED_BOOKMARK, "Saving playlist"))
));
}
private ItemTouchHelper.SimpleCallback getItemTouchCallback() {
// if adding grid layout, also include ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT
// with an `if (shouldUseGridLayout()) ...`
return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN,
ItemTouchHelper.ACTION_STATE_IDLE) {
@Override
public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView,
final int viewSize,
final int viewSizeOutOfBounds,
final int totalSize,
final long msSinceStartScroll) {
final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView,
viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll);
final int minimumAbsVelocity = Math.max(MINIMUM_INITIAL_DRAG_VELOCITY,
Math.abs(standardSpeed));
return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds);
}
@Override
public boolean onMove(@NonNull final RecyclerView recyclerView,
@NonNull final RecyclerView.ViewHolder source,
@NonNull final RecyclerView.ViewHolder target) {
// Allow swap LocalBookmarkPlaylistItemHolder and RemoteBookmarkPlaylistItemHolder.
if (itemListAdapter == null
|| source.getItemViewType() != target.getItemViewType()
&& !(
(
(source instanceof LocalBookmarkPlaylistItemHolder)
|| (source instanceof RemoteBookmarkPlaylistItemHolder)
)
&& (
(target instanceof LocalBookmarkPlaylistItemHolder)
|| (target instanceof RemoteBookmarkPlaylistItemHolder)
))
) {
return false;
}
final int sourceIndex = source.getBindingAdapterPosition();
final int targetIndex = target.getBindingAdapterPosition();
final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex);
if (isSwapped && debounceSaver != null) {
debounceSaver.setHasChangesToSave();
}
return isSwapped;
}
@Override
public boolean isLongPressDragEnabled() {
return false;
}
@Override
public boolean isItemViewSwipeEnabled() {
return false;
}
@Override
public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder,
final int swipeDir) {
// Do nothing.
}
};
}
/////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////
// Utils // Utils
/////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////
private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) { private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) {
showDeleteDialog(item.getName(), item); showDeleteDialog(item.getName(), remotePlaylistManager.deletePlaylist(item.getUid()));
} }
private void showLocalDialog(final PlaylistMetadataEntry selectedItem) { private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
@ -495,7 +262,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
final String delete = getString(R.string.delete); final String delete = getString(R.string.delete);
final String unsetThumbnail = getString(R.string.unset_playlist_thumbnail); final String unsetThumbnail = getString(R.string.unset_playlist_thumbnail);
final boolean isThumbnailPermanent = localPlaylistManager final boolean isThumbnailPermanent = localPlaylistManager
.getIsPlaylistThumbnailPermanent(selectedItem.getUid()); .getIsPlaylistThumbnailPermanent(selectedItem.uid);
final ArrayList<String> items = new ArrayList<>(); final ArrayList<String> items = new ArrayList<>();
items.add(rename); items.add(rename);
@ -508,12 +275,13 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
if (items.get(index).equals(rename)) { if (items.get(index).equals(rename)) {
showRenameDialog(selectedItem); showRenameDialog(selectedItem);
} else if (items.get(index).equals(delete)) { } else if (items.get(index).equals(delete)) {
showDeleteDialog(selectedItem.name, selectedItem); showDeleteDialog(selectedItem.name,
localPlaylistManager.deletePlaylist(selectedItem.uid));
} else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) { } else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) {
final long thumbnailStreamId = localPlaylistManager final long thumbnailStreamId = localPlaylistManager
.getAutomaticPlaylistThumbnailStreamId(selectedItem.getUid()); .getAutomaticPlaylistThumbnailStreamId(selectedItem.uid);
localPlaylistManager localPlaylistManager
.changePlaylistThumbnail(selectedItem.getUid(), thumbnailStreamId, false) .changePlaylistThumbnail(selectedItem.uid, thumbnailStreamId, false)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe(); .subscribe();
} }
@ -535,13 +303,13 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
.setView(dialogBinding.getRoot()) .setView(dialogBinding.getRoot())
.setPositiveButton(R.string.rename_playlist, (dialog, which) -> .setPositiveButton(R.string.rename_playlist, (dialog, which) ->
changeLocalPlaylistName( changeLocalPlaylistName(
selectedItem.getUid(), selectedItem.uid,
dialogBinding.dialogEditText.getText().toString())) dialogBinding.dialogEditText.getText().toString()))
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.show(); .show();
} }
private void showDeleteDialog(final String name, final PlaylistLocalItem item) { private void showDeleteDialog(final String name, final Single<Integer> deleteReactor) {
if (activity == null || disposables == null) { if (activity == null || disposables == null) {
return; return;
} }
@ -550,8 +318,35 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
.setTitle(name) .setTitle(name)
.setMessage(R.string.delete_playlist_prompt) .setMessage(R.string.delete_playlist_prompt)
.setCancelable(true) .setCancelable(true)
.setPositiveButton(R.string.delete, (dialog, i) -> deleteItem(item)) .setPositiveButton(R.string.delete, (dialog, i) ->
disposables.add(deleteReactor
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignored -> { /*Do nothing on success*/ }, throwable ->
showError(new ErrorInfo(throwable,
UserAction.REQUESTED_BOOKMARK,
"Deleting playlist")))))
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.show(); .show();
} }
private void changeLocalPlaylistName(final long id, final String name) {
if (localPlaylistManager == null) {
return;
}
if (DEBUG) {
Log.d(TAG, "Updating playlist id=[" + id + "] "
+ "with new name=[" + name + "] items");
}
localPlaylistManager.renamePlaylist(id, name);
final Disposable disposable = localPlaylistManager.renamePlaylist(id, name)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(longs -> { /*Do nothing on success*/ }, throwable -> showError(
new ErrorInfo(throwable,
UserAction.REQUESTED_BOOKMARK,
"Changing playlist name")));
disposables.add(disposable);
}
} }

View File

@ -1,95 +0,0 @@
package org.schabi.newpipe.local.bookmark;
import org.schabi.newpipe.database.playlist.PlaylistLocalItem;
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import io.reactivex.rxjava3.core.Flowable;
/**
* Takes care of remote and local playlists at once, hence "merged".
*/
public final class MergedPlaylistManager {
private MergedPlaylistManager() {
}
public static Flowable<List<PlaylistLocalItem>> getMergedOrderedPlaylists(
final LocalPlaylistManager localPlaylistManager,
final RemotePlaylistManager remotePlaylistManager) {
return Flowable.combineLatest(
localPlaylistManager.getPlaylists(),
remotePlaylistManager.getPlaylists(),
MergedPlaylistManager::merge
);
}
/**
* Merge localPlaylists and remotePlaylists by the display index.
* If two items have the same display index, sort them in {@code CASE_INSENSITIVE_ORDER}.
*
* @param localPlaylists local playlists, already sorted by display index
* @param remotePlaylists remote playlists, already sorted by display index
* @return merged playlists
*/
public static List<PlaylistLocalItem> merge(
final List<PlaylistMetadataEntry> localPlaylists,
final List<PlaylistRemoteEntity> remotePlaylists) {
// This algorithm is similar to the merge operation in merge sort.
final List<PlaylistLocalItem> result = new ArrayList<>(
localPlaylists.size() + remotePlaylists.size());
final List<PlaylistLocalItem> itemsWithSameIndex = new ArrayList<>();
int i = 0;
int j = 0;
while (i < localPlaylists.size()) {
while (j < remotePlaylists.size()) {
if (remotePlaylists.get(j).getDisplayIndex()
<= localPlaylists.get(i).getDisplayIndex()) {
addItem(result, remotePlaylists.get(j), itemsWithSameIndex);
j++;
} else {
break;
}
}
addItem(result, localPlaylists.get(i), itemsWithSameIndex);
i++;
}
while (j < remotePlaylists.size()) {
addItem(result, remotePlaylists.get(j), itemsWithSameIndex);
j++;
}
addItemsWithSameIndex(result, itemsWithSameIndex);
return result;
}
private static void addItem(final List<PlaylistLocalItem> result,
final PlaylistLocalItem item,
final List<PlaylistLocalItem> itemsWithSameIndex) {
if (!itemsWithSameIndex.isEmpty()
&& itemsWithSameIndex.get(0).getDisplayIndex() != item.getDisplayIndex()) {
// The new item has a different display index, add previous items with same
// index to the result.
addItemsWithSameIndex(result, itemsWithSameIndex);
itemsWithSameIndex.clear();
}
itemsWithSameIndex.add(item);
}
private static void addItemsWithSameIndex(final List<PlaylistLocalItem> result,
final List<PlaylistLocalItem> itemsWithSameIndex) {
Collections.sort(itemsWithSameIndex,
Comparator.comparing(PlaylistLocalItem::getOrderingName,
Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)));
result.addAll(itemsWithSameIndex);
}
}

View File

@ -155,15 +155,14 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
final Toast successToast = Toast.makeText(getContext(), toastText, Toast.LENGTH_SHORT); final Toast successToast = Toast.makeText(getContext(), toastText, Toast.LENGTH_SHORT);
playlistDisposables.add(manager.appendToPlaylist(playlist.getUid(), streams) playlistDisposables.add(manager.appendToPlaylist(playlist.uid, streams)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe(ignored -> { .subscribe(ignored -> {
successToast.show(); successToast.show();
if (playlist.thumbnailUrl != null if (playlist.thumbnailUrl.equals(PlaylistEntity.DEFAULT_THUMBNAIL)) {
&& playlist.thumbnailUrl.equals(PlaylistEntity.DEFAULT_THUMBNAIL)) {
playlistDisposables.add(manager playlistDisposables.add(manager
.changePlaylistThumbnail(playlist.getUid(), streams.get(0).getUid(), .changePlaylistThumbnail(playlist.uid, streams.get(0).getUid(),
false) false)
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe(ignore -> successToast.show())); .subscribe(ignore -> successToast.show()));

View File

@ -38,17 +38,18 @@ import android.view.ViewGroup
import android.widget.Button import android.widget.Button
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.math.MathUtils
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.evernote.android.state.State
import com.xwray.groupie.GroupieAdapter import com.xwray.groupie.GroupieAdapter
import com.xwray.groupie.Item import com.xwray.groupie.Item
import com.xwray.groupie.OnItemClickListener import com.xwray.groupie.OnItemClickListener
import com.xwray.groupie.OnItemLongClickListener import com.xwray.groupie.OnItemLongClickListener
import icepick.State
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable import io.reactivex.rxjava3.disposables.CompositeDisposable
@ -59,7 +60,6 @@ import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.databinding.FragmentFeedBinding import org.schabi.newpipe.databinding.FragmentFeedBinding
import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.ErrorUtil
import org.schabi.newpipe.error.UserAction import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
@ -453,33 +453,24 @@ class FeedFragment : BaseStateFragment<FeedState>() {
if (t is FeedLoadService.RequestException && if (t is FeedLoadService.RequestException &&
t.cause is ContentNotAvailableException t.cause is ContentNotAvailableException
) { ) {
disposables.add( Single.fromCallable {
Single.fromCallable { NewPipeDatabase.getInstance(requireContext()).subscriptionDAO()
NewPipeDatabase.getInstance(requireContext()).subscriptionDAO() .getSubscription(t.subscriptionId)
.getSubscription(t.subscriptionId) }.subscribeOn(Schedulers.io())
} .observeOn(AndroidSchedulers.mainThread())
.subscribeOn(Schedulers.io()) .subscribe(
.observeOn(AndroidSchedulers.mainThread()) { subscriptionEntity ->
.subscribe( handleFeedNotAvailable(
{ subscriptionEntity -> subscriptionEntity,
handleFeedNotAvailable( t.cause,
subscriptionEntity, errors.subList(i + 1, errors.size)
t.cause, )
errors.subList(i + 1, errors.size) },
) { throwable -> Log.e(TAG, "Unable to process", throwable) }
}, )
{ throwable -> Log.e(TAG, "Unable to process", throwable) } return // this will be called on the remaining errors by handleFeedNotAvailable()
)
)
// this will be called on the remaining errors by handleFeedNotAvailable()
return@handleItemsErrors
} }
} }
if (errors.isNotEmpty()) {
// if no error was a ContentNotAvailableException, show a general error snackbar
ErrorUtil.showSnackbar(this, ErrorInfo(errors, UserAction.REQUESTED_FEED, ""))
}
} }
private fun handleFeedNotAvailable( private fun handleFeedNotAvailable(
@ -549,7 +540,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
var typeface = Typeface.DEFAULT var typeface = Typeface.DEFAULT
var backgroundSupplier = { ctx: Context -> var backgroundSupplier = { ctx: Context ->
resolveDrawable(ctx, android.R.attr.selectableItemBackground) resolveDrawable(ctx, R.attr.selectableItemBackground)
} }
if (doCheck) { if (doCheck) {
// If the uploadDate is null or true we should highlight the item // If the uploadDate is null or true we should highlight the item
@ -562,7 +553,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
LayerDrawable( LayerDrawable(
arrayOf( arrayOf(
resolveDrawable(ctx, R.attr.dashed_border), resolveDrawable(ctx, R.attr.dashed_border),
resolveDrawable(ctx, android.R.attr.selectableItemBackground) resolveDrawable(ctx, R.attr.selectableItemBackground)
) )
) )
} }
@ -588,7 +579,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
// state until the user scrolls them out of the visible area which causes a update/bind-call // state until the user scrolls them out of the visible area which causes a update/bind-call
groupAdapter.notifyItemRangeChanged( groupAdapter.notifyItemRangeChanged(
0, 0,
highlightCount.coerceIn(lastNewItemsCount, groupAdapter.itemCount) MathUtils.clamp(highlightCount, lastNewItemsCount, groupAdapter.itemCount)
) )
if (highlightCount > 0) { if (highlightCount > 0) {
@ -607,13 +598,9 @@ class FeedFragment : BaseStateFragment<FeedState>() {
execOnEnd = { execOnEnd = {
// Disabled animations would result in immediately hiding the button // Disabled animations would result in immediately hiding the button
// after it showed up // after it showed up
// Context can be null in some cases, so we have to make sure it is not null in if (DeviceUtils.hasAnimationsAnimatorDurationEnabled(context)) {
// order to avoid a NullPointerException // Hide the new items-"popup" after 10s
context?.let { hideNewItemsLoaded(true, 10000)
if (DeviceUtils.hasAnimationsAnimatorDurationEnabled(it)) {
// Hide the new items button after 10s
hideNewItemsLoaded(true, 10000)
}
} }
} }
) )

View File

@ -13,9 +13,9 @@ sealed class FeedState {
data class LoadedState( data class LoadedState(
val items: List<StreamItem>, val items: List<StreamItem>,
val oldestUpdate: OffsetDateTime?, val oldestUpdate: OffsetDateTime? = null,
val notLoadedCount: Long, val notLoadedCount: Long,
val itemsErrors: List<Throwable> val itemsErrors: List<Throwable> = emptyList()
) : FeedState() ) : FeedState()
data class ErrorState( data class ErrorState(

View File

@ -86,7 +86,7 @@ class FeedViewModel(
.subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) -> .subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) ->
mutableStateLiveData.postValue( mutableStateLiveData.postValue(
when (event) { when (event) {
is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, listOf()) is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount)
is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage) is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage)
is SuccessResultEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, event.itemsErrors) is SuccessResultEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, event.itemsErrors)
is ErrorResultEvent -> FeedState.ErrorState(event.error) is ErrorResultEvent -> FeedState.ErrorState(event.error)

View File

@ -18,8 +18,8 @@ import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_AUDIO_STREAM
import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_STREAM import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_STREAM
import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM
import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.PicassoHelper
import org.schabi.newpipe.util.StreamTypeUtil import org.schabi.newpipe.util.StreamTypeUtil
import org.schabi.newpipe.util.image.PicassoHelper
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.function.Consumer import java.util.function.Consumer

View File

@ -1,8 +1,6 @@
package org.schabi.newpipe.local.feed.notifications package org.schabi.newpipe.local.feed.notifications
import android.app.Notification
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
@ -14,41 +12,46 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat import androidx.core.app.PendingIntentCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.getSystemService
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import com.squareup.picasso.Picasso import com.squareup.picasso.Picasso
import com.squareup.picasso.Target import com.squareup.picasso.Target
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.service.FeedUpdateInfo import org.schabi.newpipe.local.feed.service.FeedUpdateInfo
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.image.PicassoHelper import org.schabi.newpipe.util.PicassoHelper
/** /**
* Helper for everything related to show notifications about new streams to the user. * Helper for everything related to show notifications about new streams to the user.
*/ */
class NotificationHelper(val context: Context) { class NotificationHelper(val context: Context) {
private val manager = NotificationManagerCompat.from(context)
private val manager = context.getSystemService(
Context.NOTIFICATION_SERVICE
) as NotificationManager
private val iconLoadingTargets = ArrayList<Target>() private val iconLoadingTargets = ArrayList<Target>()
/** /**
* Show notifications for new streams from a single channel. The individual notifications are * Show a notification about new streams from a single channel.
* expandable on Android 7.0 and later. * Opening the notification will open the corresponding channel page.
*
* Opening the summary notification will open the corresponding channel page. Opening the
* individual notifications will open the corresponding video.
*/ */
fun displayNewStreamsNotifications(data: FeedUpdateInfo) { fun displayNewStreamsNotification(data: FeedUpdateInfo) {
val newStreams = data.newStreams val newStreams: List<StreamInfoItem> = data.newStreams
val summary = context.resources.getQuantityString( val summary = context.resources.getQuantityString(
R.plurals.new_streams, newStreams.size, newStreams.size R.plurals.new_streams, newStreams.size, newStreams.size
) )
val summaryBuilder = NotificationCompat.Builder( val builder = NotificationCompat.Builder(
context, context,
context.getString(R.string.streams_notification_channel_id) context.getString(R.string.streams_notification_channel_id)
) )
.setContentTitle(data.name) .setContentTitle(Localization.concatenateStrings(data.name, summary))
.setContentText(summary) .setContentText(
data.listInfo.relatedItems.joinToString(
context.getString(R.string.enumeration_comma)
) { x -> x.name }
)
.setNumber(newStreams.size) .setNumber(newStreams.size)
.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE) .setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) .setPriority(NotificationCompat.PRIORITY_DEFAULT)
@ -57,23 +60,21 @@ class NotificationHelper(val context: Context) {
.setColorized(true) .setColorized(true)
.setAutoCancel(true) .setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_SOCIAL) .setCategory(NotificationCompat.CATEGORY_SOCIAL)
.setGroupSummary(true)
.setGroup(data.url)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
// Build a summary notification for Android versions < 7.0 // Build style
val style = NotificationCompat.InboxStyle() val style = NotificationCompat.InboxStyle()
.setBigContentTitle(data.name)
newStreams.forEach { style.addLine(it.name) } newStreams.forEach { style.addLine(it.name) }
summaryBuilder.setStyle(style) style.setSummaryText(summary)
style.setBigContentTitle(data.name)
builder.setStyle(style)
// open the channel page when clicking on the summary notification // open the channel page when clicking on the notification
summaryBuilder.setContentIntent( builder.setContentIntent(
PendingIntentCompat.getActivity( PendingIntentCompat.getActivity(
context, context,
data.pseudoId, data.pseudoId,
NavigationHelper NavigationHelper
.getChannelIntent(context, data.serviceId, data.url) .getChannelIntent(context, data.listInfo.serviceId, data.listInfo.url)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK), .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
0, 0,
false false
@ -83,23 +84,13 @@ class NotificationHelper(val context: Context) {
// a Target is like a listener for image loading events // a Target is like a listener for image loading events
val target = object : Target { val target = object : Target {
override fun onBitmapLoaded(bitmap: Bitmap, from: Picasso.LoadedFrom) { override fun onBitmapLoaded(bitmap: Bitmap, from: Picasso.LoadedFrom) {
// set channel icon only if there is actually one (for Android versions < 7.0) builder.setLargeIcon(bitmap) // set only if there is actually one
summaryBuilder.setLargeIcon(bitmap) manager.notify(data.pseudoId, builder.build())
// Show individual stream notifications, set channel icon only if there is actually
// one
showStreamNotifications(newStreams, data.serviceId, bitmap)
// Show summary notification
manager.notify(data.pseudoId, summaryBuilder.build())
iconLoadingTargets.remove(this) // allow it to be garbage-collected iconLoadingTargets.remove(this) // allow it to be garbage-collected
} }
override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) { override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) {
// Show individual stream notifications manager.notify(data.pseudoId, builder.build())
showStreamNotifications(newStreams, data.serviceId, null)
// Show summary notification
manager.notify(data.pseudoId, summaryBuilder.build())
iconLoadingTargets.remove(this) // allow it to be garbage-collected iconLoadingTargets.remove(this) // allow it to be garbage-collected
} }
@ -115,49 +106,6 @@ class NotificationHelper(val context: Context) {
PicassoHelper.loadNotificationIcon(data.avatarUrl).into(target) PicassoHelper.loadNotificationIcon(data.avatarUrl).into(target)
} }
private fun showStreamNotifications(
newStreams: List<StreamInfoItem>,
serviceId: Int,
channelIcon: Bitmap?
) {
for (stream in newStreams) {
val notification = createStreamNotification(stream, serviceId, channelIcon)
manager.notify(stream.url.hashCode(), notification)
}
}
private fun createStreamNotification(
item: StreamInfoItem,
serviceId: Int,
channelIcon: Bitmap?
): Notification {
return NotificationCompat.Builder(
context,
context.getString(R.string.streams_notification_channel_id)
)
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
.setLargeIcon(channelIcon)
.setContentTitle(item.name)
.setContentText(item.uploaderName)
.setGroup(item.uploaderUrl)
.setColor(ContextCompat.getColor(context, R.color.ic_launcher_background))
.setColorized(true)
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
.setContentIntent(
// Open the stream link in the player when clicking on the notification.
PendingIntentCompat.getActivity(
context,
item.url.hashCode(),
NavigationHelper.getStreamIntent(context, serviceId, item.url, item.name),
PendingIntent.FLAG_UPDATE_CURRENT,
false
)
)
.setSilent(true) // Avoid creating noise for individual stream notifications.
.build()
}
companion object { companion object {
/** /**
* Check whether notifications are enabled on the device. * Check whether notifications are enabled on the device.
@ -176,7 +124,9 @@ class NotificationHelper(val context: Context) {
fun areNotificationsEnabledOnDevice(context: Context): Boolean { fun areNotificationsEnabledOnDevice(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelId = context.getString(R.string.streams_notification_channel_id) val channelId = context.getString(R.string.streams_notification_channel_id)
val manager = context.getSystemService<NotificationManager>()!! val manager = context.getSystemService(
Context.NOTIFICATION_SERVICE
) as NotificationManager
val enabled = manager.areNotificationsEnabled() val enabled = manager.areNotificationsEnabled()
val channel = manager.getNotificationChannel(channelId) val channel = manager.getNotificationChannel(channelId)
val importance = channel?.importance val importance = channel?.importance

Some files were not shown because too many files have changed in this diff Show More