Merge pull request #8889 from TeamNewPipe/release-0.24.0

Release v0.24.0 (990)
This commit is contained in:
Stypox 2022-09-25 13:56:20 +02:00 committed by GitHub
commit 0c63950429
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
467 changed files with 9880 additions and 8928 deletions

View File

@ -1,6 +1,6 @@
name: Bug report
description: Create a bug report to help us improve
labels: [bug]
labels: [bug, needs triage]
body:
- type: markdown
attributes:
@ -18,6 +18,8 @@ body:
required: true
- label: "I made sure that there are *no existing issues* - [open](https://github.com/TeamNewPipe/NewPipe/issues) or [closed](https://github.com/TeamNewPipe/NewPipe/issues?q=is%3Aissue+is%3Aclosed) - which I could contribute my information to."
required: true
- label: "I have read the [FAQ](https://newpipe.net/FAQ/) and my problem isn't listed."
required: true
- label: "I have taken the time to fill in all the required details. I understand that the bug report will be dismissed otherwise."
required: true
- label: "This issue contains only one bug."
@ -40,7 +42,7 @@ body:
label: Steps to reproduce the bug
description: |
What did you do for the bug to show up?
If you can't cause the bug to show up again reliably (and hence don't have a proper set of steps to give us), please still try to give as many details as possible on how you think you encountered the bug.
placeholder: |
1. Go to '...'
@ -69,11 +71,11 @@ body:
label: Screenshots/Screen recordings
description: |
A picture or video is worth a thousand words.
If applicable, add screenshots or a screen recording to help explain your problem.
GitHub supports uploading them directly in the text box.
If your file is too big for Github to accept, try to compress it (ZIP-file) or feel free to paste a link to an image/video hoster here instead.
:heavy_exclamation_mark: DON'T POST SCREENSHOTS OF THE ERROR PAGE.
Instead, follow the instructions in the "Logs" section below.

View File

@ -1,6 +1,6 @@
name: Feature request
description: Suggest an idea for this project
labels: [enhancement]
labels: [enhancement, needs triage]
body:
- type: markdown
attributes:
@ -8,7 +8,6 @@ body:
Thank you for helping to make NewPipe better by suggesting a feature. :hugs:
Your ideas are highly welcome! The app is made for you, the users, after all.
- type: checkboxes
id: checklist
attributes:
@ -16,6 +15,8 @@ body:
options:
- label: "I made sure that there are *no existing issues* - [open](https://github.com/TeamNewPipe/NewPipe/issues) or [closed](https://github.com/TeamNewPipe/NewPipe/issues?q=is%3Aissue+is%3Aclosed) - which I could contribute my information to."
required: true
- label: "I have read the [FAQ](https://newpipe.net/FAQ/) and my problem isn't listed."
required: true
- label: "I'm aware that this is a request for NewPipe itself and that requests for adding a new service need to be made at [NewPipeExtractor](https://github.com/TeamNewPipe/NewPipeExtractor/issues)."
required: true
- label: "I have taken the time to fill in all the required details. I understand that the feature request will be dismissed otherwise."
@ -43,7 +44,7 @@ body:
Describe any problem or limitation you come across while using the app which would be solved by this feature.
validations:
required: true
- type: textarea
id: additional-information
attributes:

View File

@ -1,6 +1,6 @@
name: Question
description: Ask about anything NewPipe-related
labels: [question]
labels: [question, needs triage]
body:
- type: markdown
attributes:
@ -16,6 +16,8 @@ body:
options:
- label: "I made sure that there are *no existing issues* - [open](https://github.com/TeamNewPipe/NewPipe/issues) or [closed](https://github.com/TeamNewPipe/NewPipe/issues?q=is%3Aissue+is%3Aclosed) - which I could contribute my information to."
required: true
- label: "I have read the [FAQ](https://newpipe.net/FAQ/) and my question isn't listed."
required: true
- label: "I have taken the time to fill in all the required details. I understand that the question will be dismissed otherwise."
required: true
- label: "I have read and understood the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/dev/.github/CONTRIBUTING.md)."
@ -27,7 +29,7 @@ body:
label: What is/are your question(s)?
validations:
required: true
- type: textarea
id: additional-information
attributes:

View File

@ -25,7 +25,7 @@
<!-- Delete this if it doesn't apply to your PR. -->
-
#### APK testing
#### APK testing
<!-- Use a new, meaningfully named branch. The name is used as a suffix for the app ID to allow installing and testing multiple versions of NewPipe, e.g. "commentfix", if your PR implements a bugfix for comments. (No names like "patch-0" and "feature-1".) -->
<!-- Remove the following line if you directly link the APK created by the CI pipeline. Directly linking is preferred if you need to let users test.-->
The APK can be found by going to the "Checks" tab below the title. On the left pane, click on "CI", scroll down to "artifacts" and click "app" to download the zip file which contains the debug APK of this PR.

View File

@ -6,7 +6,7 @@ on:
branches:
- dev
- master
- release/**
- release**
paths-ignore:
- 'README.md'
- 'doc/**'
@ -31,6 +31,10 @@ on:
jobs:
build-and-test-jvm:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v3
- uses: gradle/wrapper-validation-action@v1
@ -64,6 +68,10 @@ jobs:
matrix:
# api-level 19 is min sdk, but throws errors related to desugaring
api-level: [ 21, 29 ]
permissions:
contents: read
steps:
- uses: actions/checkout@v3
@ -81,7 +89,7 @@ jobs:
# workaround to emulator bug: https://github.com/ReactiveCircus/android-emulator-runner/issues/160
emulator-build: 7425822
script: ./gradlew connectedCheck --stacktrace
- name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553
uses: actions/upload-artifact@v3
if: failure()
@ -91,6 +99,10 @@ jobs:
sonar:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v3
with:

View File

@ -6,6 +6,10 @@ on:
issues:
types: [opened, edited]
permissions:
issues: write
pull-requests: write
jobs:
try-minimize:
runs-on: ubuntu-latest

View File

@ -9,6 +9,10 @@ on:
# Run daily at midnight.
- cron: '0 0 * * *'
permissions:
issues: write
pull-requests: write
jobs:
noResponse:
runs-on: ubuntu-latest
@ -17,4 +21,4 @@ jobs:
with:
token: ${{ github.token }}
daysUntilClose: 14
responseRequiredLabel: waiting-for-author
responseRequiredLabel: waiting for author

124
README.md
View File

@ -1,6 +1,6 @@
<p align="center"><a href="https://newpipe.net"><img src="assets/new_pipe_icon_5.png" width="150"></a></p>
<h2 align="center"><b>NewPipe</b></h2>
<h4 align="center">A libre lightweight streaming frontend for Android.</h4>
<h4 align="center">A libre lightweight streaming front-end for Android.</h4>
<p align="center"><a href="https://f-droid.org/packages/org.schabi.newpipe/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on-en.svg" alt="Get it on F-Droid" height=80/></a></p>
@ -13,15 +13,15 @@
<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>
<hr>
<p align="center"><a href="#screenshots">Screenshots</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>
<hr>
*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).*
<b>WARNING: THIS IS A BETA VERSION, THEREFORE YOU MAY ENCOUNTER BUGS. IF YOU DO, OPEN AN ISSUE VIA OUR GITHUB REPOSITORY.</b>
<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>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
@ -38,62 +38,66 @@
[<img src="fastlane/metadata/android/en-US/images/tenInchScreenshots/shot_11.png" width=405>](fastlane/metadata/android/en-US/images/tenInchScreenshots/shot_11.png)
[<img src="fastlane/metadata/android/en-US/images/tenInchScreenshots/shot_12.png" width=405>](fastlane/metadata/android/en-US/images/tenInchScreenshots/shot_12.png)
### Supported Services
NewPipe currently supports these services:
<!-- We link to the service websites separately to avoid people accidentally opening a website they didn't want to. -->
* YouTube ([website](https://www.youtube.com/)) and YouTube Music ([website](https://music.youtube.com/)) ([wiki](https://en.wikipedia.org/wiki/YouTube))
* PeerTube ([website](https://joinpeertube.org/)) and all its instances (open the website to know what that means!) ([wiki](https://en.wikipedia.org/wiki/PeerTube))
* Bandcamp ([website](https://bandcamp.com/)) ([wiki](https://en.wikipedia.org/wiki/Bandcamp))
* SoundCloud ([website](https://soundcloud.com/)) ([wiki](https://en.wikipedia.org/wiki/SoundCloud))
* media.ccc.de ([website](https://media.ccc.de/)) ([wiki](https://en.wikipedia.org/wiki/Chaos_Computer_Club))
As you can see, NewPipe supports multiple video and audio services. Though it started off with YouTube, other people have added more services over the years, making NewPipe more and more versatile!
Partially due to circumstance, and partially due to its popularity, YouTube is the best supported out of these services. If you use or are familiar with any of these other services, please help us improve support for them! We're looking for maintainers for SoundCloud and PeerTube.
If you intend to add a new service, please get in touch with us first! Our [docs](https://teamnewpipe.github.io/documentation/) provide more information on how a new service can be added to the app and to the [NewPipe Extractor](https://github.com/TeamNewPipe/NewPipeExtractor).
## Description
NewPipe does not use any Google framework libraries, nor the YouTube API. Websites are only parsed to fetch required info, so this app can be used on devices without Google services installed. Also, you don't need a YouTube account to use NewPipe, which is copylefted libre software.
NewPipe works by fetching the required data from the official API (e.g. PeerTube) of the service you're using. If the official API is restricted (e.g. YouTube) for our purposes, or is proprietary, the app parses the website or uses an internal API instead. This means that you don't need an account on any service to use NewPipe.
Also, since they are free and open source software, neither the app nor the Extractor use any proprietary libraries or frameworks, such as Google Play Services. This means you can use NewPipe on devices or custom ROMs that do not have Google apps installed.
### Features
* Search videos
* No Login Required
* Display general info about videos
* Watch YouTube videos
* Listen to YouTube videos
* Popup mode (floating player)
* Select streaming player to watch video with
* Download videos
* Download audio only
* Open a video in Kodi
* Show next/related videos
* Search YouTube in a specific language
* Watch/Block age restricted material
* Display general info about channels
* Search channels
* Watch videos from a channel
* Orbot/Tor support (not yet directly)
* 1080p/2K/4K support
* View history
* Subscribe to channels
* Search history
* Search/watch playlists
* Watch as enqueued playlists
* Enqueue videos
* Local playlists
* Subtitles
* Livestream support
* Show comments
* Watch videos at resolutions up to 4K
* Listen to audio in the background, only loading the audio stream to save data
* Popup mode (floating player, aka Picture-in-Picture)
* Watch live streams
* Show/hide subtitles/closed captions
* Search videos and audios (on YouTube, you can specify the content language as well)
* Enqueue videos (and optionally save them as local playlists)
* Show/hide general information about videos (such as description and tags)
* Show/hide next/related videos
* Show/hide comments
* Search videos, audios, channels, playlists and albums
* Browse videos and audios within a channel
* Subscribe to channels (yes, without logging into any account!)
* Get notifications about new videos from channels you're subscribed to
* Create and edit channel groups (for easier browsing and management)
* Browse video feeds generated from your channel groups
* View and search your watch history
* Search and watch playlists (these are remote playlists, which means they're fetched from the service you're browsing)
* Create and edit local playlists (these are created and saved within the app, and have nothing to do with any service)
* Download videos/audios/subtitles (closed captions)
* Open in Kodi
* Watch/Block age-restricted material
### Supported Services
NewPipe supports multiple services. Our [docs](https://teamnewpipe.github.io/documentation/) provide more info on how a new service can be added to the app and the extractor. Please get in touch with us if you intend to add a new one. Currently supported services are:
* YouTube
* SoundCloud \[beta\]
* media.ccc.de \[beta\]
* PeerTube instances \[beta\]
* Bandcamp \[beta\]
<!-- Hidden span to keep old links compatible. -->
<!-- Hidden span to keep old links compatible. You should remove this span if you're translating the README into another language.-->
<span id="updates"></span>
## Installation and updates
You can install NewPipe using one of the following methods:
1. Add our custom repo to F-Droid and install it from there. The instructions are here: https://newpipe.net/FAQ/tutorials/install-add-fdroid-repo/
2. Download the APK from [GitHub Releases](https://github.com/TeamNewPipe/NewPipe/releases) and install it.
3. Update via F-Droid. This is the slowest method of getting updates, as F-Droid must recognize changes, build the APK itself, sign it, then push the update to users.
3. Update via F-Droid. This is the slowest method of getting updates, as F-Droid must recognize changes, build the APK itself, sign it, and then push the update to users.
4. Build a debug APK yourself. This is the fastest way to get new features on your device, but is much more complicated, so we recommend using one of the other methods.
5. If you're interested in a specific feature or bugfix provided in a Pull Request in this repo, you can also download its APK from within the PR. Read the PR description for instructions. The great thing about PR-specific APKs is that they're installed side-by-side the official app, so you don't have to worry about losing your data or messing anything up.
We recommend method 1 for most users. APKs installed using method 1 or 2 are compatible with each other, but not with those installed using method 3. This is due to the same signing key (ours) being used for 1 and 2, but a different signing key (F-Droid's) being used for 3. Building a debug APK using method 4 excludes a key entirely. Signing keys help ensure that a user isn't tricked into installing a malicious update to an app.
We recommend method 1 for most users. APKs installed using method 1 or 2 are compatible with each other (meaning that if you installed NewPipe using either method 1 or 2, you can also update NewPipe using the other), but not with those installed using method 3. This is due to the same signing key (ours) being used for 1 and 2, but a different signing key (F-Droid's) being used for 3. Building a debug APK using method 4 excludes a key entirely. Signing keys help ensure that a user isn't tricked into installing a malicious update to an app. When using method 5, each APK is signed with a different random key supplied by GitHub Actions, so you cannot even update it. You will have to backup and restore the app data each time you wish to use a new APK.
In the meanwhile, if you want to switch sources for some reason (e.g. NewPipe's core functionality breaks and F-Droid doesn't have the latest update yet), we recommend following this procedure:
1. Back up your data via Settings > Content > Export Database so you keep your history, subscriptions, and playlists
@ -101,30 +105,29 @@ In the meanwhile, if you want to switch sources for some reason (e.g. NewPipe's
3. Download the APK from the new source and install it
4. Import the data from step 1 via Settings > Content > Import Database
## Contribution
Whether you have ideas, translations, design changes, code cleaning, or real heavy code changes, help is always welcome.
The more is done the better it gets!
<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>
If you'd like to get involved, check our [contribution notes](.github/CONTRIBUTING.md).
## Contribution
Whether you have ideas, translations, design changes, code cleaning, or even major code changes, help is always welcome. The app gets better and better with each contribution, no matter how big or small! If you'd like to get involved, check our [contribution notes](.github/CONTRIBUTING.md).
<a href="https://hosted.weblate.org/engage/newpipe/">
<img src="https://hosted.weblate.org/widgets/newpipe/-/287x66-grey.png" alt="Translation status" />
</a>
## Donate
If you like NewPipe we'd be happy about a donation. You can either send bitcoin or donate via Bountysource or Liberapay. For further info on donating to NewPipe, please visit our [website](https://newpipe.net/donate).
If you like NewPipe, you're welcome to send a donation. We prefer Liberapay, as it is both open-source and non-profit. For further info on donating to NewPipe, please visit our [website](https://newpipe.net/donate).
<table>
<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://liberapay.com/TeamNewPipe/"><img src="https://upload.wikimedia.org/wikipedia/commons/2/27/Liberapay_logo_v2_white-on-yellow.svg" alt="Liberapay" width="80px" ></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>
</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>
@ -134,14 +137,9 @@ If you like NewPipe we'd be happy about a donation. You can either send bitcoin
## Privacy Policy
The NewPipe project aims to provide a private, anonymous experience for using media web services.
Therefore, the app does not collect any data without your consent. NewPipe's privacy policy explains in detail what data is sent and stored when you send a crash report, or comment in our blog. You can find the document [here](https://newpipe.net/legal/privacy/).
The NewPipe project aims to provide a private, anonymous experience for using web-based media services. Therefore, the app does not collect any data without your consent. NewPipe's privacy policy explains in detail what data is sent and stored when you send a crash report, or leave a comment in our blog. You can find the document [here](https://newpipe.net/legal/privacy/).
## License
[![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](https://www.gnu.org/licenses/gpl-3.0.en.html)
NewPipe is Free Software: You can use, study, share, and improve it at
will. Specifically you can redistribute and/or modify it under the terms of the
[GNU General Public License](https://www.gnu.org/licenses/gpl.html) as
published by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
NewPipe is Free Software: You can use, study, share, and improve it at will. Specifically you can redistribute and/or modify it under the terms of the [GNU General Public License](https://www.gnu.org/licenses/gpl.html) as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

View File

@ -14,15 +14,12 @@ android {
defaultConfig {
applicationId "org.schabi.newpipe"
resValue "string", "app_name", "NewPipe"
minSdk 19
minSdk 21
targetSdk 29
versionCode 989
versionName "0.23.3"
multiDexEnabled true
versionCode 990
versionName "0.24.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables.useSupportLibrary = true
javaCompileOptions {
annotationProcessorOptions {
@ -98,14 +95,14 @@ android {
}
ext {
checkstyleVersion = '10.0'
checkstyleVersion = '10.3.1'
androidxLifecycleVersion = '2.3.1'
androidxRoomVersion = '2.4.2'
androidxLifecycleVersion = '2.5.1'
androidxRoomVersion = '2.4.3'
androidxWorkVersion = '2.7.1'
icepickVersion = '3.2.0'
exoPlayerVersion = '2.17.1'
exoPlayerVersion = '2.18.1'
googleAutoServiceVersion = '1.0.1'
groupieVersion = '2.10.1'
markwonVersion = '4.6.2'
@ -113,7 +110,7 @@ ext {
leakCanaryVersion = '2.5'
stethoVersion = '1.6.0'
mockitoVersion = '4.0.0'
assertJVersion = '3.22.0'
assertJVersion = '3.23.1'
}
configurations {
@ -182,7 +179,7 @@ sonarqube {
dependencies {
/** Desugaring **/
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.6'
/** NewPipe libraries **/
// You can use a local version by uncommenting a few lines in settings.gradle
@ -190,27 +187,27 @@ dependencies {
// name and the commit hash with the commit hash of the (pushed) commit you want to test
// This works thanks to JitPack: https://jitpack.io/
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
implementation 'com.github.TeamNewPipe:NewPipeExtractor:6a858368c86bc9a55abee586eb6c733e86c26b97'
implementation 'com.github.TeamNewPipe:NewPipeExtractor:5c710da160f488bb40ab2cf4469bec9bd4cefd38'
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
/** Checkstyle **/
checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}"
ktlint 'com.pinterest:ktlint:0.44.0'
ktlint 'com.pinterest:ktlint:0.45.2'
/** Kotlin **/
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin_version}"
/** AndroidX **/
implementation 'androidx.appcompat:appcompat:1.3.1'
implementation 'androidx.appcompat:appcompat:1.4.2'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
implementation 'androidx.core:core-ktx:1.6.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.core:core-ktx:1.8.0'
implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'androidx.fragment:fragment-ktx:1.3.6'
implementation "androidx.lifecycle:lifecycle-livedata:${androidxLifecycleVersion}"
implementation "androidx.lifecycle:lifecycle-viewmodel:${androidxLifecycleVersion}"
implementation 'androidx.fragment:fragment-ktx:1.4.1'
implementation "androidx.lifecycle:lifecycle-livedata-ktx:${androidxLifecycleVersion}"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${androidxLifecycleVersion}"
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
implementation 'androidx.media:media:1.5.0'
implementation 'androidx.multidex:multidex:2.0.1'
implementation 'androidx.media:media:1.6.0'
implementation 'androidx.preference:preference:1.2.0'
implementation 'androidx.recyclerview:recyclerview:1.2.1'
implementation "androidx.room:room-runtime:${androidxRoomVersion}"
@ -220,10 +217,9 @@ dependencies {
// Newer version specified to prevent accessibility regressions with RecyclerView, see:
// https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.webkit:webkit:1.4.0'
implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}"
implementation "androidx.work:work-rxjava3:${androidxWorkVersion}"
implementation 'com.google.android.material:material:1.5.0'
implementation 'com.google.android.material:material:1.6.1'
/** Third-party libraries **/
// Instance state boilerplate elimination
@ -234,11 +230,16 @@ dependencies {
implementation "org.jsoup:jsoup:1.15.3"
// HTTP client
//noinspection GradleDependency --> do not update okhttp to keep supporting Android 4.4 users
implementation "com.squareup.okhttp3:okhttp:3.12.13"
implementation "com.squareup.okhttp3:okhttp:4.10.0"
// Media player
implementation "com.google.android.exoplayer:exoplayer:${exoPlayerVersion}"
implementation "com.google.android.exoplayer:exoplayer-core:${exoPlayerVersion}"
implementation "com.google.android.exoplayer:exoplayer-dash:${exoPlayerVersion}"
implementation "com.google.android.exoplayer:exoplayer-database:${exoPlayerVersion}"
implementation "com.google.android.exoplayer:exoplayer-datasource:${exoPlayerVersion}"
implementation "com.google.android.exoplayer:exoplayer-hls:${exoPlayerVersion}"
implementation "com.google.android.exoplayer:exoplayer-smoothstreaming:${exoPlayerVersion}"
implementation "com.google.android.exoplayer:exoplayer-ui:${exoPlayerVersion}"
implementation "com.google.android.exoplayer:extension-mediasession:${exoPlayerVersion}"
// Metadata generator for service descriptors
@ -257,9 +258,6 @@ dependencies {
implementation "io.noties.markwon:core:${markwonVersion}"
implementation "io.noties.markwon:linkify:${markwonVersion}"
// File picker
implementation "com.nononsenseapps:filepicker:4.2.1"
// Crash reporting
implementation "ch.acra:acra-core:5.9.3"
@ -273,7 +271,7 @@ dependencies {
implementation "com.jakewharton.rxbinding4:rxbinding:4.0.0"
// Date and time formatting
implementation "org.ocpsoft.prettytime:prettytime:5.0.2.Final"
implementation "org.ocpsoft.prettytime:prettytime:5.0.3.Final"
/** Debugging **/
// Memory leak detection

View File

@ -18,7 +18,6 @@
-dontobfuscate
-keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; }
-keep class org.ocpsoft.prettytime.i18n.** { *; }
-keep class org.mozilla.javascript.** { *; }
@ -26,9 +25,6 @@
-keep class com.google.android.exoplayer2.** { *; }
-dontwarn org.mozilla.javascript.tools.**
-dontwarn android.arch.util.paging.CountedDataSource
-dontwarn android.arch.persistence.room.paging.LimitOffsetDataSource
# Rules for icepick. Copy paste from https://github.com/frankiesardo/icepick
-dontwarn icepick.**
@ -39,12 +35,11 @@
}
-keepnames class * { @icepick.State *;}
# Rules for OkHttp. Copy paste from https://github.com/square/okhttp
## Rules for OkHttp. Copy paste from https://github.com/square/okhttp
-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn javax.annotation.**
# A resource is loaded with a relative path so the package of this class must be preserved.
-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase
##
-keepclassmembers class * implements java.io.Serializable {
static final long serialVersionUID;
!static !transient <fields>;

View File

@ -44,7 +44,7 @@
</receiver>
<service
android:name=".player.MainPlayer"
android:name=".player.PlayerService"
android:exported="false"
android:foregroundServiceType="mediaPlayback">
<intent-filter>

View File

@ -282,11 +282,9 @@ public abstract class FragmentStatePagerAdapterMenuWorkaround extends PagerAdapt
@Nullable
public Parcelable saveState() {
Bundle state = null;
if (mSavedState.size() > 0) {
if (!mSavedState.isEmpty()) {
state = new Bundle();
final Fragment.SavedState[] fss = new Fragment.SavedState[mSavedState.size()];
mSavedState.toArray(fss);
state.putParcelableArray("states", fss);
state.putParcelableArray("states", mSavedState.toArray(new Fragment.SavedState[0]));
}
for (int i = 0; i < mFragments.size(); i++) {
final Fragment f = mFragments.get(i);

View File

@ -14,7 +14,6 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout;
import org.schabi.newpipe.R;
import java.lang.reflect.Field;
import java.util.Arrays;
import java.util.List;
// See https://stackoverflow.com/questions/56849221#57997489
@ -27,7 +26,7 @@ public final class FlingBehavior extends AppBarLayout.Behavior {
private boolean allowScroll = true;
private final Rect globalRect = new Rect();
private final List<Integer> skipInterceptionOfElements = Arrays.asList(
private final List<Integer> skipInterceptionOfElements = List.of(
R.id.itemsListPanel, R.id.playbackSeekBar,
R.id.playPauseButton, R.id.playPreviousButton, R.id.playNextButton);
@ -67,7 +66,7 @@ public final class FlingBehavior extends AppBarLayout.Behavior {
public boolean onInterceptTouchEvent(@NonNull final CoordinatorLayout parent,
@NonNull final AppBarLayout child,
@NonNull final MotionEvent ev) {
for (final Integer element : skipInterceptionOfElements) {
for (final int element : skipInterceptionOfElements) {
final View view = child.findViewById(element);
if (view != null) {
final boolean visible = view.getGlobalVisibleRect(globalRect);
@ -132,8 +131,8 @@ public final class FlingBehavior extends AppBarLayout.Behavior {
try {
final Class<?> headerBehaviorType = this.getClass().getSuperclass().getSuperclass();
if (headerBehaviorType != null) {
final Field field
= headerBehaviorType.getDeclaredField("lastNestedScrollingChildRef");
final Field field =
headerBehaviorType.getDeclaredField("lastNestedScrollingChildRef");
field.setAccessible(true);
return field;
}

View File

@ -1,5 +1,6 @@
package org.schabi.newpipe;
import android.app.Application;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
@ -7,7 +8,6 @@ import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationChannelCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.multidex.MultiDexApplication;
import androidx.preference.PreferenceManager;
import com.jakewharton.processphoenix.ProcessPhoenix;
@ -27,9 +27,8 @@ import org.schabi.newpipe.util.StateSaver;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.net.SocketException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import io.reactivex.rxjava3.exceptions.CompositeException;
import io.reactivex.rxjava3.exceptions.MissingBackpressureException;
@ -56,7 +55,7 @@ import io.reactivex.rxjava3.plugins.RxJavaPlugins;
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
public class App extends MultiDexApplication {
public class App extends Application {
public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID;
private static final String TAG = App.class.toString();
private static App app;
@ -140,7 +139,7 @@ public class App extends MultiDexApplication {
if (throwable instanceof UndeliverableException) {
// As UndeliverableException is a wrapper,
// get the cause of it to get the "real" exception
actualThrowable = throwable.getCause();
actualThrowable = Objects.requireNonNull(throwable.getCause());
} else {
actualThrowable = throwable;
}
@ -149,7 +148,7 @@ public class App extends MultiDexApplication {
if (actualThrowable instanceof CompositeException) {
errors = ((CompositeException) actualThrowable).getExceptions();
} else {
errors = Collections.singletonList(actualThrowable);
errors = List.of(actualThrowable);
}
for (final Throwable error : errors) {
@ -213,41 +212,37 @@ public class App extends MultiDexApplication {
private void initNotificationChannels() {
// Keep the importance below DEFAULT to avoid making noise on every notification update for
// the main and update channels
final List<NotificationChannelCompat> notificationChannelCompats = new ArrayList<>();
notificationChannelCompats.add(new NotificationChannelCompat
.Builder(getString(R.string.notification_channel_id),
final List<NotificationChannelCompat> notificationChannelCompats = List.of(
new NotificationChannelCompat.Builder(getString(R.string.notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.notification_channel_name))
.setDescription(getString(R.string.notification_channel_description))
.build());
notificationChannelCompats.add(new NotificationChannelCompat
.Builder(getString(R.string.app_update_notification_channel_id),
.setName(getString(R.string.notification_channel_name))
.setDescription(getString(R.string.notification_channel_description))
.build(),
new NotificationChannelCompat
.Builder(getString(R.string.app_update_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.app_update_notification_channel_name))
.setDescription(getString(R.string.app_update_notification_channel_description))
.build());
notificationChannelCompats.add(new NotificationChannelCompat
.Builder(getString(R.string.hash_channel_id),
.setName(getString(R.string.app_update_notification_channel_name))
.setDescription(
getString(R.string.app_update_notification_channel_description))
.build(),
new NotificationChannelCompat.Builder(getString(R.string.hash_channel_id),
NotificationManagerCompat.IMPORTANCE_HIGH)
.setName(getString(R.string.hash_channel_name))
.setDescription(getString(R.string.hash_channel_description))
.build());
notificationChannelCompats.add(new NotificationChannelCompat
.Builder(getString(R.string.error_report_channel_id),
.setName(getString(R.string.hash_channel_name))
.setDescription(getString(R.string.hash_channel_description))
.build(),
new NotificationChannelCompat.Builder(getString(R.string.error_report_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.error_report_channel_name))
.setDescription(getString(R.string.error_report_channel_description))
.build());
notificationChannelCompats.add(new NotificationChannelCompat
.Builder(getString(R.string.streams_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setName(getString(R.string.streams_notification_channel_name))
.setDescription(getString(R.string.streams_notification_channel_description))
.build());
.setName(getString(R.string.error_report_channel_name))
.setDescription(getString(R.string.error_report_channel_description))
.build(),
new NotificationChannelCompat
.Builder(getString(R.string.streams_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setName(getString(R.string.streams_notification_channel_name))
.setDescription(
getString(R.string.streams_notification_channel_description))
.build()
);
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
notificationManager.createNotificationChannelsCompat(notificationChannelCompats);

View File

@ -1,7 +1,6 @@
package org.schabi.newpipe;
import android.content.Context;
import android.os.Build;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@ -12,40 +11,27 @@ import org.schabi.newpipe.extractor.downloader.Downloader;
import org.schabi.newpipe.extractor.downloader.Request;
import org.schabi.newpipe.extractor.downloader.Response;
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
import org.schabi.newpipe.util.CookieUtils;
import org.schabi.newpipe.util.InfoCache;
import org.schabi.newpipe.util.TLSSocketFactoryCompat;
import java.io.IOException;
import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.net.ssl.SSLSocketFactory;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import javax.net.ssl.X509TrustManager;
import okhttp3.CipherSuite;
import okhttp3.ConnectionSpec;
import okhttp3.OkHttpClient;
import okhttp3.RequestBody;
import okhttp3.ResponseBody;
import static org.schabi.newpipe.MainActivity.DEBUG;
public final class DownloaderImpl extends Downloader {
public static final String USER_AGENT
= "Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0";
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY
= "youtube_restricted_mode_key";
public static final String USER_AGENT =
"Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0";
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY =
"youtube_restricted_mode_key";
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000";
public static final String YOUTUBE_DOMAIN = "youtube.com";
@ -54,9 +40,6 @@ public final class DownloaderImpl extends Downloader {
private final OkHttpClient client;
private DownloaderImpl(final OkHttpClient.Builder builder) {
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) {
enableModernTLS(builder);
}
this.client = builder
.readTimeout(30, TimeUnit.SECONDS)
// .cache(new Cache(new File(context.getExternalCacheDir(), "okhttp"),
@ -81,69 +64,16 @@ public final class DownloaderImpl extends Downloader {
return instance;
}
/**
* Enable TLS 1.2 and 1.1 on Android Kitkat. This function is mostly taken
* from the documentation of OkHttpClient.Builder.sslSocketFactory(_,_).
* <p>
* If there is an error, the function will safely fall back to doing nothing
* and printing the error to the console.
* </p>
*
* @param builder The HTTPClient Builder on which TLS is enabled on (will be modified in-place)
*/
private static void enableModernTLS(final OkHttpClient.Builder builder) {
try {
// get the default TrustManager
final TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(
TrustManagerFactory.getDefaultAlgorithm());
trustManagerFactory.init((KeyStore) null);
final TrustManager[] trustManagers = trustManagerFactory.getTrustManagers();
if (trustManagers.length != 1 || !(trustManagers[0] instanceof X509TrustManager)) {
throw new IllegalStateException("Unexpected default trust managers:"
+ Arrays.toString(trustManagers));
}
final X509TrustManager trustManager = (X509TrustManager) trustManagers[0];
// insert our own TLSSocketFactory
final SSLSocketFactory sslSocketFactory = TLSSocketFactoryCompat.getInstance();
builder.sslSocketFactory(sslSocketFactory, trustManager);
// This will try to enable all modern CipherSuites(+2 more)
// that are supported on the device.
// Necessary because some servers (e.g. Framatube.org)
// don't support the old cipher suites.
// https://github.com/square/okhttp/issues/4053#issuecomment-402579554
final List<CipherSuite> cipherSuites =
new ArrayList<>(ConnectionSpec.MODERN_TLS.cipherSuites());
cipherSuites.add(CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA);
cipherSuites.add(CipherSuite.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA);
final ConnectionSpec legacyTLS = new ConnectionSpec.Builder(ConnectionSpec.MODERN_TLS)
.cipherSuites(cipherSuites.toArray(new CipherSuite[0]))
.build();
builder.connectionSpecs(Arrays.asList(legacyTLS, ConnectionSpec.CLEARTEXT));
} catch (final KeyManagementException | NoSuchAlgorithmException | KeyStoreException e) {
if (DEBUG) {
e.printStackTrace();
}
}
}
public String getCookies(final String url) {
final List<String> resultCookies = new ArrayList<>();
if (url.contains(YOUTUBE_DOMAIN)) {
final String youtubeCookie = getCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY);
if (youtubeCookie != null) {
resultCookies.add(youtubeCookie);
}
}
final String youtubeCookie = url.contains(YOUTUBE_DOMAIN)
? getCookie(YOUTUBE_RESTRICTED_MODE_COOKIE_KEY) : null;
// Recaptcha cookie is always added TODO: not sure if this is necessary
final String recaptchaCookie = getCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY);
if (recaptchaCookie != null) {
resultCookies.add(recaptchaCookie);
}
return CookieUtils.concatCookies(resultCookies);
return Stream.of(youtubeCookie, getCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY))
.filter(Objects::nonNull)
.flatMap(cookies -> Arrays.stream(cookies.split("; *")))
.distinct()
.collect(Collectors.joining("; "));
}
public String getCookie(final String key) {
@ -203,7 +133,7 @@ public final class DownloaderImpl extends Downloader {
RequestBody requestBody = null;
if (dataToSend != null) {
requestBody = RequestBody.create(null, dataToSend);
requestBody = RequestBody.create(dataToSend);
}
final okhttp3.Request.Builder requestBuilder = new okhttp3.Request.Builder()

View File

@ -3,7 +3,6 @@ package org.schabi.newpipe;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import org.schabi.newpipe.util.NavigationHelper;
@ -44,11 +43,7 @@ public class ExitActivity extends Activity {
protected void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
finishAndRemoveTask();
} else {
finish();
}
finishAndRemoveTask();
NavigationHelper.restartApp(this);
}

View File

@ -28,7 +28,6 @@ import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
@ -86,7 +85,6 @@ import org.schabi.newpipe.util.PermissionHelper;
import org.schabi.newpipe.util.SerializedCache;
import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.util.StateSaver;
import org.schabi.newpipe.util.TLSSocketFactoryCompat;
import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.views.FocusOverlayView;
@ -131,11 +129,6 @@ public class MainActivity extends AppCompatActivity {
+ "savedInstanceState = [" + savedInstanceState + "]");
}
// enable TLS1.1/1.2 for kitkat devices, to fix download and play for media.ccc.de sources
if (Build.VERSION.SDK_INT == Build.VERSION_CODES.KITKAT) {
TLSSocketFactoryCompat.setAsDefault();
}
ThemeHelper.setDayNightMode(this);
ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this));
@ -381,8 +374,7 @@ public class MainActivity extends AppCompatActivity {
private void showServices() {
for (final StreamingService s : NewPipe.getServices()) {
final String title = s.getServiceInfo().getName()
+ (ServiceHelper.isBeta(s) ? " (beta)" : "");
final String title = s.getServiceInfo().getName();
final MenuItem menuItem = drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_services_group, s.getServiceId(), ORDER, title)
@ -390,7 +382,7 @@ public class MainActivity extends AppCompatActivity {
// peertube specifics
if (s.getServiceId() == 3) {
enhancePeertubeMenu(s, menuItem);
enhancePeertubeMenu(menuItem);
}
}
drawerLayoutBinding.navigation.getMenu()
@ -398,9 +390,9 @@ public class MainActivity extends AppCompatActivity {
.setChecked(true);
}
private void enhancePeertubeMenu(final StreamingService s, final MenuItem menuItem) {
private void enhancePeertubeMenu(final MenuItem menuItem) {
final PeertubeInstance currentInstance = PeertubeHelper.getCurrentInstance();
menuItem.setTitle(currentInstance.getName() + (ServiceHelper.isBeta(s) ? " (beta)" : ""));
menuItem.setTitle(currentInstance.getName());
final Spinner spinner = InstanceSpinnerLayoutBinding.inflate(LayoutInflater.from(this))
.getRoot();
final List<PeertubeInstance> instances = PeertubeHelper.getInstanceList(this);
@ -480,8 +472,8 @@ public class MainActivity extends AppCompatActivity {
ErrorUtil.showUiErrorSnackbar(this, "Setting up service toggle", e);
}
final SharedPreferences sharedPreferences
= PreferenceManager.getDefaultSharedPreferences(this);
final SharedPreferences sharedPreferences =
PreferenceManager.getDefaultSharedPreferences(this);
if (sharedPreferences.getBoolean(Constants.KEY_THEME_CHANGE, false)) {
if (DEBUG) {
Log.d(TAG, "Theme has changed, recreating activity...");
@ -653,8 +645,8 @@ public class MainActivity extends AppCompatActivity {
}
super.onCreateOptionsMenu(menu);
final Fragment fragment
= getSupportFragmentManager().findFragmentById(R.id.fragment_holder);
final Fragment fragment =
getSupportFragmentManager().findFragmentById(R.id.fragment_holder);
if (!(fragment instanceof SearchFragment)) {
toolbarLayoutBinding.toolbarSearchContainer.getRoot().setVisibility(View.GONE);
}

View File

@ -3,7 +3,6 @@ package org.schabi.newpipe;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
/*
@ -40,10 +39,6 @@ public class PanicResponderActivity extends Activity {
ExitActivity.exitAndRemoveFromRecentApps(this);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
finishAndRemoveTask();
} else {
finish();
}
finishAndRemoveTask();
}
}

View File

@ -16,7 +16,7 @@ import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.SparseItemUtil;
import java.util.Collections;
import java.util.List;
public final class QueueItemMenuUtil {
private QueueItemMenuUtil() {
@ -53,7 +53,7 @@ public final class QueueItemMenuUtil {
case R.id.menu_item_append_playlist:
PlaylistDialog.createCorrespondingDialog(
context,
Collections.singletonList(new StreamEntity(item)),
List.of(new StreamEntity(item)),
dialog -> dialog.show(
fragmentManager,
"QueueItemMenuUtil@append_playlist"

View File

@ -30,6 +30,7 @@ import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.app.NotificationCompat;
import androidx.core.app.ServiceCompat;
import androidx.core.math.MathUtils;
import androidx.fragment.app.FragmentManager;
import androidx.preference.PreferenceManager;
@ -60,7 +61,7 @@ import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.player.MainPlayer;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.helper.PlayerHelper;
import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
@ -81,7 +82,6 @@ import org.schabi.newpipe.views.FocusOverlayView;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import icepick.Icepick;
@ -452,7 +452,7 @@ public class RouterActivity extends AppCompatActivity {
}
}
selectedRadioPosition = Math.min(Math.max(-1, selectedRadioPosition), choices.size() - 1);
selectedRadioPosition = MathUtils.clamp(selectedRadioPosition, -1, choices.size() - 1);
if (selectedRadioPosition != -1) {
((RadioButton) radioGroup.getChildAt(selectedRadioPosition)).setChecked(true);
}
@ -630,8 +630,8 @@ public class RouterActivity extends AppCompatActivity {
}
// ...the player is not running or in normal Video-mode/type
final MainPlayer.PlayerType playerType = PlayerHolder.getInstance().getType();
return playerType == null || playerType == MainPlayer.PlayerType.VIDEO;
final PlayerType playerType = PlayerHolder.getInstance().getType();
return playerType == null || playerType == PlayerType.MAIN;
}
private void openAddToPlaylistDialog() {
@ -649,7 +649,7 @@ public class RouterActivity extends AppCompatActivity {
.subscribe(
info -> PlaylistDialog.createCorrespondingDialog(
getThemeWrapperContext(),
Collections.singletonList(new StreamEntity(info)),
List.of(new StreamEntity(info)),
playlistDialog -> {
playlistDialog.setOnDismissListener(dialog -> finish());

View File

@ -8,7 +8,6 @@ import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.schabi.newpipe.R
import org.schabi.newpipe.about.LicenseFragmentHelper.showLicense
import org.schabi.newpipe.databinding.FragmentLicensesBinding
import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding

View File

@ -12,126 +12,92 @@ import org.schabi.newpipe.R
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.ThemeHelper
import org.schabi.newpipe.util.external_communication.ShareUtils
import java.io.BufferedReader
import java.io.IOException
import java.io.InputStreamReader
import java.nio.charset.StandardCharsets
object LicenseFragmentHelper {
/**
* @param context the context to use
* @param license the license
* @return String which contains a HTML formatted license page
* styled according to the context's theme
*/
private fun getFormattedLicense(context: Context, license: License): String {
val licenseContent = StringBuilder()
val webViewData: String
try {
BufferedReader(
InputStreamReader(
context.assets.open(license.filename),
StandardCharsets.UTF_8
)
).use { `in` ->
var str: String?
while (`in`.readLine().also { str = it } != null) {
licenseContent.append(str)
}
/**
* @param context the context to use
* @param license the license
* @return String which contains a HTML formatted license page
* styled according to the context's theme
*/
private fun getFormattedLicense(context: Context, license: License): String {
try {
return context.assets.open(license.filename).bufferedReader().use { it.readText() }
// split the HTML file and insert the stylesheet into the HEAD of the file
.replace("</head>", "<style>${getLicenseStylesheet(context)}</style></head>")
} catch (e: IOException) {
throw IllegalArgumentException("Could not get license file: ${license.filename}", e)
}
}
// split the HTML file and insert the stylesheet into the HEAD of the file
webViewData = "$licenseContent".replace(
"</head>",
"<style>" + getLicenseStylesheet(context) + "</style></head>"
)
}
} catch (e: IOException) {
throw IllegalArgumentException(
"Could not get license file: " + license.filename, e
)
/**
* @param context the Android context
* @return String which is a CSS stylesheet according to the context's theme
*/
private fun getLicenseStylesheet(context: Context): String {
val isLightTheme = ThemeHelper.isLightThemeSelected(context)
val licenseBackgroundColor = getHexRGBColor(
context, if (isLightTheme) R.color.light_license_background_color else R.color.dark_license_background_color
)
val licenseTextColor = getHexRGBColor(
context, if (isLightTheme) R.color.light_license_text_color else R.color.dark_license_text_color
)
val youtubePrimaryColor = getHexRGBColor(
context, if (isLightTheme) R.color.light_youtube_primary_color else R.color.dark_youtube_primary_color
)
return "body{padding:12px 15px;margin:0;background:#$licenseBackgroundColor;color:#$licenseTextColor}" +
"a[href]{color:#$youtubePrimaryColor}pre{white-space:pre-wrap}"
}
/**
* Cast R.color to a hexadecimal color value.
*
* @param context the context to use
* @param color the color number from R.color
* @return a six characters long String with hexadecimal RGB values
*/
private fun getHexRGBColor(context: Context, color: Int): String {
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()
}
return webViewData
}
/**
* @param context the Android context
* @return String which is a CSS stylesheet according to the context's theme
*/
private fun getLicenseStylesheet(context: Context): String {
val isLightTheme = ThemeHelper.isLightThemeSelected(context)
return (
"body{padding:12px 15px;margin:0;" + "background:#" + getHexRGBColor(
context,
if (isLightTheme) R.color.light_license_background_color
else R.color.dark_license_background_color
) + ";" + "color:#" + getHexRGBColor(
context,
if (isLightTheme) R.color.light_license_text_color
else R.color.dark_license_text_color
) + "}" + "a[href]{color:#" + getHexRGBColor(
context,
if (isLightTheme) R.color.light_youtube_primary_color
else R.color.dark_youtube_primary_color
) + "}" + "pre{white-space:pre-wrap}"
)
}
/**
* Cast R.color to a hexadecimal color value.
*
* @param context the context to use
* @param color the color number from R.color
* @return a six characters long String with hexadecimal RGB values
*/
private fun getHexRGBColor(context: Context, color: Int): String {
return context.getString(color).substring(3)
}
fun showLicense(context: Context?, license: License): Disposable {
return showLicense(context, license) { alertDialog ->
alertDialog.setPositiveButton(R.string.ok) { dialog, _ ->
dialog.dismiss()
}
}
}
fun showLicense(context: Context?, component: SoftwareComponent): Disposable {
return showLicense(context, component.license) { alertDialog ->
alertDialog.setPositiveButton(R.string.dismiss) { dialog, _ ->
dialog.dismiss()
}
alertDialog.setNeutralButton(R.string.open_website_license) { _, _ ->
ShareUtils.openUrlInBrowser(context!!, component.link)
}
}
}
private fun showLicense(
context: Context?,
license: License,
block: (AlertDialog.Builder) -> Unit
): Disposable {
return if (context == null) {
Disposable.empty()
} else {
Observable.fromCallable { getFormattedLicense(context, license) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { formattedLicense ->
val webViewData = Base64.encodeToString(
formattedLicense.toByteArray(StandardCharsets.UTF_8), Base64.NO_PADDING
)
val webView = WebView(context)
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
AlertDialog.Builder(context).apply {
setTitle(license.name)
setView(webView)
Localization.assureCorrectAppLanguage(context)
block(this)
show()
}
}
setNeutralButton(R.string.open_website_license) { _, _ ->
ShareUtils.openUrlInBrowser(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

@ -3,7 +3,6 @@ package org.schabi.newpipe.database;
import androidx.room.Dao;
import androidx.room.Delete;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Update;
import java.util.Collection;
@ -14,13 +13,10 @@ import io.reactivex.rxjava3.core.Flowable;
@Dao
public interface BasicDAO<Entity> {
/* Inserts */
@Insert(onConflict = OnConflictStrategy.ABORT)
@Insert
long insert(Entity entity);
@Insert(onConflict = OnConflictStrategy.ABORT)
List<Long> insertAll(Entity... entities);
@Insert(onConflict = OnConflictStrategy.ABORT)
@Insert
List<Long> insertAll(Collection<Entity> entities);
/* Searches */
@ -32,9 +28,6 @@ public interface BasicDAO<Entity> {
@Delete
void delete(Entity entity);
@Delete
int delete(Collection<Entity> entities);
int deleteAll();
/* Updates */

View File

@ -9,6 +9,7 @@ import androidx.room.Update
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe
import org.schabi.newpipe.database.feed.model.FeedEntity
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
import org.schabi.newpipe.database.stream.StreamWithState
import org.schabi.newpipe.database.stream.model.StreamStateEntity
@ -21,56 +22,16 @@ abstract class FeedDAO {
@Query("DELETE FROM feed")
abstract fun deleteAll(): Int
@Query(
"""
SELECT s.*, sst.progress_time
FROM streams s
LEFT JOIN stream_state sst
ON s.uid = sst.stream_id
LEFT JOIN stream_history sh
ON s.uid = sh.stream_id
INNER JOIN feed f
ON s.uid = f.stream_id
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
LIMIT 500
"""
)
abstract fun getAllStreams(): Maybe<List<StreamWithState>>
@Query(
"""
SELECT s.*, sst.progress_time
FROM streams s
LEFT JOIN stream_state sst
ON s.uid = sst.stream_id
LEFT JOIN stream_history sh
ON s.uid = sh.stream_id
INNER JOIN feed f
ON s.uid = f.stream_id
INNER JOIN feed_group_subscription_join fgs
ON fgs.subscription_id = f.subscription_id
WHERE fgs.group_id = :groupId
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
LIMIT 500
"""
)
abstract fun getAllStreamsForGroup(groupId: Long): Maybe<List<StreamWithState>>
/**
* @param groupId the group id to get feed streams of; use
* [FeedGroupEntity.GROUP_ALL_ID] to not filter by group
* @param includePlayed if false, only return all of the live, never-played or non-finished
* feed streams (see `@see` items); if true no filter is applied
* @param uploadDateBefore get only streams uploaded before this date (useful to filter out
* future streams); use null to not filter by upload date
* @return the feed streams filtered according to the conditions provided in the parameters
* @see StreamStateEntity.isFinished()
* @see StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS
* @return all of the non-live, never-played and non-finished streams in the feed
* (all of the cited conditions must hold for a stream to be in the returned list)
*/
@Query(
"""
@ -79,67 +40,44 @@ abstract class FeedDAO {
LEFT JOIN stream_state sst
ON s.uid = sst.stream_id
LEFT JOIN stream_history sh
ON s.uid = sh.stream_id
ON s.uid = sh.stream_id
INNER JOIN feed f
ON s.uid = f.stream_id
LEFT JOIN feed_group_subscription_join fgs
ON fgs.subscription_id = f.subscription_id
WHERE (
sh.stream_id IS NULL
OR sst.stream_id IS NULL
OR sst.progress_time < s.duration * 1000 - ${StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS}
OR sst.progress_time < s.duration * 1000 * 3 / 4
OR s.stream_type = 'LIVE_STREAM'
OR s.stream_type = 'AUDIO_LIVE_STREAM'
:groupId = ${FeedGroupEntity.GROUP_ALL_ID}
OR fgs.group_id = :groupId
)
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
LIMIT 500
"""
)
abstract fun getLiveOrNotPlayedStreams(): Maybe<List<StreamWithState>>
/**
* @see StreamStateEntity.isFinished()
* @see StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS
* @param groupId the group id to get streams of
* @return all of the non-live, never-played and non-finished streams for the given feed group
* (all of the cited conditions must hold for a stream to be in the returned list)
*/
@Query(
"""
SELECT s.*, sst.progress_time
FROM streams s
LEFT JOIN stream_state sst
ON s.uid = sst.stream_id
LEFT JOIN stream_history sh
ON s.uid = sh.stream_id
INNER JOIN feed f
ON s.uid = f.stream_id
INNER JOIN feed_group_subscription_join fgs
ON fgs.subscription_id = f.subscription_id
WHERE fgs.group_id = :groupId
AND (
sh.stream_id IS NULL
:includePlayed
OR sh.stream_id IS NULL
OR sst.stream_id IS NULL
OR sst.progress_time < s.duration * 1000 - ${StreamStateEntity.PLAYBACK_FINISHED_END_MILLISECONDS}
OR sst.progress_time < s.duration * 1000 * 3 / 4
OR s.stream_type = 'LIVE_STREAM'
OR s.stream_type = 'AUDIO_LIVE_STREAM'
)
AND (
:uploadDateBefore IS NULL
OR s.upload_date IS NULL
OR s.upload_date < :uploadDateBefore
)
ORDER BY s.upload_date IS NULL DESC, s.upload_date DESC, s.uploader ASC
LIMIT 500
"""
)
abstract fun getLiveOrNotPlayedStreamsForGroup(groupId: Long): Maybe<List<StreamWithState>>
abstract fun getStreams(
groupId: Long,
includePlayed: Boolean,
uploadDateBefore: OffsetDateTime?
): Maybe<List<StreamWithState>>
@Query(
"""

View File

@ -3,10 +3,10 @@ package org.schabi.newpipe.database.playlist;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public interface PlaylistLocalItem extends LocalItem {
String getOrderingName();
@ -14,14 +14,9 @@ public interface PlaylistLocalItem extends LocalItem {
static List<PlaylistLocalItem> merge(
final List<PlaylistMetadataEntry> localPlaylists,
final List<PlaylistRemoteEntity> remotePlaylists) {
final List<PlaylistLocalItem> items = new ArrayList<>(
localPlaylists.size() + remotePlaylists.size());
items.addAll(localPlaylists);
items.addAll(remotePlaylists);
Collections.sort(items, Comparator.comparing(PlaylistLocalItem::getOrderingName,
Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)));
return items;
return Stream.concat(localPlaylists.stream(), remotePlaylists.stream())
.sorted(Comparator.comparing(PlaylistLocalItem::getOrderingName,
Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)))
.collect(Collectors.toList());
}
}

View File

@ -1,5 +1,9 @@
package org.schabi.newpipe.download;
import static org.schabi.newpipe.extractor.stream.DeliveryMethod.PROGRESSIVE_HTTP;
import static org.schabi.newpipe.util.ListHelper.getStreamsOfSpecifiedDelivery;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.app.Activity;
import android.content.ComponentName;
import android.content.Context;
@ -82,10 +86,6 @@ import us.shandian.giga.service.DownloadManagerService;
import us.shandian.giga.service.DownloadManagerService.DownloadManagerBinder;
import us.shandian.giga.service.MissionState;
import static org.schabi.newpipe.extractor.stream.DeliveryMethod.PROGRESSIVE_HTTP;
import static org.schabi.newpipe.util.ListHelper.getStreamsOfSpecifiedDelivery;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
public class DownloadDialog extends DialogFragment
implements RadioGroup.OnCheckedChangeListener, AdapterView.OnItemSelectedListener {
private static final String TAG = "DialogFragment";
@ -205,8 +205,8 @@ public class DownloadDialog extends DialogFragment
setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(context));
Icepick.restoreInstanceState(this, savedInstanceState);
final SparseArray<SecondaryStreamHelper<AudioStream>> secondaryStreams
= new SparseArray<>(4);
final SparseArray<SecondaryStreamHelper<AudioStream>> secondaryStreams =
new SparseArray<>(4);
final List<VideoStream> videoStreams = wrappedVideoStreams.getStreamsList();
for (int i = 0; i < videoStreams.size(); i++) {

View File

@ -31,6 +31,7 @@ import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.stream.Collectors;
/*
* Created by Christian Schabesberger on 24.10.15.
@ -65,11 +66,11 @@ public class ErrorActivity extends AppCompatActivity {
public static final String ERROR_EMAIL_ADDRESS = "crashreport@newpipe.schabi.org";
public static final String ERROR_EMAIL_SUBJECT = "Exception in ";
public static final String ERROR_GITHUB_ISSUE_URL
= "https://github.com/TeamNewPipe/NewPipe/issues";
public static final String ERROR_GITHUB_ISSUE_URL =
"https://github.com/TeamNewPipe/NewPipe/issues";
public static final DateTimeFormatter CURRENT_TIMESTAMP_FORMATTER
= DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
public static final DateTimeFormatter CURRENT_TIMESTAMP_FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private ErrorInfo errorInfo;
@ -182,14 +183,9 @@ public class ErrorActivity extends AppCompatActivity {
}
private String formErrorText(final String[] el) {
final StringBuilder text = new StringBuilder();
if (el != null) {
for (final String e : el) {
text.append("-------------------------------------\n").append(e);
}
}
text.append("-------------------------------------");
return text.toString();
final String separator = "-------------------------------------";
return Arrays.stream(el)
.collect(Collectors.joining(separator + "\n", separator + "\n", separator));
}
/**

View File

@ -14,8 +14,6 @@ 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.util.ServiceHelper
import java.io.PrintWriter
import java.io.StringWriter
@Parcelize
class ErrorInfo(
@ -80,19 +78,10 @@ class ErrorInfo(
companion object {
const val SERVICE_NONE = "none"
private fun getStackTrace(throwable: Throwable): String {
StringWriter().use { stringWriter ->
PrintWriter(stringWriter, true).use { printWriter ->
throwable.printStackTrace(printWriter)
return stringWriter.buffer.toString()
}
}
}
fun throwableToStringList(throwable: Throwable) = arrayOf(throwable.stackTraceToString())
fun throwableToStringList(throwable: Throwable) = arrayOf(getStackTrace(throwable))
fun throwableListToStringList(throwable: List<Throwable>) =
Array(throwable.size) { i -> getStackTrace(throwable[i]) }
fun throwableListToStringList(throwableList: List<Throwable>) =
throwableList.map { it.stackTraceToString() }.toTypedArray()
private fun getInfoServiceName(info: Info?) =
if (info == null) SERVICE_NONE else ServiceHelper.getNameOfServiceById(info.serviceId)

View File

@ -114,13 +114,7 @@ class ErrorUtil {
context,
context.getString(R.string.error_report_channel_id)
)
.setSmallIcon(
// the vector drawable icon causes crashes on KitKat devices
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
R.drawable.ic_bug_report
else
android.R.drawable.stat_notify_error
)
.setSmallIcon(R.drawable.ic_bug_report)
.setContentTitle(context.getString(R.string.error_report_notification_title))
.setContentText(context.getString(errorInfo.messageStringId))
.setAutoCancel(true)

View File

@ -3,14 +3,15 @@ package org.schabi.newpipe.error;
import android.annotation.SuppressLint;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Build;
import android.os.Bundle;
import android.util.Log;
import android.view.Menu;
import android.view.MenuItem;
import android.webkit.CookieManager;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
import android.webkit.WebViewClient;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@ -18,7 +19,6 @@ import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.NavUtils;
import androidx.preference.PreferenceManager;
import androidx.webkit.WebViewClientCompat;
import org.schabi.newpipe.databinding.ActivityRecaptchaBinding;
import org.schabi.newpipe.DownloaderImpl;
@ -86,14 +86,15 @@ public class ReCaptchaActivity extends AppCompatActivity {
webSettings.setJavaScriptEnabled(true);
webSettings.setUserAgentString(DownloaderImpl.USER_AGENT);
recaptchaBinding.reCaptchaWebView.setWebViewClient(new WebViewClientCompat() {
recaptchaBinding.reCaptchaWebView.setWebViewClient(new WebViewClient() {
@Override
public boolean shouldOverrideUrlLoading(final WebView view, final String url) {
public boolean shouldOverrideUrlLoading(final WebView view,
final WebResourceRequest request) {
if (MainActivity.DEBUG) {
Log.d(TAG, "shouldOverrideUrlLoading: url=" + url);
Log.d(TAG, "shouldOverrideUrlLoading: url=" + request.getUrl().toString());
}
handleCookiesFromUrl(url);
handleCookiesFromUrl(request.getUrl().toString());
return false;
}
@ -107,12 +108,7 @@ public class ReCaptchaActivity extends AppCompatActivity {
// cleaning cache, history and cookies from webView
recaptchaBinding.reCaptchaWebView.clearCache(true);
recaptchaBinding.reCaptchaWebView.clearHistory();
final CookieManager cookieManager = CookieManager.getInstance();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
cookieManager.removeAllCookies(value -> { });
} else {
cookieManager.removeAllCookie();
}
CookieManager.getInstance().removeAllCookies(null);
recaptchaBinding.reCaptchaWebView.loadUrl(url);
}

View File

@ -1,5 +1,9 @@
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.utils.Utils.isBlank;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
@ -26,17 +30,9 @@ import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.external_communication.TextLinkifier;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import icepick.State;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import static android.text.TextUtils.isEmpty;
import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
public class DescriptionFragment extends BaseFragment {
@State
@ -185,8 +181,8 @@ public class DescriptionFragment extends BaseFragment {
return;
}
final ItemMetadataBinding itemBinding
= ItemMetadataBinding.inflate(inflater, layout, false);
final ItemMetadataBinding itemBinding =
ItemMetadataBinding.inflate(inflater, layout, false);
itemBinding.metadataTypeView.setText(type);
itemBinding.metadataTypeView.setOnLongClickListener(v -> {
@ -206,19 +202,16 @@ public class DescriptionFragment extends BaseFragment {
private void addTagsMetadataItem(final LayoutInflater inflater, final LinearLayout layout) {
if (streamInfo.getTags() != null && !streamInfo.getTags().isEmpty()) {
final ItemMetadataTagsBinding itemBinding
= ItemMetadataTagsBinding.inflate(inflater, layout, false);
final var itemBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false);
final List<String> tags = new ArrayList<>(streamInfo.getTags());
Collections.sort(tags);
for (final String tag : tags) {
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());
}

View File

@ -1,5 +1,16 @@
package org.schabi.newpipe.fragments.detail;
import static android.text.TextUtils.isEmpty;
import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.COMMENTS;
import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateRotation;
import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientationLocked;
import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfirmationRequired;
import static org.schabi.newpipe.player.playqueue.PlayQueueItem.RECOVERY_UNSET;
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams;
import android.animation.ValueAnimator;
import android.app.Activity;
import android.content.BroadcastReceiver;
@ -10,7 +21,6 @@ import android.content.SharedPreferences;
import android.content.pm.ActivityInfo;
import android.database.ContentObserver;
import android.graphics.Color;
import android.graphics.Point;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.net.Uri;
@ -77,9 +87,9 @@ import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment;
import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.player.MainPlayer;
import org.schabi.newpipe.player.MainPlayer.PlayerType;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.event.OnKeyDownListener;
import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener;
import org.schabi.newpipe.player.helper.PlayerHelper;
@ -87,6 +97,8 @@ import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.player.ui.MainPlayerUi;
import org.schabi.newpipe.player.ui.VideoPlayerUi;
import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ExtractorHelper;
@ -101,11 +113,11 @@ import org.schabi.newpipe.util.external_communication.KoreUtils;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Iterator;
import java.util.LinkedList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
import icepick.State;
@ -114,17 +126,6 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
import static android.text.TextUtils.isEmpty;
import static org.schabi.newpipe.extractor.StreamingService.ServiceInfo.MediaCapability.COMMENTS;
import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateRotation;
import static org.schabi.newpipe.player.helper.PlayerHelper.globalScreenOrientationLocked;
import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfirmationRequired;
import static org.schabi.newpipe.player.playqueue.PlayQueueItem.RECOVERY_UNSET;
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams;
public final class VideoDetailFragment
extends BaseStateFragment<StreamInfo>
implements BackPressable,
@ -179,6 +180,8 @@ public final class VideoDetailFragment
@State
int bottomSheetState = BottomSheetBehavior.STATE_EXPANDED;
@State
int lastStableBottomSheetState = BottomSheetBehavior.STATE_EXPANDED;
@State
protected boolean autoPlayEnabled = true;
@Nullable
@ -190,6 +193,7 @@ public final class VideoDetailFragment
private Disposable positionSubscriber = null;
private BottomSheetBehavior<FrameLayout> bottomSheetBehavior;
private BottomSheetBehavior.BottomSheetCallback bottomSheetCallback;
private BroadcastReceiver broadcastReceiver;
/*//////////////////////////////////////////////////////////////////////////
@ -202,7 +206,7 @@ public final class VideoDetailFragment
private ContentObserver settingsContentObserver;
@Nullable
private MainPlayer playerService;
private PlayerService playerService;
private Player player;
private final PlayerHolder playerHolder = PlayerHolder.getInstance();
@ -211,7 +215,7 @@ public final class VideoDetailFragment
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onServiceConnected(final Player connectedPlayer,
final MainPlayer connectedPlayerService,
final PlayerService connectedPlayerService,
final boolean playAfterConnect) {
player = connectedPlayer;
playerService = connectedPlayerService;
@ -219,6 +223,7 @@ public final class VideoDetailFragment
// It will do nothing if the player is not in fullscreen mode
hideSystemUiIfNeeded();
final Optional<MainPlayerUi> playerUi = player.UIs().get(MainPlayerUi.class);
if (!player.videoPlayerSelected() && !playAfterConnect) {
return;
}
@ -227,22 +232,19 @@ public final class VideoDetailFragment
// If the video is playing but orientation changed
// let's make the video in fullscreen again
checkLandscape();
} else if (player.isFullscreen() && !player.isVerticalVideo()
} else if (playerUi.map(ui -> ui.isFullscreen() && !ui.isVerticalVideo()).orElse(false)
// Tablet UI has orientation-independent fullscreen
&& !DeviceUtils.isTablet(activity)) {
// Device is in portrait orientation after rotation but UI is in fullscreen.
// Return back to non-fullscreen state
player.toggleFullscreen();
}
if (playerIsNotStopped() && player.videoPlayerSelected()) {
addVideoPlayerView();
playerUi.ifPresent(MainPlayerUi::toggleFullscreen);
}
//noinspection SimplifyOptionalCallChains
if (playAfterConnect
|| (currentInfo != null
&& isAutoplayEnabled()
&& player.getParentActivity() == null)) {
&& !playerUi.isPresent())) {
autoPlayEnabled = true; // forcefully start playing
openVideoPlayerAutoFullscreen();
}
@ -269,7 +271,7 @@ public final class VideoDetailFragment
public static VideoDetailFragment getInstanceInCollapsedState() {
final VideoDetailFragment instance = new VideoDetailFragment();
instance.bottomSheetState = BottomSheetBehavior.STATE_COLLAPSED;
instance.updateBottomSheetState(BottomSheetBehavior.STATE_COLLAPSED);
return instance;
}
@ -329,6 +331,9 @@ public final class VideoDetailFragment
@Override
public void onResume() {
super.onResume();
if (DEBUG) {
Log.d(TAG, "onResume() called");
}
activity.sendBroadcast(new Intent(ACTION_VIDEO_FRAGMENT_RESUMED));
@ -383,7 +388,7 @@ public final class VideoDetailFragment
disposables.clear();
positionSubscriber = null;
currentWorker = null;
bottomSheetBehavior.setBottomSheetCallback(null);
bottomSheetBehavior.removeBottomSheetCallback(bottomSheetCallback);
if (activity.isFinishing()) {
playQueue = null;
@ -449,7 +454,7 @@ public final class VideoDetailFragment
disposables.add(
PlaylistDialog.createCorrespondingDialog(
getContext(),
Collections.singletonList(new StreamEntity(currentInfo)),
List.of(new StreamEntity(currentInfo)),
dialog -> dialog.show(getFM(), TAG)
)
);
@ -500,12 +505,18 @@ public final class VideoDetailFragment
}
break;
case R.id.detail_thumbnail_root_layout:
autoPlayEnabled = true; // forcefully start playing
// FIXME Workaround #7427
if (isPlayerAvailable()) {
player.setRecovery();
// make sure not to open any player if there is nothing currently loaded!
// FIXME removing this `if` causes the player service to start correctly, then stop,
// then restart badly without calling `startForeground()`, causing a crash when
// later closing the detail fragment
if (currentInfo != null) {
autoPlayEnabled = true; // forcefully start playing
// FIXME Workaround #7427
if (isPlayerAvailable()) {
player.setRecovery();
}
openVideoPlayerAutoFullscreen();
}
openVideoPlayerAutoFullscreen();
break;
case R.id.detail_title_root_layout:
toggleTitleAndSecondaryControls();
@ -518,7 +529,7 @@ public final class VideoDetailFragment
case R.id.overlay_play_pause_button:
if (playerIsNotStopped()) {
player.playPause();
player.hideControls(0, 0);
player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0));
showSystemUi();
} else {
autoPlayEnabled = true; // forcefully start playing
@ -583,12 +594,12 @@ public final class VideoDetailFragment
if (binding.detailSecondaryControlPanel.getVisibility() == View.GONE) {
binding.detailVideoTitleView.setMaxLines(10);
animateRotation(binding.detailToggleSecondaryControlsView,
Player.DEFAULT_CONTROLS_DURATION, 180);
VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 180);
binding.detailSecondaryControlPanel.setVisibility(View.VISIBLE);
} else {
binding.detailVideoTitleView.setMaxLines(1);
animateRotation(binding.detailToggleSecondaryControlsView,
Player.DEFAULT_CONTROLS_DURATION, 0);
VideoPlayerUi.DEFAULT_CONTROLS_DURATION, 0);
binding.detailSecondaryControlPanel.setVisibility(View.GONE);
}
// view pager height has changed, update the tab layout
@ -714,7 +725,7 @@ public final class VideoDetailFragment
}
private void initThumbnailViews(@NonNull final StreamInfo info) {
PicassoHelper.loadThumbnail(info.getThumbnailUrl()).tag(PICASSO_VIDEO_DETAILS_TAG)
PicassoHelper.loadDetailsThumbnail(info.getThumbnailUrl()).tag(PICASSO_VIDEO_DETAILS_TAG)
.into(binding.detailThumbnailImageView, new Callback() {
@Override
public void onSuccess() {
@ -746,7 +757,9 @@ public final class VideoDetailFragment
@Override
public boolean onKeyDown(final int keyCode) {
return isPlayerAvailable() && player.onKeyDown(keyCode);
return isPlayerAvailable()
&& player.UIs().get(VideoPlayerUi.class)
.map(playerUi -> playerUi.onKeyDown(keyCode)).orElse(false);
}
@Override
@ -756,7 +769,7 @@ public final class VideoDetailFragment
}
// If we are in fullscreen mode just exit from it via first back press
if (isPlayerAvailable() && player.isFullscreen()) {
if (isFullscreen()) {
if (!DeviceUtils.isTablet(activity)) {
player.pause();
}
@ -1006,8 +1019,7 @@ public final class VideoDetailFragment
getChildFragmentManager().beginTransaction()
.replace(R.id.relatedItemsLayout, RelatedItemsFragment.getInstance(info))
.commitAllowingStateLoss();
binding.relatedItemsLayout.setVisibility(
isPlayerAvailable() && player.isFullscreen() ? View.GONE : View.VISIBLE);
binding.relatedItemsLayout.setVisibility(isFullscreen() ? View.GONE : View.VISIBLE);
}
}
@ -1047,15 +1059,13 @@ public final class VideoDetailFragment
// call `post()` to be sure `viewPager.getHitRect()`
// is up to date and not being currently recomputed
binding.tabLayout.post(() -> {
if (getContext() != null) {
final var activity = getActivity();
if (activity != null) {
final Rect pagerHitRect = new Rect();
binding.viewPager.getHitRect(pagerHitRect);
final Point displaySize = new Point();
Objects.requireNonNull(ContextCompat.getSystemService(getContext(),
WindowManager.class)).getDefaultDisplay().getSize(displaySize);
final int viewPagerVisibleHeight = displaySize.y - pagerHitRect.top;
final int height = DeviceUtils.getWindowHeight(activity.getWindowManager());
final int viewPagerVisibleHeight = height - pagerHitRect.top;
// see TabLayout.DEFAULT_HEIGHT, which is equal to 48dp
final float tabLayoutHeight = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, 48, getResources().getDisplayMetrics());
@ -1087,8 +1097,12 @@ public final class VideoDetailFragment
private void toggleFullscreenIfInFullscreenMode() {
// If a user watched video inside fullscreen mode and than chose another player
// return to non-fullscreen mode
if (isPlayerAvailable() && player.isFullscreen()) {
player.toggleFullscreen();
if (isPlayerAvailable()) {
player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> {
if (playerUi.isFullscreen()) {
playerUi.toggleFullscreen();
}
});
}
}
@ -1164,7 +1178,7 @@ public final class VideoDetailFragment
// doesn't tell which state it was settling to, and thus the bottom sheet settles to
// STATE_COLLAPSED. This can be solved by manually setting the state that will be
// restored (i.e. bottomSheetState) to STATE_EXPANDED.
bottomSheetState = BottomSheetBehavior.STATE_EXPANDED;
updateBottomSheetState(BottomSheetBehavior.STATE_EXPANDED);
// toggle landscape in order to open directly in fullscreen
onScreenRotationButtonClicked();
}
@ -1214,16 +1228,10 @@ public final class VideoDetailFragment
}
final PlayQueue queue = setupPlayQueueForIntent(false);
// Video view can have elements visible from popup,
// We hide it here but once it ready the view will be shown in handleIntent()
if (playerService.getView() != null) {
playerService.getView().setVisibility(View.GONE);
}
addVideoPlayerView();
tryAddVideoPlayerView();
final Intent playerIntent = NavigationHelper.getPlayerIntent(requireContext(),
MainPlayer.class, queue, true, autoPlayEnabled);
PlayerService.class, queue, true, autoPlayEnabled);
ContextCompat.startForegroundService(activity, playerIntent);
}
@ -1235,8 +1243,8 @@ public final class VideoDetailFragment
* be reused in a few milliseconds and the flickering would be annoying.
*/
private void hideMainPlayerOnLoadingNewStream() {
if (!isPlayerServiceAvailable()
|| playerService.getView() == null
//noinspection SimplifyOptionalCallChains
if (!isPlayerServiceAvailable() || !getRoot().isPresent()
|| !player.videoPlayerSelected()) {
return;
}
@ -1244,7 +1252,7 @@ public final class VideoDetailFragment
removeVideoPlayerView();
if (isAutoplayEnabled()) {
playerService.stopForImmediateReusing();
playerService.getView().setVisibility(View.GONE);
getRoot().ifPresent(view -> view.setVisibility(View.GONE));
} else {
playerHolder.stopService();
}
@ -1301,27 +1309,41 @@ public final class VideoDetailFragment
&& PlayerHelper.isAutoplayAllowedByUser(requireContext());
}
private void addVideoPlayerView() {
if (!isPlayerAvailable() || getView() == null) {
return;
private void tryAddVideoPlayerView() {
if (isPlayerAvailable() && getView() != null) {
// Setup the surface view height, so that it fits the video correctly; this is done also
// here, and not only in the Handler, to avoid a choppy fullscreen rotation animation.
setHeightThumbnail();
}
// Check if viewHolder already contains a child
if (player.getRootView().getParent() != binding.playerPlaceholder) {
playerService.removeViewFromParent();
}
setHeightThumbnail();
// do all the null checks in the posted lambda, too, since the player, the binding and the
// view could be set or unset before the lambda gets executed on the next main thread cycle
new Handler(Looper.getMainLooper()).post(() -> {
if (!isPlayerAvailable() || getView() == null) {
return;
}
// Prevent from re-adding a view multiple times
if (player.getRootView().getParent() == null) {
binding.playerPlaceholder.addView(player.getRootView());
}
// setup the surface view height, so that it fits the video correctly
setHeightThumbnail();
player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> {
// sometimes binding would be null here, even though getView() != null above u.u
if (binding != null) {
// prevent from re-adding a view multiple times
playerUi.removeViewFromParent();
binding.playerPlaceholder.addView(playerUi.getBinding().getRoot());
playerUi.setupVideoSurfaceIfNeeded();
}
});
});
}
private void removeVideoPlayerView() {
makeDefaultHeightForVideoPlaceholder();
playerService.removeViewFromParent();
if (player != null) {
player.UIs().get(VideoPlayerUi.class).ifPresent(VideoPlayerUi::removeViewFromParent);
}
}
private void makeDefaultHeightForVideoPlaceholder() {
@ -1362,7 +1384,7 @@ public final class VideoDetailFragment
final boolean isPortrait = metrics.heightPixels > metrics.widthPixels;
requireView().getViewTreeObserver().removeOnPreDrawListener(preDrawListener);
if (isPlayerAvailable() && player.isFullscreen()) {
if (isFullscreen()) {
final int height = (DeviceUtils.isInMultiWindow(activity)
? requireView()
: activity.getWindow().getDecorView()).getHeight();
@ -1387,8 +1409,9 @@ public final class VideoDetailFragment
binding.detailThumbnailImageView.setMinimumHeight(newHeight);
if (isPlayerAvailable()) {
final int maxHeight = (int) (metrics.heightPixels * MAX_PLAYER_HEIGHT);
player.getSurfaceView()
.setHeights(newHeight, player.isFullscreen() ? newHeight : maxHeight);
player.UIs().get(VideoPlayerUi.class).ifPresent(ui ->
ui.getBinding().surfaceView.setHeights(newHeight,
ui.isFullscreen() ? newHeight : maxHeight));
}
}
@ -1517,7 +1540,7 @@ public final class VideoDetailFragment
if (binding.relatedItemsLayout != null) {
if (showRelatedItems) {
binding.relatedItemsLayout.setVisibility(
isPlayerAvailable() && player.isFullscreen() ? View.GONE : View.INVISIBLE);
isFullscreen() ? View.GONE : View.INVISIBLE);
} else {
binding.relatedItemsLayout.setVisibility(View.GONE);
}
@ -1551,7 +1574,8 @@ public final class VideoDetailFragment
binding.detailUploaderThumbnailView.setVisibility(View.GONE);
}
final Drawable buddyDrawable = AppCompatResources.getDrawable(activity, R.drawable.buddy);
final Drawable buddyDrawable =
AppCompatResources.getDrawable(activity, R.drawable.placeholder_person);
binding.detailSubChannelThumbnailView.setImageDrawable(buddyDrawable);
binding.detailUploaderThumbnailView.setImageDrawable(buddyDrawable);
@ -1778,6 +1802,11 @@ public final class VideoDetailFragment
// Player event listener
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onViewCreated() {
tryAddVideoPlayerView();
}
@Override
public void onQueueUpdate(final PlayQueue queue) {
playQueue = queue;
@ -1898,15 +1927,10 @@ public final class VideoDetailFragment
@Override
public void onFullscreenStateChanged(final boolean fullscreen) {
setupBrightness();
//noinspection SimplifyOptionalCallChains
if (!isPlayerAndPlayerServiceAvailable()
|| playerService.getView() == null
|| player.getParentActivity() == null) {
return;
}
final View view = playerService.getView();
final ViewGroup parent = (ViewGroup) view.getParent();
if (parent == null) {
|| !player.UIs().get(MainPlayerUi.class).isPresent()
|| getRoot().map(View::getParent).orElse(null) == null) {
return;
}
@ -1922,13 +1946,7 @@ public final class VideoDetailFragment
}
scrollToTop();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
addVideoPlayerView();
} else {
// KitKat needs a delay before addVideoPlayerView call or it reports wrong height in
// activity.getWindow().getDecorView().getHeight()
new Handler().post(this::addVideoPlayerView);
}
tryAddVideoPlayerView();
}
@Override
@ -1940,7 +1958,7 @@ public final class VideoDetailFragment
final boolean isLandscape = DeviceUtils.isLandscape(requireContext());
if (DeviceUtils.isTablet(activity)
&& (!globalScreenOrientationLocked(activity) || isLandscape)) {
player.toggleFullscreen();
player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen);
return;
}
@ -1991,10 +2009,8 @@ public final class VideoDetailFragment
}
activity.getWindow().getDecorView().setSystemUiVisibility(0);
activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
activity.getWindow().setStatusBarColor(ThemeHelper.resolveColorFromAttr(
requireContext(), android.R.attr.colorPrimary));
}
activity.getWindow().setStatusBarColor(ThemeHelper.resolveColorFromAttr(
requireContext(), android.R.attr.colorPrimary));
}
private void hideSystemUi() {
@ -2025,8 +2041,7 @@ public final class VideoDetailFragment
}
activity.getWindow().getDecorView().setSystemUiVisibility(visibility);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP
&& (isInMultiWindow || (isPlayerAvailable() && player.isFullscreen()))) {
if (isInMultiWindow || isFullscreen()) {
activity.getWindow().setStatusBarColor(Color.TRANSPARENT);
activity.getWindow().setNavigationBarColor(Color.TRANSPARENT);
}
@ -2034,14 +2049,19 @@ public final class VideoDetailFragment
}
// Listener implementation
@Override
public void hideSystemUiIfNeeded() {
if (isPlayerAvailable()
&& player.isFullscreen()
if (isFullscreen()
&& bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED) {
hideSystemUi();
}
}
private boolean isFullscreen() {
return isPlayerAvailable() && player.UIs().get(VideoPlayerUi.class)
.map(VideoPlayerUi::isFullscreen).orElse(false);
}
private boolean playerIsNotStopped() {
return isPlayerAvailable() && !player.isStopped();
}
@ -2064,10 +2084,7 @@ public final class VideoDetailFragment
}
final WindowManager.LayoutParams lp = activity.getWindow().getAttributes();
if (!isPlayerAvailable()
|| !player.videoPlayerSelected()
|| !player.isFullscreen()
|| bottomSheetState != BottomSheetBehavior.STATE_EXPANDED) {
if (!isFullscreen() || bottomSheetState != BottomSheetBehavior.STATE_EXPANDED) {
// Apply system brightness when the player is not in fullscreen
restoreDefaultBrightness();
} else {
@ -2091,7 +2108,7 @@ public final class VideoDetailFragment
setAutoPlay(true);
}
player.checkLandscape();
player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::checkLandscape);
// Let's give a user time to look at video information page if video is not playing
if (globalScreenOrientationLocked(activity) && !player.isPlaying()) {
player.play();
@ -2170,12 +2187,8 @@ public final class VideoDetailFragment
} else {
final int selectedVideoStreamIndexForExternalPlayers =
ListHelper.getDefaultResolutionIndex(activity, videoStreamsForExternalPlayers);
final CharSequence[] resolutions =
new CharSequence[videoStreamsForExternalPlayers.size()];
for (int i = 0; i < videoStreamsForExternalPlayers.size(); i++) {
resolutions[i] = videoStreamsForExternalPlayers.get(i).getResolution();
}
final CharSequence[] resolutions = videoStreamsForExternalPlayers.stream()
.map(VideoStream::getResolution).toArray(CharSequence[]::new);
builder.setSingleChoiceItems(resolutions, selectedVideoStreamIndexForExternalPlayers,
null);
@ -2279,7 +2292,9 @@ public final class VideoDetailFragment
final FrameLayout bottomSheetLayout = activity.findViewById(R.id.fragment_player_holder);
bottomSheetBehavior = BottomSheetBehavior.from(bottomSheetLayout);
bottomSheetBehavior.setState(bottomSheetState);
bottomSheetBehavior.setState(lastStableBottomSheetState);
updateBottomSheetState(lastStableBottomSheetState);
final int peekHeight = getResources().getDimensionPixelSize(R.dimen.mini_player_height);
if (bottomSheetState != BottomSheetBehavior.STATE_HIDDEN) {
manageSpaceAtTheBottom(false);
@ -2292,10 +2307,10 @@ public final class VideoDetailFragment
}
}
bottomSheetBehavior.setBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() {
bottomSheetCallback = new BottomSheetBehavior.BottomSheetCallback() {
@Override
public void onStateChanged(@NonNull final View bottomSheet, final int newState) {
bottomSheetState = newState;
updateBottomSheetState(newState);
switch (newState) {
case BottomSheetBehavior.STATE_HIDDEN:
@ -2318,10 +2333,10 @@ public final class VideoDetailFragment
if (DeviceUtils.isLandscape(requireContext())
&& isPlayerAvailable()
&& player.isPlaying()
&& !player.isFullscreen()
&& !DeviceUtils.isTablet(activity)
&& player.videoPlayerSelected()) {
player.toggleFullscreen();
&& !isFullscreen()
&& !DeviceUtils.isTablet(activity)) {
player.UIs().get(MainPlayerUi.class)
.ifPresent(MainPlayerUi::toggleFullscreen);
}
setOverlayLook(binding.appBarLayout, behavior, 1);
break;
@ -2334,19 +2349,26 @@ public final class VideoDetailFragment
// Re-enable clicks
setOverlayElementsClickable(true);
if (isPlayerAvailable()) {
player.closeItemsList();
player.UIs().get(MainPlayerUi.class)
.ifPresent(MainPlayerUi::closeItemsList);
}
setOverlayLook(binding.appBarLayout, behavior, 0);
break;
case BottomSheetBehavior.STATE_DRAGGING:
case BottomSheetBehavior.STATE_SETTLING:
if (isPlayerAvailable() && player.isFullscreen()) {
if (isFullscreen()) {
showSystemUi();
}
if (isPlayerAvailable() && player.isControlsVisible()) {
player.hideControls(0, 0);
if (isPlayerAvailable()) {
player.UIs().get(MainPlayerUi.class).ifPresent(ui -> {
if (ui.isControlsVisible()) {
ui.hideControls(0, 0);
}
});
}
break;
case BottomSheetBehavior.STATE_HALF_EXPANDED:
break;
}
}
@ -2354,7 +2376,9 @@ public final class VideoDetailFragment
public void onSlide(@NonNull final View bottomSheet, final float slideOffset) {
setOverlayLook(binding.appBarLayout, behavior, slideOffset);
}
});
};
bottomSheetBehavior.addBottomSheetCallback(bottomSheetCallback);
// User opened a new page and the player will hide itself
activity.getSupportFragmentManager().addOnBackStackChangedListener(() -> {
@ -2369,8 +2393,8 @@ public final class VideoDetailFragment
@Nullable final String thumbnailUrl) {
binding.overlayTitleTextView.setText(isEmpty(overlayTitle) ? "" : overlayTitle);
binding.overlayChannelTextView.setText(isEmpty(uploader) ? "" : uploader);
binding.overlayThumbnail.setImageResource(R.drawable.dummy_thumbnail_dark);
PicassoHelper.loadThumbnail(thumbnailUrl).tag(PICASSO_VIDEO_DETAILS_TAG)
binding.overlayThumbnail.setImageDrawable(null);
PicassoHelper.loadDetailsThumbnail(thumbnailUrl).tag(PICASSO_VIDEO_DETAILS_TAG)
.into(binding.overlayThumbnail);
}
@ -2418,4 +2442,21 @@ public final class VideoDetailFragment
boolean isPlayerAndPlayerServiceAvailable() {
return (player != null && playerService != null);
}
public Optional<View> getRoot() {
if (player == null) {
return Optional.empty();
}
return player.UIs().get(VideoPlayerUi.class)
.map(playerUi -> playerUi.getBinding().getRoot());
}
private void updateBottomSheetState(final int newState) {
bottomSheetState = newState;
if (newState != BottomSheetBehavior.STATE_DRAGGING
&& newState != BottomSheetBehavior.STATE_SETTLING) {
lastStableBottomSheetState = newState;
}
}
}

View File

@ -6,6 +6,7 @@ import static com.google.android.exoplayer2.PlaybackException.ERROR_CODE_UNSPECI
import android.content.Context;
import android.util.Log;
import android.util.Pair;
import android.view.ContextThemeWrapper;
import android.view.LayoutInflater;
import android.view.ViewGroup;
@ -28,9 +29,7 @@ import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.util.ThemeHelper;
import java.io.IOException;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.List;
import java.util.function.Supplier;
/**
@ -43,50 +42,34 @@ public final class VideoDetailPlayerCrasher {
// https://stackoverflow.com/a/54744028
private static final String TAG = "VideoDetPlayerCrasher";
private static final Map<String, Supplier<ExoPlaybackException>> AVAILABLE_EXCEPTION_TYPES =
getExceptionTypes();
private static final String DEFAULT_MSG = "Dummy";
private static final List<Pair<String, Supplier<ExoPlaybackException>>>
AVAILABLE_EXCEPTION_TYPES = List.of(
new Pair<>("Source", () -> ExoPlaybackException.createForSource(
new IOException(DEFAULT_MSG),
ERROR_CODE_BEHIND_LIVE_WINDOW
)),
new Pair<>("Renderer", () -> ExoPlaybackException.createForRenderer(
new Exception(DEFAULT_MSG),
"Dummy renderer",
0,
null,
C.FORMAT_HANDLED,
/*isRecoverable=*/false,
ERROR_CODE_DECODING_FAILED
)),
new Pair<>("Unexpected", () -> ExoPlaybackException.createForUnexpected(
new RuntimeException(DEFAULT_MSG),
ERROR_CODE_UNSPECIFIED
)),
new Pair<>("Remote", () -> ExoPlaybackException.createForRemote(DEFAULT_MSG))
);
private VideoDetailPlayerCrasher() {
// No impls
}
private static Map<String, Supplier<ExoPlaybackException>> getExceptionTypes() {
final String defaultMsg = "Dummy";
final Map<String, Supplier<ExoPlaybackException>> exceptionTypes = new LinkedHashMap<>();
exceptionTypes.put(
"Source",
() -> ExoPlaybackException.createForSource(
new IOException(defaultMsg),
ERROR_CODE_BEHIND_LIVE_WINDOW
)
);
exceptionTypes.put(
"Renderer",
() -> ExoPlaybackException.createForRenderer(
new Exception(defaultMsg),
"Dummy renderer",
0,
null,
C.FORMAT_HANDLED,
/*isRecoverable=*/false,
ERROR_CODE_DECODING_FAILED
)
);
exceptionTypes.put(
"Unexpected",
() -> ExoPlaybackException.createForUnexpected(
new RuntimeException(defaultMsg),
ERROR_CODE_UNSPECIFIED
)
);
exceptionTypes.put(
"Remote",
() -> ExoPlaybackException.createForRemote(defaultMsg)
);
return Collections.unmodifiableMap(exceptionTypes);
}
private static Context getThemeWrapperContext(final Context context) {
return new ContextThemeWrapper(
context,
@ -121,10 +104,9 @@ public final class VideoDetailPlayerCrasher {
.setNegativeButton(R.string.cancel, null)
.create();
for (final Map.Entry<String, Supplier<ExoPlaybackException>> entry
: AVAILABLE_EXCEPTION_TYPES.entrySet()) {
for (final Pair<String, Supplier<ExoPlaybackException>> entry : AVAILABLE_EXCEPTION_TYPES) {
final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater).getRoot();
radioButton.setText(entry.getKey());
radioButton.setText(entry.first);
radioButton.setChecked(false);
radioButton.setLayoutParams(
new RadioGroup.LayoutParams(
@ -133,7 +115,7 @@ public final class VideoDetailPlayerCrasher {
)
);
radioButton.setOnClickListener(v -> {
tryCrashPlayerWith(player, entry.getValue().get());
tryCrashPlayerWith(player, entry.second.get());
alertDialog.cancel();
});
binding.list.addView(radioButton);

View File

@ -23,14 +23,11 @@ import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.BaseStateFragment;
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.InfoListAdapter;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
import org.schabi.newpipe.util.StateSaver;
@ -264,45 +261,28 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
}
});
infoListAdapter.setOnChannelSelectedListener(new OnClickGesture<>() {
@Override
public void selected(final ChannelInfoItem selectedItem) {
try {
onItemSelected(selectedItem);
NavigationHelper.openChannelFragment(getFM(),
selectedItem.getServiceId(),
selectedItem.getUrl(),
selectedItem.getName());
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(
BaseListFragment.this, "Opening channel fragment", e);
}
}
});
infoListAdapter.setOnPlaylistSelectedListener(new OnClickGesture<>() {
@Override
public void selected(final PlaylistInfoItem selectedItem) {
try {
onItemSelected(selectedItem);
NavigationHelper.openPlaylistFragment(getFM(),
selectedItem.getServiceId(),
selectedItem.getUrl(),
selectedItem.getName());
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(BaseListFragment.this,
"Opening playlist fragment", e);
}
}
});
infoListAdapter.setOnCommentsSelectedListener(new OnClickGesture<>() {
@Override
public void selected(final CommentsInfoItem selectedItem) {
infoListAdapter.setOnChannelSelectedListener(selectedItem -> {
try {
onItemSelected(selectedItem);
NavigationHelper.openChannelFragment(getFM(), selectedItem.getServiceId(),
selectedItem.getUrl(), selectedItem.getName());
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e);
}
});
infoListAdapter.setOnPlaylistSelectedListener(selectedItem -> {
try {
onItemSelected(selectedItem);
NavigationHelper.openPlaylistFragment(getFM(), selectedItem.getServiceId(),
selectedItem.getUrl(), selectedItem.getName());
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(this, "Opening playlist fragment", e);
}
});
infoListAdapter.setOnCommentsSelectedListener(this::onItemSelected);
// Ensure that there is always a scroll listener (e.g. when rotating the device)
useNormalItemListScrollListener();
}

View File

@ -43,7 +43,7 @@ import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.local.subscription.SubscriptionManager;
import org.schabi.newpipe.local.feed.notifications.NotificationHelper;
import org.schabi.newpipe.player.MainPlayer.PlayerType;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.util.ExtractorHelper;
@ -578,17 +578,13 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
}
private PlayQueue getPlayQueue() {
return getPlayQueue(0);
}
private PlayQueue getPlayQueue(final int index) {
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, index);
currentInfo.getNextPage(), streamItems, 0);
}
/*//////////////////////////////////////////////////////////////////////////

View File

@ -43,7 +43,7 @@ import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
import org.schabi.newpipe.player.MainPlayer.PlayerType;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
import org.schabi.newpipe.util.ExtractorHelper;

View File

@ -200,7 +200,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
showLocalSuggestions = NewPipeSettings.showLocalSearchSuggestions(activity, prefs);
showRemoteSuggestions = NewPipeSettings.showRemoteSearchSuggestions(activity, prefs);
suggestionListAdapter = new SuggestionListAdapter(activity);
suggestionListAdapter = new SuggestionListAdapter();
historyRecordManager = new HistoryRecordManager(context);
}
@ -340,6 +340,8 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
super.initViews(rootView, savedInstanceState);
searchBinding.suggestionsList.setAdapter(suggestionListAdapter);
// animations are just strange and useless, since the suggestions keep changing too much
searchBinding.suggestionsList.setItemAnimator(null);
new ItemTouchHelper(new ItemTouchHelper.Callback() {
@Override
public int getMovementFlags(@NonNull final RecyclerView recyclerView,
@ -497,9 +499,6 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
+ lastSearchedString);
}
searchEditText.setText(searchString);
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP) {
searchEditText.setHintTextColor(searchEditText.getTextColors().withAlpha(128));
}
if (TextUtils.isEmpty(searchString) || TextUtils.isEmpty(searchEditText.getText())) {
searchToolbarContainer.setTranslationX(100);
@ -533,7 +532,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
searchBinding.correctSuggestion.setVisibility(View.GONE);
searchEditText.setText("");
suggestionListAdapter.setItems(new ArrayList<>());
suggestionListAdapter.submitList(null);
showKeyboardSearch();
});
@ -922,7 +921,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
filterItemCheckedId = item.getItemId();
item.setChecked(true);
contentFilter = new String[]{theContentFilter.get(0)};
contentFilter = theContentFilter.toArray(new String[0]);
if (!TextUtils.isEmpty(searchString)) {
search(searchString, contentFilter, sortFilter);
@ -947,8 +946,8 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
if (DEBUG) {
Log.d(TAG, "handleSuggestions() called with: suggestions = [" + suggestions + "]");
}
searchBinding.suggestionsList.smoothScrollToPosition(0);
searchBinding.suggestionsList.post(() -> suggestionListAdapter.setItems(suggestions));
suggestionListAdapter.submitList(suggestions,
() -> searchBinding.suggestionsList.scrollToPosition(0));
if (suggestionsPanelVisible && isErrorPanelVisible()) {
hideLoading();
@ -983,8 +982,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
isCorrectedSearch = result.isCorrectedSearch();
// List<MetaInfo> cannot be bundled without creating some containers
metaInfo = new MetaInfo[result.getMetaInfo().size()];
metaInfo = result.getMetaInfo().toArray(metaInfo);
metaInfo = result.getMetaInfo().toArray(new MetaInfo[0]);
showMetaInfoInTextView(result.getMetaInfo(), searchBinding.searchMetaInfoTextView,
searchBinding.searchMetaInfoSeparator, disposables);
@ -1070,14 +1068,14 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
return 0;
}
final SuggestionItem item = suggestionListAdapter.getItem(position);
final SuggestionItem item = suggestionListAdapter.getCurrentList().get(position);
return item.fromHistory ? makeMovementFlags(0,
ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT) : 0;
}
public void onSuggestionItemSwiped(@NonNull final RecyclerView.ViewHolder viewHolder) {
final int position = viewHolder.getBindingAdapterPosition();
final String query = suggestionListAdapter.getItem(position).query;
final String query = suggestionListAdapter.getCurrentList().get(position).query;
final Disposable onDelete = historyRecordManager.deleteSearchHistory(query)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(

View File

@ -1,34 +1,22 @@
package org.schabi.newpipe.fragments.list.search;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.ListAdapter;
import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.R;
import java.util.ArrayList;
import java.util.List;
import org.schabi.newpipe.databinding.ItemSearchSuggestionBinding;
public class SuggestionListAdapter
extends RecyclerView.Adapter<SuggestionListAdapter.SuggestionItemHolder> {
private final ArrayList<SuggestionItem> items = new ArrayList<>();
private final Context context;
extends ListAdapter<SuggestionItem, SuggestionListAdapter.SuggestionItemHolder> {
private OnSuggestionItemSelected listener;
public SuggestionListAdapter(final Context context) {
this.context = context;
}
public void setItems(final List<SuggestionItem> items) {
this.items.clear();
this.items.addAll(items);
notifyDataSetChanged();
public SuggestionListAdapter() {
super(new SuggestionItemCallback());
}
public void setListener(final OnSuggestionItemSelected listener) {
@ -39,45 +27,32 @@ public class SuggestionListAdapter
@Override
public SuggestionItemHolder onCreateViewHolder(@NonNull final ViewGroup parent,
final int viewType) {
return new SuggestionItemHolder(LayoutInflater.from(context)
.inflate(R.layout.item_search_suggestion, parent, false));
return new SuggestionItemHolder(ItemSearchSuggestionBinding
.inflate(LayoutInflater.from(parent.getContext()), parent, false));
}
@Override
public void onBindViewHolder(final SuggestionItemHolder holder, final int position) {
final SuggestionItem currentItem = getItem(position);
holder.updateFrom(currentItem);
holder.queryView.setOnClickListener(v -> {
holder.itemBinding.suggestionSearch.setOnClickListener(v -> {
if (listener != null) {
listener.onSuggestionItemSelected(currentItem);
}
});
holder.queryView.setOnLongClickListener(v -> {
holder.itemBinding.suggestionSearch.setOnLongClickListener(v -> {
if (listener != null) {
listener.onSuggestionItemLongClick(currentItem);
}
return true;
});
holder.insertView.setOnClickListener(v -> {
holder.itemBinding.suggestionInsert.setOnClickListener(v -> {
if (listener != null) {
listener.onSuggestionItemInserted(currentItem);
}
});
}
SuggestionItem getItem(final int position) {
return items.get(position);
}
@Override
public int getItemCount() {
return items.size();
}
public boolean isEmpty() {
return getItemCount() == 0;
}
public interface OnSuggestionItemSelected {
void onSuggestionItemSelected(SuggestionItem item);
@ -87,30 +62,32 @@ public class SuggestionListAdapter
}
public static final class SuggestionItemHolder extends RecyclerView.ViewHolder {
private final TextView itemSuggestionQuery;
private final ImageView suggestionIcon;
private final View queryView;
private final View insertView;
private final ItemSearchSuggestionBinding itemBinding;
// Cache some ids, as they can potentially be constantly updated/recycled
private final int historyResId;
private final int searchResId;
private SuggestionItemHolder(final View rootView) {
super(rootView);
suggestionIcon = rootView.findViewById(R.id.item_suggestion_icon);
itemSuggestionQuery = rootView.findViewById(R.id.item_suggestion_query);
queryView = rootView.findViewById(R.id.suggestion_search);
insertView = rootView.findViewById(R.id.suggestion_insert);
historyResId = R.drawable.ic_history;
searchResId = R.drawable.ic_search;
private SuggestionItemHolder(final ItemSearchSuggestionBinding binding) {
super(binding.getRoot());
this.itemBinding = binding;
}
private void updateFrom(final SuggestionItem item) {
suggestionIcon.setImageResource(item.fromHistory ? historyResId : searchResId);
itemSuggestionQuery.setText(item.query);
itemBinding.itemSuggestionIcon.setImageResource(item.fromHistory ? R.drawable.ic_history
: R.drawable.ic_search);
itemBinding.itemSuggestionQuery.setText(item.query);
}
}
private static class SuggestionItemCallback extends DiffUtil.ItemCallback<SuggestionItem> {
@Override
public boolean areItemsTheSame(@NonNull final SuggestionItem oldItem,
@NonNull final SuggestionItem newItem) {
return oldItem.fromHistory == newItem.fromHistory
&& oldItem.query.equals(newItem.query);
}
@Override
public boolean areContentsTheSame(@NonNull final SuggestionItem oldItem,
@NonNull final SuggestionItem newItem) {
return true; // items' contents never change; the list of items themselves does
}
}
}

View File

@ -67,8 +67,8 @@ public class InfoItemBuilder {
public View buildView(@NonNull final ViewGroup parent, @NonNull final InfoItem infoItem,
final HistoryRecordManager historyRecordManager,
final boolean useMiniVariant) {
final InfoItemHolder holder
= holderFromInfoType(parent, infoItem.getInfoType(), useMiniVariant);
final InfoItemHolder holder =
holderFromInfoType(parent, infoItem.getInfoType(), useMiniVariant);
holder.updateFromItem(infoItem, historyRecordManager);
return holder.itemView;
}

View File

@ -321,6 +321,7 @@ public final class InfoItemDialog {
*/
public Builder addDefaultEndEntries() {
addAllEntries(
StreamDialogDefaultEntry.DOWNLOAD,
StreamDialogDefaultEntry.APPEND_PLAYLIST,
StreamDialogDefaultEntry.SHARE,
StreamDialogDefaultEntry.OPEN_IN_BROWSER

View File

@ -2,6 +2,7 @@ package org.schabi.newpipe.info_list.dialog;
import static org.schabi.newpipe.util.NavigationHelper.openChannelFragment;
import static org.schabi.newpipe.util.SparseItemUtil.fetchItemInfoIfSparse;
import static org.schabi.newpipe.util.SparseItemUtil.fetchStreamInfoAndSaveToDatabase;
import static org.schabi.newpipe.util.SparseItemUtil.fetchUploaderUrlIfSparse;
import android.net.Uri;
@ -11,6 +12,7 @@ import androidx.annotation.StringRes;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.download.DownloadDialog;
import org.schabi.newpipe.local.dialog.PlaylistAppendDialog;
import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.local.history.HistoryRecordManager;
@ -18,7 +20,7 @@ import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.KoreUtils;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.Collections;
import java.util.List;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
@ -87,7 +89,7 @@ public enum StreamDialogDefaultEntry {
APPEND_PLAYLIST(R.string.add_to_playlist, (fragment, item) ->
PlaylistDialog.createCorrespondingDialog(
fragment.getContext(),
Collections.singletonList(new StreamEntity(item)),
List.of(new StreamEntity(item)),
dialog -> dialog.show(
fragment.getParentFragmentManager(),
"StreamDialogEntry@"
@ -110,6 +112,15 @@ public enum StreamDialogDefaultEntry {
ShareUtils.shareText(fragment.requireContext(), item.getName(), item.getUrl(),
item.getThumbnailUrl())),
DOWNLOAD(R.string.download, (fragment, item) ->
fetchStreamInfoAndSaveToDatabase(fragment.requireContext(), item.getServiceId(),
item.getUrl(), info -> {
final DownloadDialog downloadDialog =
new DownloadDialog(fragment.requireContext(), info);
downloadDialog.show(fragment.getChildFragmentManager(), "downloadDialog");
})
),
OPEN_IN_BROWSER(R.string.open_in_browser, (fragment, item) ->
ShareUtils.openUrlInBrowser(fragment.requireContext(), item.getUrl())),

View File

@ -42,7 +42,7 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder {
itemTitleView.setText(item.getName());
itemAdditionalDetailView.setText(getDetailLine(item));
PicassoHelper.loadThumbnail(item.getThumbnailUrl()).into(itemThumbnailView);
PicassoHelper.loadAvatar(item.getThumbnailUrl()).into(itemThumbnailView);
itemView.setOnClickListener(view -> {
if (itemBuilder.getOnChannelSelectedListener() != null) {

View File

@ -23,9 +23,9 @@ import org.schabi.newpipe.util.CommentTextOnTouchListener;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.TimestampExtractor;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.external_communication.TimestampExtractor;
import java.util.regex.Matcher;
@ -204,8 +204,9 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
boolean hasEllipsis = false;
if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
final int endOfLastLine
= itemContentView.getLayout().getLineEnd(COMMENT_DEFAULT_LINES - 1);
final int endOfLastLine = itemContentView
.getLayout()
.getLineEnd(COMMENT_DEFAULT_LINES - 1);
int end = itemContentView.getText().toString().lastIndexOf(' ', endOfLastLine - 2);
if (end == -1) {
end = Math.max(endOfLastLine - 2, 0);

View File

@ -14,8 +14,8 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.StreamTypeUtil;
import org.schabi.newpipe.views.AnimatedProgressBar;
@ -111,8 +111,9 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
final HistoryRecordManager historyRecordManager) {
final StreamInfoItem item = (StreamInfoItem) infoItem;
final StreamStateEntity state
= historyRecordManager.loadStreamState(infoItem).blockingGet()[0];
final StreamStateEntity state = historyRecordManager
.loadStreamState(infoItem)
.blockingGet()[0];
if (state != null && item.getDuration() > 0
&& !StreamTypeUtil.isLiveStream(item.getStreamType())) {
itemProgressView.setMax((int) item.getDuration());

View File

@ -21,10 +21,6 @@ import org.schabi.newpipe.MainActivity
private const val TAG = "ViewUtils"
inline var View.backgroundTintListCompat: ColorStateList?
get() = ViewCompat.getBackgroundTintList(this)
set(value) = ViewCompat.setBackgroundTintList(this, value)
/**
* Animate the view.
*
@ -96,62 +92,43 @@ fun View.animateBackgroundColor(duration: Long, @ColorInt colorStart: Int, @Colo
if (MainActivity.DEBUG) {
Log.d(
TAG,
"animateBackgroundColor() called with: " +
"view = [" + this + "], duration = [" + duration + "], " +
"colorStart = [" + colorStart + "], colorEnd = [" + colorEnd + "]"
"animateBackgroundColor() called with: view = [$this], duration = [$duration], " +
"colorStart = [$colorStart], colorEnd = [$colorEnd]"
)
}
val empty = arrayOf(IntArray(0))
val viewPropertyAnimator = ValueAnimator.ofObject(ArgbEvaluator(), colorStart, colorEnd)
viewPropertyAnimator.interpolator = FastOutSlowInInterpolator()
viewPropertyAnimator.duration = duration
viewPropertyAnimator.addUpdateListener { animation: ValueAnimator ->
backgroundTintListCompat = ColorStateList(empty, intArrayOf(animation.animatedValue as Int))
fun listenerAction(color: Int) {
ViewCompat.setBackgroundTintList(this, ColorStateList.valueOf(color))
}
viewPropertyAnimator.addListener(
onCancel = { backgroundTintListCompat = ColorStateList(empty, intArrayOf(colorEnd)) },
onEnd = { backgroundTintListCompat = ColorStateList(empty, intArrayOf(colorEnd)) }
)
viewPropertyAnimator.addUpdateListener { listenerAction(it.animatedValue as Int) }
viewPropertyAnimator.addListener(onCancel = { listenerAction(colorEnd) }, onEnd = { listenerAction(colorEnd) })
viewPropertyAnimator.start()
}
fun View.animateHeight(duration: Long, targetHeight: Int): ValueAnimator {
if (MainActivity.DEBUG) {
Log.d(
TAG,
"animateHeight: duration = [" + duration + "], " +
"from " + height + " to → " + targetHeight + " in: " + this
)
Log.d(TAG, "animateHeight: duration = [$duration], from $height to → $targetHeight in: $this")
}
val animator = ValueAnimator.ofFloat(height.toFloat(), targetHeight.toFloat())
animator.interpolator = FastOutSlowInInterpolator()
animator.duration = duration
animator.addUpdateListener { animation: ValueAnimator ->
val value = animation.animatedValue as Float
layoutParams.height = value.toInt()
fun listenerAction(value: Int) {
layoutParams.height = value
requestLayout()
}
animator.addListener(
onCancel = {
layoutParams.height = targetHeight
requestLayout()
},
onEnd = {
layoutParams.height = targetHeight
requestLayout()
}
)
animator.addUpdateListener { listenerAction((it.animatedValue as Float).toInt()) }
animator.addListener(onCancel = { listenerAction(targetHeight) }, onEnd = { listenerAction(targetHeight) })
animator.start()
return animator
}
fun View.animateRotation(duration: Long, targetRotation: Int) {
if (MainActivity.DEBUG) {
Log.d(
TAG,
"animateRotation: duration = [" + duration + "], " +
"from " + rotation + " to → " + targetRotation + " in: " + this
)
Log.d(TAG, "animateRotation: duration = [$duration], from $rotation to → $targetRotation in: $this")
}
animate().setListener(null).cancel()
animate()
@ -172,20 +149,13 @@ private fun View.animateAlpha(enterOrExit: Boolean, duration: Long, delay: Long,
if (enterOrExit) {
animate().setInterpolator(FastOutSlowInInterpolator()).alpha(1f)
.setDuration(duration).setStartDelay(delay)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
execOnEnd?.run()
}
}).start()
.setListener(ExecOnEndListener(execOnEnd))
.start()
} else {
animate().setInterpolator(FastOutSlowInInterpolator()).alpha(0f)
.setDuration(duration).setStartDelay(delay)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
isGone = true
execOnEnd?.run()
}
}).start()
.setListener(HideAndExecOnEndListener(this, execOnEnd))
.start()
}
}
@ -197,11 +167,8 @@ private fun View.animateScaleAndAlpha(enterOrExit: Boolean, duration: Long, dela
.setInterpolator(FastOutSlowInInterpolator())
.alpha(1f).scaleX(1f).scaleY(1f)
.setDuration(duration).setStartDelay(delay)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
execOnEnd?.run()
}
}).start()
.setListener(ExecOnEndListener(execOnEnd))
.start()
} else {
scaleX = 1f
scaleY = 1f
@ -209,12 +176,8 @@ private fun View.animateScaleAndAlpha(enterOrExit: Boolean, duration: Long, dela
.setInterpolator(FastOutSlowInInterpolator())
.alpha(0f).scaleX(.8f).scaleY(.8f)
.setDuration(duration).setStartDelay(delay)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
isGone = true
execOnEnd?.run()
}
}).start()
.setListener(HideAndExecOnEndListener(this, execOnEnd))
.start()
}
}
@ -227,11 +190,8 @@ private fun View.animateLightScaleAndAlpha(enterOrExit: Boolean, duration: Long,
.setInterpolator(FastOutSlowInInterpolator())
.alpha(1f).scaleX(1f).scaleY(1f)
.setDuration(duration).setStartDelay(delay)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
execOnEnd?.run()
}
}).start()
.setListener(ExecOnEndListener(execOnEnd))
.start()
} else {
alpha = 1f
scaleX = 1f
@ -240,12 +200,8 @@ private fun View.animateLightScaleAndAlpha(enterOrExit: Boolean, duration: Long,
.setInterpolator(FastOutSlowInInterpolator())
.alpha(0f).scaleX(.95f).scaleY(.95f)
.setDuration(duration).setStartDelay(delay)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
isGone = true
execOnEnd?.run()
}
}).start()
.setListener(HideAndExecOnEndListener(this, execOnEnd))
.start()
}
}
@ -256,22 +212,15 @@ private fun View.animateSlideAndAlpha(enterOrExit: Boolean, duration: Long, dela
animate()
.setInterpolator(FastOutSlowInInterpolator()).alpha(1f).translationY(0f)
.setDuration(duration).setStartDelay(delay)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
execOnEnd?.run()
}
}).start()
.setListener(ExecOnEndListener(execOnEnd))
.start()
} else {
animate()
.setInterpolator(FastOutSlowInInterpolator())
.alpha(0f).translationY(-height.toFloat())
.setDuration(duration).setStartDelay(delay)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
isGone = true
execOnEnd?.run()
}
}).start()
.setListener(HideAndExecOnEndListener(this, execOnEnd))
.start()
}
}
@ -282,21 +231,14 @@ private fun View.animateLightSlideAndAlpha(enterOrExit: Boolean, duration: Long,
animate()
.setInterpolator(FastOutSlowInInterpolator()).alpha(1f).translationY(0f)
.setDuration(duration).setStartDelay(delay)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
execOnEnd?.run()
}
}).start()
.setListener(ExecOnEndListener(execOnEnd))
.start()
} else {
animate().setInterpolator(FastOutSlowInInterpolator())
.alpha(0f).translationY(-height / 2.0f)
.setDuration(duration).setStartDelay(delay)
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
isGone = true
execOnEnd?.run()
}
}).start()
.setListener(HideAndExecOnEndListener(this, execOnEnd))
.start()
}
}
@ -318,11 +260,7 @@ fun View.slideUp(
.setStartDelay(delay)
.setDuration(duration)
.setInterpolator(FastOutSlowInInterpolator())
.setListener(object : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
execOnEnd?.run()
}
})
.setListener(ExecOnEndListener(execOnEnd))
.start()
}
@ -336,6 +274,20 @@ fun View.animateHideRecyclerViewAllowingScrolling() {
animate().alpha(0.0f).setDuration(200).start()
}
private open class ExecOnEndListener(private val execOnEnd: Runnable?) : AnimatorListenerAdapter() {
override fun onAnimationEnd(animation: Animator) {
execOnEnd?.run()
}
}
private class HideAndExecOnEndListener(private val view: View, execOnEnd: Runnable?) :
ExecOnEndListener(execOnEnd) {
override fun onAnimationEnd(animation: Animator) {
view.isGone = true
super.onAnimationEnd(animation)
}
}
enum class AnimationType {
ALPHA, SCALE_AND_ALPHA, LIGHT_SCALE_AND_ALPHA, SLIDE_AND_ALPHA, LIGHT_SLIDE_AND_ALPHA
}

View File

@ -98,7 +98,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
protected void initListeners() {
super.initListeners();
itemListAdapter.setSelectedListener(new OnClickGesture<LocalItem>() {
itemListAdapter.setSelectedListener(new OnClickGesture<>() {
@Override
public void selected(final LocalItem selectedItem) {
final FragmentManager fragmentManager = getFM();
@ -256,8 +256,8 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
}
private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
final DialogEditTextBinding dialogBinding
= DialogEditTextBinding.inflate(getLayoutInflater());
final DialogEditTextBinding dialogBinding =
DialogEditTextBinding.inflate(getLayoutInflater());
dialogBinding.dialogEditText.setHint(R.string.name);
dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT);
dialogBinding.dialogEditText.setText(selectedItem.name);

View File

@ -13,12 +13,10 @@ import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.local.LocalItemListAdapter;
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
import org.schabi.newpipe.util.OnClickGesture;
import java.util.List;
@ -63,18 +61,10 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
new LocalPlaylistManager(NewPipeDatabase.getInstance(requireContext()));
playlistAdapter = new LocalItemListAdapter(getActivity());
playlistAdapter.setSelectedListener(new OnClickGesture<LocalItem>() {
@Override
public void selected(final LocalItem selectedItem) {
if (!(selectedItem instanceof PlaylistMetadataEntry)
|| getStreamEntities() == null) {
return;
}
onPlaylistSelected(
playlistManager,
(PlaylistMetadataEntry) selectedItem,
getStreamEntities()
);
playlistAdapter.setSelectedListener(selectedItem -> {
final List<StreamEntity> entities = getStreamEntities();
if (selectedItem instanceof PlaylistMetadataEntry && entities != null) {
onPlaylistSelected(playlistManager, (PlaylistMetadataEntry) selectedItem, entities);
}
});
@ -138,14 +128,11 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
private void onPlaylistSelected(@NonNull final LocalPlaylistManager manager,
@NonNull final PlaylistMetadataEntry playlist,
@NonNull final List<StreamEntity> streams) {
if (getStreamEntities() == null) {
return;
}
final Toast successToast = Toast.makeText(getContext(),
R.string.playlist_add_stream_success, Toast.LENGTH_SHORT);
if (playlist.thumbnailUrl.equals("drawable://" + R.drawable.dummy_thumbnail_playlist)) {
if (playlist.thumbnailUrl
.equals("drawable://" + R.drawable.placeholder_thumbnail_playlist)) {
playlistDisposables.add(manager
.changePlaylistThumbnail(playlist.uid, streams.get(0).getThumbnailUrl())
.observeOn(AndroidSchedulers.mainThread())

View File

@ -45,8 +45,8 @@ public final class PlaylistCreationDialog extends PlaylistDialog {
return super.onCreateDialog(savedInstanceState);
}
final DialogEditTextBinding dialogBinding
= DialogEditTextBinding.inflate(getLayoutInflater());
final DialogEditTextBinding dialogBinding =
DialogEditTextBinding.inflate(getLayoutInflater());
dialogBinding.getRoot().getContext().setTheme(ThemeHelper.getDialogTheme(requireContext()));
dialogBinding.dialogEditText.setHint(R.string.name);
dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT);

View File

@ -9,15 +9,20 @@ import android.view.Window;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.FragmentManager;
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.util.StateSaver;
import java.util.List;
import java.util.Objects;
import java.util.Queue;
import java.util.function.Consumer;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable;
@ -131,13 +136,13 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
* @param context context used for accessing the database
* @param streamEntities used for crating the dialog
* @param onExec execution that should occur after a dialog got created, e.g. showing it
* @return Disposable
* @return the disposable that was created
*/
public static Disposable createCorrespondingDialog(
final Context context,
final List<StreamEntity> streamEntities,
final Consumer<PlaylistDialog> onExec
) {
final Consumer<PlaylistDialog> onExec) {
return new LocalPlaylistManager(NewPipeDatabase.getInstance(context))
.hasPlaylists()
.observeOn(AndroidSchedulers.mainThread())
@ -147,4 +152,30 @@ public abstract class PlaylistDialog extends DialogFragment implements StateSave
: PlaylistCreationDialog.newInstance(streamEntities))
);
}
/**
* Creates a {@link PlaylistAppendDialog} when playlists exists,
* otherwise a {@link PlaylistCreationDialog}. If the player's play queue is null or empty, no
* dialog will be created.
*
* @param player the player from which to extract the context and the play queue
* @param fragmentManager the fragment manager to use to show the dialog
* @return the disposable that was created
*/
public static Disposable showForPlayQueue(
final Player player,
@NonNull final FragmentManager fragmentManager) {
final List<StreamEntity> streamEntities = Stream.of(player.getPlayQueue())
.filter(Objects::nonNull)
.flatMap(playQueue -> playQueue.getStreams().stream())
.map(StreamEntity::new)
.collect(Collectors.toList());
if (streamEntities.isEmpty()) {
return Disposable.empty();
}
return PlaylistDialog.createCorrespondingDialog(player.getContext(), streamEntities,
dialog -> dialog.show(fragmentManager, "PlaylistDialog"));
}
}

View File

@ -41,19 +41,15 @@ class FeedDatabaseManager(context: Context) {
fun database() = database
fun getStreams(
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
getPlayedStreams: Boolean = true
groupId: Long,
includePlayedStreams: Boolean,
includeFutureStreams: Boolean
): Maybe<List<StreamWithState>> {
return when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> {
if (getPlayedStreams) feedTable.getAllStreams()
else feedTable.getLiveOrNotPlayedStreams()
}
else -> {
if (getPlayedStreams) feedTable.getAllStreamsForGroup(groupId)
else feedTable.getLiveOrNotPlayedStreamsForGroup(groupId)
}
}
return feedTable.getStreams(
groupId,
includePlayedStreams,
if (includeFutureStreams) null else OffsetDateTime.now()
)
}
fun outdatedSubscriptions(outdatedThreshold: OffsetDateTime) = feedTable.getAllOutdated(outdatedThreshold)

View File

@ -41,6 +41,7 @@ import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.edit
import androidx.core.os.bundleOf
import androidx.core.view.MenuItemCompat
import androidx.core.view.isVisible
import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager
@ -98,6 +99,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
private lateinit var groupAdapter: GroupieAdapter
@State @JvmField var showPlayedItems: Boolean = true
@State @JvmField var showFutureItems: Boolean = true
private var onSettingsChangeListener: SharedPreferences.OnSharedPreferenceChangeListener? = null
private var updateListViewModeOnResume = false
@ -134,9 +136,10 @@ class FeedFragment : BaseStateFragment<FeedState>() {
_feedBinding = FragmentFeedBinding.bind(rootView)
super.onViewCreated(rootView, savedInstanceState)
val factory = FeedViewModel.Factory(requireContext(), groupId)
viewModel = ViewModelProvider(this, factory).get(FeedViewModel::class.java)
val factory = FeedViewModel.getFactory(requireContext(), groupId)
viewModel = ViewModelProvider(this, factory)[FeedViewModel::class.java]
showPlayedItems = viewModel.getShowPlayedItemsFromPreferences()
showFutureItems = viewModel.getShowFutureItemsFromPreferences()
viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(::handleResult) }
groupAdapter = GroupieAdapter().apply {
@ -212,6 +215,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
inflater.inflate(R.menu.menu_feed_fragment, menu)
updateTogglePlayedItemsButton(menu.findItem(R.id.menu_item_feed_toggle_played_items))
updateToggleFutureItemsButton(menu.findItem(R.id.menu_item_feed_toggle_future_items))
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
@ -241,6 +245,11 @@ class FeedFragment : BaseStateFragment<FeedState>() {
updateTogglePlayedItemsButton(item)
viewModel.togglePlayedItems(showPlayedItems)
viewModel.saveShowPlayedItemsToPreferences(showPlayedItems)
} else if (item.itemId == R.id.menu_item_feed_toggle_future_items) {
showFutureItems = !item.isChecked
updateToggleFutureItemsButton(item)
viewModel.toggleFutureItems(showFutureItems)
viewModel.saveShowFutureItemsToPreferences(showFutureItems)
}
return super.onOptionsItemSelected(item)
@ -278,6 +287,32 @@ class FeedFragment : BaseStateFragment<FeedState>() {
requireContext(),
if (showPlayedItems) R.drawable.ic_visibility_on else R.drawable.ic_visibility_off
)
MenuItemCompat.setTooltipText(
menuItem,
getString(
if (showPlayedItems)
R.string.feed_toggle_hide_played_items
else
R.string.feed_toggle_show_played_items
)
)
}
private fun updateToggleFutureItemsButton(menuItem: MenuItem) {
menuItem.isChecked = showFutureItems
menuItem.icon = AppCompatResources.getDrawable(
requireContext(),
if (showFutureItems) R.drawable.ic_history_future else R.drawable.ic_history
)
MenuItemCompat.setTooltipText(
menuItem,
getString(
if (showFutureItems)
R.string.feed_toggle_hide_future_items
else
R.string.feed_toggle_show_future_items
)
)
}
// //////////////////////////////////////////////////////////////////////////

View File

@ -1,17 +1,20 @@
package org.schabi.newpipe.local.feed
import android.app.Application
import android.content.Context
import androidx.core.content.edit
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import androidx.preference.PreferenceManager
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.functions.Function4
import io.reactivex.rxjava3.functions.Function5
import io.reactivex.rxjava3.processors.BehaviorProcessor
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.App
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.stream.StreamWithState
@ -26,17 +29,23 @@ import java.time.OffsetDateTime
import java.util.concurrent.TimeUnit
class FeedViewModel(
private val applicationContext: Context,
private val application: Application,
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
initialShowPlayedItems: Boolean = true
initialShowPlayedItems: Boolean = true,
initialShowFutureItems: Boolean = true
) : ViewModel() {
private var feedDatabaseManager: FeedDatabaseManager = FeedDatabaseManager(applicationContext)
private val feedDatabaseManager = FeedDatabaseManager(application)
private val toggleShowPlayedItems = BehaviorProcessor.create<Boolean>()
private val toggleShowPlayedItemsFlowable = toggleShowPlayedItems
.startWithItem(initialShowPlayedItems)
.distinctUntilChanged()
private val toggleShowFutureItems = BehaviorProcessor.create<Boolean>()
private val toggleShowFutureItemsFlowable = toggleShowFutureItems
.startWithItem(initialShowFutureItems)
.distinctUntilChanged()
private val mutableStateLiveData = MutableLiveData<FeedState>()
val stateLiveData: LiveData<FeedState> = mutableStateLiveData
@ -44,21 +53,22 @@ class FeedViewModel(
.combineLatest(
FeedEventManager.events(),
toggleShowPlayedItemsFlowable,
toggleShowFutureItemsFlowable,
feedDatabaseManager.notLoadedCount(groupId),
feedDatabaseManager.oldestSubscriptionUpdate(groupId),
Function4 { t1: FeedEventManager.Event, t2: Boolean,
t3: Long, t4: List<OffsetDateTime> ->
return@Function4 CombineResultEventHolder(t1, t2, t3, t4.firstOrNull())
Function5 { t1: FeedEventManager.Event, t2: Boolean, t3: Boolean,
t4: Long, t5: List<OffsetDateTime> ->
return@Function5 CombineResultEventHolder(t1, t2, t3, t4, t5.firstOrNull())
}
)
.throttleLatest(DEFAULT_THROTTLE_TIMEOUT, TimeUnit.MILLISECONDS)
.subscribeOn(Schedulers.io())
.observeOn(Schedulers.io())
.map { (event, showPlayedItems, notLoadedCount, oldestUpdate) ->
.map { (event, showPlayedItems, showFutureItems, notLoadedCount, oldestUpdate) ->
val streamItems = if (event is SuccessResultEvent || event is IdleEvent)
feedDatabaseManager
.getStreams(groupId, showPlayedItems)
.getStreams(groupId, showPlayedItems, showFutureItems)
.blockingGet(arrayListOf())
else
arrayListOf()
@ -89,8 +99,9 @@ class FeedViewModel(
private data class CombineResultEventHolder(
val t1: FeedEventManager.Event,
val t2: Boolean,
val t3: Long,
val t4: OffsetDateTime?
val t3: Boolean,
val t4: Long,
val t5: OffsetDateTime?
)
private data class CombineResultDataHolder(
@ -105,31 +116,42 @@ class FeedViewModel(
}
fun saveShowPlayedItemsToPreferences(showPlayedItems: Boolean) =
PreferenceManager.getDefaultSharedPreferences(applicationContext).edit {
this.putBoolean(applicationContext.getString(R.string.feed_show_played_items_key), showPlayedItems)
PreferenceManager.getDefaultSharedPreferences(application).edit {
this.putBoolean(application.getString(R.string.feed_show_played_items_key), showPlayedItems)
this.apply()
}
fun getShowPlayedItemsFromPreferences() = getShowPlayedItemsFromPreferences(applicationContext)
fun getShowPlayedItemsFromPreferences() = getShowPlayedItemsFromPreferences(application)
fun toggleFutureItems(showFutureItems: Boolean) {
toggleShowFutureItems.onNext(showFutureItems)
}
fun saveShowFutureItemsToPreferences(showFutureItems: Boolean) =
PreferenceManager.getDefaultSharedPreferences(application).edit {
this.putBoolean(application.getString(R.string.feed_show_future_items_key), showFutureItems)
this.apply()
}
fun getShowFutureItemsFromPreferences() = getShowFutureItemsFromPreferences(application)
companion object {
private fun getShowPlayedItemsFromPreferences(context: Context) =
PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(context.getString(R.string.feed_show_played_items_key), true)
}
class Factory(
private val context: Context,
private val groupId: Long = FeedGroupEntity.GROUP_ALL_ID
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
return FeedViewModel(
context.applicationContext,
groupId,
// Read initial value from preferences
getShowPlayedItemsFromPreferences(context.applicationContext)
) as T
private fun getShowFutureItemsFromPreferences(context: Context) =
PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(context.getString(R.string.feed_show_future_items_key), true)
fun getFactory(context: Context, groupId: Long) = viewModelFactory {
initializer {
FeedViewModel(
App.getApp(),
groupId,
// Read initial value from preferences
getShowPlayedItemsFromPreferences(context.applicationContext),
getShowFutureItemsFromPreferences(context.applicationContext)
)
}
}
}
}

View File

@ -4,6 +4,8 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.provider.Settings
@ -11,6 +13,8 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import com.squareup.picasso.Picasso
import com.squareup.picasso.Target
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.service.FeedUpdateInfo
@ -27,6 +31,8 @@ class NotificationHelper(val context: Context) {
Context.NOTIFICATION_SERVICE
) as NotificationManager
private val iconLoadingTargets = ArrayList<Target>()
/**
* Show a notification about new streams from a single channel.
* Opening the notification will open the corresponding channel page.
@ -77,10 +83,29 @@ class NotificationHelper(val context: Context) {
)
)
PicassoHelper.loadNotificationIcon(data.avatarUrl) { bitmap ->
bitmap?.let { builder.setLargeIcon(it) } // set only if != null
manager.notify(data.pseudoId, builder.build())
// a Target is like a listener for image loading events
val target = object : Target {
override fun onBitmapLoaded(bitmap: Bitmap, from: Picasso.LoadedFrom) {
builder.setLargeIcon(bitmap) // set only if there is actually one
manager.notify(data.pseudoId, builder.build())
iconLoadingTargets.remove(this) // allow it to be garbage-collected
}
override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) {
manager.notify(data.pseudoId, builder.build())
iconLoadingTargets.remove(this) // allow it to be garbage-collected
}
override fun onPrepareLoad(placeHolderDrawable: Drawable) {
// Nothing to do
}
}
// add the target to the list to hold a strong reference and prevent it from being garbage
// collected, since Picasso only holds weak references to targets
iconLoadingTargets.add(target)
PicassoHelper.loadNotificationIcon(data.avatarUrl).into(target)
}
companion object {

View File

@ -28,7 +28,6 @@ import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.AppDatabase;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.feed.dao.FeedDAO;
import org.schabi.newpipe.database.history.dao.SearchHistoryDAO;
import org.schabi.newpipe.database.history.dao.StreamHistoryDAO;
import org.schabi.newpipe.database.history.model.SearchHistoryEntry;
@ -51,7 +50,6 @@ import org.schabi.newpipe.util.ExtractorHelper;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import io.reactivex.rxjava3.core.Completable;
@ -89,7 +87,6 @@ public class HistoryRecordManager {
* Marks a stream item as watched such that it is hidden from the feed if watched videos are
* hidden. Adds a history entry and updates the stream progress to 100%.
*
* @see FeedDAO#getLiveOrNotPlayedStreams
* @see FeedViewModel#togglePlayedItems
* @param info the item to mark as watched
* @return a Maybe containing the ID of the item if successful
@ -176,10 +173,6 @@ public class HistoryRecordManager {
.subscribeOn(Schedulers.io());
}
public Flowable<List<StreamHistoryEntry>> getStreamHistory() {
return streamHistoryTable.getHistory().subscribeOn(Schedulers.io());
}
public Flowable<List<StreamHistoryEntry>> getStreamHistorySortedById() {
return streamHistoryTable.getHistorySortedById().subscribeOn(Schedulers.io());
}
@ -188,24 +181,6 @@ public class HistoryRecordManager {
return streamHistoryTable.getStatistics().subscribeOn(Schedulers.io());
}
public Single<List<Long>> insertStreamHistory(final Collection<StreamHistoryEntry> entries) {
final List<StreamHistoryEntity> entities = new ArrayList<>(entries.size());
for (final StreamHistoryEntry entry : entries) {
entities.add(entry.toStreamHistoryEntity());
}
return Single.fromCallable(() -> streamHistoryTable.insertAll(entities))
.subscribeOn(Schedulers.io());
}
public Single<Integer> deleteStreamHistory(final Collection<StreamHistoryEntry> entries) {
final List<StreamHistoryEntity> entities = new ArrayList<>(entries.size());
for (final StreamHistoryEntry entry : entries) {
entities.add(entry.toStreamHistoryEntity());
}
return Single.fromCallable(() -> streamHistoryTable.delete(entities))
.subscribeOn(Schedulers.io());
}
private boolean isStreamHistoryEnabled() {
return sharedPreferences.getBoolean(streamHistoryKey, false);
}
@ -259,13 +234,6 @@ public class HistoryRecordManager {
// Stream State History
///////////////////////////////////////////////////////
public Maybe<StreamHistoryEntity> getStreamHistory(final StreamInfo info) {
return Maybe.fromCallable(() -> {
final long streamId = streamTable.upsert(new StreamEntity(info));
return streamHistoryTable.getLatestEntry(streamId);
}).subscribeOn(Schedulers.io());
}
public Maybe<StreamStateEntity> loadStreamState(final PlayQueueItem queueItem) {
return queueItem.getStream()
.map(info -> streamTable.upsert(new StreamEntity(info)))
@ -311,28 +279,6 @@ public class HistoryRecordManager {
}).subscribeOn(Schedulers.io());
}
public Single<List<StreamStateEntity>> loadStreamStateBatch(final List<InfoItem> infos) {
return Single.fromCallable(() -> {
final List<StreamStateEntity> result = new ArrayList<>(infos.size());
for (final InfoItem info : infos) {
final List<StreamEntity> entities = streamTable
.getStream(info.getServiceId(), info.getUrl()).blockingFirst();
if (entities.isEmpty()) {
result.add(null);
continue;
}
final List<StreamStateEntity> states = streamStateTable
.getState(entities.get(0).getUid()).blockingFirst();
if (states.isEmpty()) {
result.add(null);
} else {
result.add(states.get(0));
}
}
return result;
}).subscribeOn(Schedulers.io());
}
public Single<List<StreamStateEntity>> loadLocalStreamStateBatch(
final List<? extends LocalItem> items) {
return Single.fromCallable(() -> {

View File

@ -135,7 +135,7 @@ public class StatisticsPlaylistFragment
protected void initListeners() {
super.initListeners();
itemListAdapter.setSelectedListener(new OnClickGesture<LocalItem>() {
itemListAdapter.setSelectedListener(new OnClickGesture<>() {
@Override
public void selected(final LocalItem selectedItem) {
if (selectedItem instanceof StreamStatisticsEntry) {

View File

@ -1,5 +1,6 @@
package org.schabi.newpipe.local.playlist;
import static org.schabi.newpipe.error.ErrorUtil.showUiErrorSnackbar;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
@ -41,15 +42,16 @@ import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.player.MainPlayer.PlayerType;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.ArrayList;
import java.util.Collections;
@ -57,10 +59,12 @@ import java.util.Iterator;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
import icepick.State;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
@ -163,7 +167,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
itemTouchHelper = new ItemTouchHelper(getItemTouchCallback());
itemTouchHelper.attachToRecyclerView(itemsList);
itemListAdapter.setSelectedListener(new OnClickGesture<LocalItem>() {
itemListAdapter.setSelectedListener(new OnClickGesture<>() {
@Override
public void selected(final LocalItem selectedItem) {
if (selectedItem instanceof PlaylistStreamEntry) {
@ -345,7 +349,11 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
@Override
public boolean onOptionsItemSelected(final MenuItem item) {
if (item.getItemId() == R.id.menu_item_remove_watched) {
if (item.getItemId() == R.id.menu_item_share_playlist) {
sharePlaylist();
} else if (item.getItemId() == R.id.menu_item_rename_playlist) {
createRenameDialog();
} else if (item.getItemId() == R.id.menu_item_remove_watched) {
if (!isRemovingWatched) {
new AlertDialog.Builder(requireContext())
.setMessage(R.string.remove_watched_popup_warning)
@ -360,14 +368,26 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
.create()
.show();
}
} else if (item.getItemId() == R.id.menu_item_rename_playlist) {
createRenameDialog();
} else {
return super.onOptionsItemSelected(item);
}
return true;
}
/**
* Share the playlist as a newline-separated list of stream URLs.
*/
public void sharePlaylist() {
disposables.add(playlistManager.getPlaylistStreams(playlistId)
.flatMapSingle(playlist -> Single.just(playlist.stream()
.map(PlaylistStreamEntry::getStreamEntity)
.map(StreamEntity::getUrl)
.collect(Collectors.joining("\n"))))
.observeOn(AndroidSchedulers.mainThread())
.subscribe(urlsText -> ShareUtils.shareText(requireContext(), name, urlsText),
throwable -> showUiErrorSnackbar(this, "Sharing playlist", throwable)));
}
public void removeWatchedStreams(final boolean removePartiallyWatched) {
if (isRemovingWatched) {
return;
@ -382,8 +402,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
final Iterator<PlaylistStreamEntry> playlistIter = playlist.iterator();
// History data
final HistoryRecordManager recordManager
= new HistoryRecordManager(getContext());
final HistoryRecordManager recordManager =
new HistoryRecordManager(getContext());
final Iterator<StreamHistoryEntry> historyIter = recordManager
.getStreamHistorySortedById().blockingFirst().iterator();
@ -524,8 +544,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
return;
}
final DialogEditTextBinding dialogBinding
= DialogEditTextBinding.inflate(getLayoutInflater());
final DialogEditTextBinding dialogBinding =
DialogEditTextBinding.inflate(getLayoutInflater());
dialogBinding.dialogEditText.setHint(R.string.name);
dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT);
dialogBinding.dialogEditText.setSelection(dialogBinding.dialogEditText.getText().length());
@ -593,7 +613,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
newThumbnailUrl = ((PlaylistStreamEntry) itemListAdapter.getItemsList().get(0))
.getStreamEntity().getThumbnailUrl();
} else {
newThumbnailUrl = "drawable://" + R.drawable.dummy_thumbnail_playlist;
newThumbnailUrl = "drawable://" + R.drawable.placeholder_thumbnail_playlist;
}
changeThumbnailUrl(newThumbnailUrl);

View File

@ -346,7 +346,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
override fun doInitialLoadLogic() = Unit
override fun startLoading(forceLoad: Boolean) = Unit
private val listenerFeedGroups = object : OnClickGesture<Item<*>>() {
private val listenerFeedGroups = object : OnClickGesture<Item<*>> {
override fun selected(selectedItem: Item<*>?) {
when (selectedItem) {
is FeedGroupCardItem -> NavigationHelper.openFeedFragment(fm, selectedItem.groupId, selectedItem.name)
@ -361,7 +361,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
}
}
private val listenerChannelItem = object : OnClickGesture<ChannelInfoItem>() {
private val listenerChannelItem = object : OnClickGesture<ChannelInfoItem> {
override fun selected(selectedItem: ChannelInfoItem) = NavigationHelper.openChannelFragment(
fm,
selectedItem.serviceId, selectedItem.url, selectedItem.name

View File

@ -8,12 +8,10 @@ import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.InputMethodManager
import android.widget.Toast
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.getSystemService
import androidx.core.os.bundleOf
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.widget.ImageViewCompat
import androidx.core.widget.doOnTextChanged
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.Observer
@ -124,14 +122,6 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
_feedGroupCreateBinding = DialogFeedGroupCreateBinding.bind(view)
_searchLayoutBinding = feedGroupCreateBinding.subscriptionsHeaderSearchContainer
if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.LOLLIPOP) {
// KitKat doesn't apply container's theme to <include> content
val contrastColor = AppCompatResources.getColorStateList(requireContext(), R.color.contrastColor)
searchLayoutBinding.toolbarSearchEditText.setTextColor(contrastColor)
searchLayoutBinding.toolbarSearchEditText.setHintTextColor(contrastColor.withAlpha(128))
ImageViewCompat.setImageTintList(searchLayoutBinding.toolbarSearchClearIcon, contrastColor)
}
viewModel = ViewModelProvider(
this,
FeedGroupDialogViewModel.Factory(

View File

@ -122,7 +122,7 @@ class FeedGroupDialogViewModel(
private val initialShowOnlyUngrouped: Boolean = false
) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return FeedGroupDialogViewModel(
context.applicationContext,
groupId, initialQuery, initialShowOnlyUngrouped

View File

@ -39,7 +39,7 @@ class ChannelItem(
itemChannelDescriptionView.text = infoItem.description
}
PicassoHelper.loadThumbnail(infoItem.thumbnailUrl).into(itemThumbnailView)
PicassoHelper.loadAvatar(infoItem.thumbnailUrl).into(itemThumbnailView)
gesturesListener?.run {
viewHolder.root.setOnClickListener { selected(infoItem) }

View File

@ -19,6 +19,8 @@
package org.schabi.newpipe.local.subscription.services;
import static org.schabi.newpipe.MainActivity.DEBUG;
import android.content.Intent;
import android.net.Uri;
import android.util.Log;
@ -43,8 +45,6 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.functions.Function;
import io.reactivex.rxjava3.schedulers.Schedulers;
import static org.schabi.newpipe.MainActivity.DEBUG;
public class SubscriptionsExportService extends BaseImportExportService {
public static final String KEY_FILE_PATH = "key_file_path";
@ -109,8 +109,8 @@ public class SubscriptionsExportService extends BaseImportExportService {
subscriptionManager.subscriptionTable().getAll().take(1)
.map(subscriptionEntities -> {
final List<SubscriptionItem> result
= new ArrayList<>(subscriptionEntities.size());
final List<SubscriptionItem> result =
new ArrayList<>(subscriptionEntities.size());
for (final SubscriptionEntity entity : subscriptionEntities) {
result.add(new SubscriptionItem(entity.getServiceId(), entity.getUrl(),
entity.getName()));

View File

@ -1,259 +0,0 @@
/*
* Copyright 2017 Mauricio Colli <mauriciocolli@outlook.com>
* Part of NewPipe
*
* License: GPL-3.0+
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe.player;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Binder;
import android.os.IBinder;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import org.schabi.newpipe.App;
import org.schabi.newpipe.databinding.PlayerBinding;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ThemeHelper;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
/**
* One service for all players.
*
* @author mauriciocolli
*/
public final class MainPlayer extends Service {
private static final String TAG = "MainPlayer";
private static final boolean DEBUG = Player.DEBUG;
private Player player;
private WindowManager windowManager;
private final IBinder mBinder = new MainPlayer.LocalBinder();
public enum PlayerType {
VIDEO,
AUDIO,
POPUP
}
/*//////////////////////////////////////////////////////////////////////////
// Notification
//////////////////////////////////////////////////////////////////////////*/
static final String ACTION_CLOSE
= App.PACKAGE_NAME + ".player.MainPlayer.CLOSE";
static final String ACTION_PLAY_PAUSE
= App.PACKAGE_NAME + ".player.MainPlayer.PLAY_PAUSE";
static final String ACTION_REPEAT
= App.PACKAGE_NAME + ".player.MainPlayer.REPEAT";
static final String ACTION_PLAY_NEXT
= App.PACKAGE_NAME + ".player.MainPlayer.ACTION_PLAY_NEXT";
static final String ACTION_PLAY_PREVIOUS
= App.PACKAGE_NAME + ".player.MainPlayer.ACTION_PLAY_PREVIOUS";
static final String ACTION_FAST_REWIND
= App.PACKAGE_NAME + ".player.MainPlayer.ACTION_FAST_REWIND";
static final String ACTION_FAST_FORWARD
= App.PACKAGE_NAME + ".player.MainPlayer.ACTION_FAST_FORWARD";
static final String ACTION_SHUFFLE
= App.PACKAGE_NAME + ".player.MainPlayer.ACTION_SHUFFLE";
public static final String ACTION_RECREATE_NOTIFICATION
= App.PACKAGE_NAME + ".player.MainPlayer.ACTION_RECREATE_NOTIFICATION";
/*//////////////////////////////////////////////////////////////////////////
// Service's LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreate() {
if (DEBUG) {
Log.d(TAG, "onCreate() called");
}
assureCorrectAppLanguage(this);
windowManager = ContextCompat.getSystemService(this, WindowManager.class);
ThemeHelper.setTheme(this);
createView();
}
private void createView() {
final PlayerBinding binding = PlayerBinding.inflate(LayoutInflater.from(this));
player = new Player(this);
player.setupFromView(binding);
NotificationUtil.getInstance().createNotificationAndStartForeground(player, this);
}
@Override
public int onStartCommand(final Intent intent, final int flags, final int startId) {
if (DEBUG) {
Log.d(TAG, "onStartCommand() called with: intent = [" + intent
+ "], flags = [" + flags + "], startId = [" + startId + "]");
}
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
&& player.getPlayQueue() == null) {
// Player is not working, no need to process media button's action
return START_NOT_STICKY;
}
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
|| intent.getStringExtra(Player.PLAY_QUEUE_KEY) != null) {
NotificationUtil.getInstance().createNotificationAndStartForeground(player, this);
}
player.handleIntent(intent);
if (player.getMediaSessionManager() != null) {
player.getMediaSessionManager().handleMediaButtonIntent(intent);
}
return START_NOT_STICKY;
}
public void stopForImmediateReusing() {
if (DEBUG) {
Log.d(TAG, "stopForImmediateReusing() called");
}
if (!player.exoPlayerIsNull()) {
player.saveWasPlaying();
// Releases wifi & cpu, disables keepScreenOn, etc.
// We can't just pause the player here because it will make transition
// from one stream to a new stream not smooth
player.smoothStopPlayer();
player.setRecovery();
// Android TV will handle back button in case controls will be visible
// (one more additional unneeded click while the player is hidden)
player.hideControls(0, 0);
player.closeItemsList();
// Notification shows information about old stream but if a user selects
// a stream from backStack it's not actual anymore
// So we should hide the notification at all.
// When autoplay enabled such notification flashing is annoying so skip this case
}
}
@Override
public void onTaskRemoved(final Intent rootIntent) {
super.onTaskRemoved(rootIntent);
if (!player.videoPlayerSelected()) {
return;
}
onDestroy();
// Unload from memory completely
Runtime.getRuntime().halt(0);
}
@Override
public void onDestroy() {
if (DEBUG) {
Log.d(TAG, "destroy() called");
}
cleanup();
}
private void cleanup() {
if (player != null) {
// Exit from fullscreen when user closes the player via notification
if (player.isFullscreen()) {
player.toggleFullscreen();
}
removeViewFromParent();
player.saveStreamProgressState();
player.setRecovery();
player.stopActivityBinding();
player.removePopupFromView();
player.destroy();
player = null;
}
}
public void stopService() {
NotificationUtil.getInstance().cancelNotificationAndStopForeground(this);
cleanup();
stopSelf();
}
@Override
protected void attachBaseContext(final Context base) {
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base));
}
@Override
public IBinder onBind(final Intent intent) {
return mBinder;
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
boolean isLandscape() {
// DisplayMetrics from activity context knows about MultiWindow feature
// while DisplayMetrics from app context doesn't
return DeviceUtils.isLandscape(player != null && player.getParentActivity() != null
? player.getParentActivity() : this);
}
@Nullable
public View getView() {
if (player == null) {
return null;
}
return player.getRootView();
}
public void removeViewFromParent() {
if (getView() != null && getView().getParent() != null) {
if (player.getParentActivity() != null) {
// This means view was added to fragment
final ViewGroup parent = (ViewGroup) getView().getParent();
parent.removeView(getView());
} else {
// This means view was added by windowManager for popup player
windowManager.removeViewImmediate(getView());
}
}
}
public class LocalBinder extends Binder {
public MainPlayer getService() {
return MainPlayer.this;
}
public Player getPlayer() {
return MainPlayer.this.player;
}
}
}

View File

@ -29,6 +29,7 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.ActivityPlayerQueueControlBinding;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.player.event.PlayerEventListener;
import org.schabi.newpipe.player.helper.PlaybackParameterDialog;
import org.schabi.newpipe.player.playqueue.PlayQueue;
@ -51,7 +52,7 @@ public final class PlayQueueActivity extends AppCompatActivity
private static final int SMOOTH_SCROLL_MAXIMUM_DISTANCE = 80;
protected Player player;
private Player player;
private boolean serviceBound;
private ServiceConnection serviceConnection;
@ -126,13 +127,13 @@ public final class PlayQueueActivity extends AppCompatActivity
NavigationHelper.openSettings(this);
return true;
case R.id.action_append_playlist:
player.onAddToPlaylistClicked(getSupportFragmentManager());
PlaylistDialog.showForPlayQueue(player, getSupportFragmentManager());
return true;
case R.id.action_playback_speed:
openPlaybackParameterDialog();
return true;
case R.id.action_mute:
player.onMuteUnmuteButtonClicked();
player.toggleMute();
return true;
case R.id.action_system_audio:
startActivity(new Intent(Settings.ACTION_SOUND_SETTINGS));
@ -168,7 +169,7 @@ public final class PlayQueueActivity extends AppCompatActivity
////////////////////////////////////////////////////////////////////////////
private void bind() {
final Intent bindIntent = new Intent(this, MainPlayer.class);
final Intent bindIntent = new Intent(this, PlayerService.class);
final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE);
if (!success) {
unbindService(serviceConnection);
@ -184,10 +185,7 @@ public final class PlayQueueActivity extends AppCompatActivity
player.removeActivityListener(this);
}
if (player != null && player.getPlayQueueAdapter() != null) {
player.getPlayQueueAdapter().unsetSelectedListener();
}
queueControlBinding.playQueue.setAdapter(null);
onQueueUpdate(null);
if (itemTouchHelper != null) {
itemTouchHelper.attachToRecyclerView(null);
}
@ -208,17 +206,15 @@ public final class PlayQueueActivity extends AppCompatActivity
public void onServiceConnected(final ComponentName name, final IBinder service) {
Log.d(TAG, "Player service is connected");
if (service instanceof PlayerServiceBinder) {
player = ((PlayerServiceBinder) service).getPlayerInstance();
} else if (service instanceof MainPlayer.LocalBinder) {
player = ((MainPlayer.LocalBinder) service).getPlayer();
if (service instanceof PlayerService.LocalBinder) {
player = ((PlayerService.LocalBinder) service).getPlayer();
}
if (player == null || player.getPlayQueue() == null
|| player.getPlayQueueAdapter() == null || player.exoPlayerIsNull()) {
if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) {
unbind();
finish();
} else {
onQueueUpdate(player.getPlayQueue());
buildComponents();
if (player != null) {
player.setActivityListener(PlayQueueActivity.this);
@ -241,7 +237,6 @@ public final class PlayQueueActivity extends AppCompatActivity
private void buildQueue() {
queueControlBinding.playQueue.setLayoutManager(new LinearLayoutManager(this));
queueControlBinding.playQueue.setAdapter(player.getPlayQueueAdapter());
queueControlBinding.playQueue.setClickable(true);
queueControlBinding.playQueue.setLongClickable(true);
queueControlBinding.playQueue.clearOnScrollListeners();
@ -249,8 +244,6 @@ public final class PlayQueueActivity extends AppCompatActivity
itemTouchHelper = new ItemTouchHelper(getItemTouchCallback());
itemTouchHelper.attachToRecyclerView(queueControlBinding.playQueue);
player.getPlayQueueAdapter().setSelectedListener(getOnSelectedListener());
}
private void buildMetadata() {
@ -370,7 +363,7 @@ public final class PlayQueueActivity extends AppCompatActivity
}
if (view.getId() == queueControlBinding.controlRepeat.getId()) {
player.onRepeatClicked();
player.cycleNextRepeatMode();
} else if (view.getId() == queueControlBinding.controlBackward.getId()) {
player.playPrevious();
} else if (view.getId() == queueControlBinding.controlFastRewind.getId()) {
@ -382,7 +375,7 @@ public final class PlayQueueActivity extends AppCompatActivity
} else if (view.getId() == queueControlBinding.controlForward.getId()) {
player.playNext();
} else if (view.getId() == queueControlBinding.controlShuffle.getId()) {
player.onShuffleClicked();
player.toggleShuffleModeEnabled();
} else if (view.getId() == queueControlBinding.metadata.getId()) {
scrollToSelected();
} else if (view.getId() == queueControlBinding.liveSync.getId()) {
@ -445,7 +438,14 @@ public final class PlayQueueActivity extends AppCompatActivity
////////////////////////////////////////////////////////////////////////////
@Override
public void onQueueUpdate(final PlayQueue queue) {
public void onQueueUpdate(@Nullable final PlayQueue queue) {
if (queue == null) {
queueControlBinding.playQueue.setAdapter(null);
} else {
final PlayQueueAdapter adapter = new PlayQueueAdapter(this, queue);
adapter.setSelectedListener(getOnSelectedListener());
queueControlBinding.playQueue.setAdapter(adapter);
}
}
@Override
@ -454,7 +454,6 @@ public final class PlayQueueActivity extends AppCompatActivity
onStateChanged(state);
onPlayModeChanged(repeatMode, shuffled);
onPlaybackParameterChanged(parameters);
onMaybePlaybackAdapterChanged();
onMaybeMuteChanged();
}
@ -582,17 +581,6 @@ public final class PlayQueueActivity extends AppCompatActivity
}
}
private void onMaybePlaybackAdapterChanged() {
if (player == null) {
return;
}
final PlayQueueAdapter maybeNewAdapter = player.getPlayQueueAdapter();
if (maybeNewAdapter != null
&& queueControlBinding.playQueue.getAdapter() != maybeNewAdapter) {
queueControlBinding.playQueue.setAdapter(maybeNewAdapter);
}
}
private void onMaybeMuteChanged() {
if (menu != null && player != null) {
final MenuItem item = menu.findItem(R.id.action_mute);

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,149 @@
/*
* Copyright 2017 Mauricio Colli <mauriciocolli@outlook.com>
* Part of NewPipe
*
* License: GPL-3.0+
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe.player;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
import android.os.Binder;
import android.os.IBinder;
import android.util.Log;
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
import org.schabi.newpipe.util.ThemeHelper;
/**
* One service for all players.
*/
public final class PlayerService extends Service {
private static final String TAG = PlayerService.class.getSimpleName();
private static final boolean DEBUG = Player.DEBUG;
private Player player;
private final IBinder mBinder = new PlayerService.LocalBinder();
/*//////////////////////////////////////////////////////////////////////////
// Service's LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreate() {
if (DEBUG) {
Log.d(TAG, "onCreate() called");
}
assureCorrectAppLanguage(this);
ThemeHelper.setTheme(this);
player = new Player(this);
}
@Override
public int onStartCommand(final Intent intent, final int flags, final int startId) {
if (DEBUG) {
Log.d(TAG, "onStartCommand() called with: intent = [" + intent
+ "], flags = [" + flags + "], startId = [" + startId + "]");
}
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
&& player.getPlayQueue() == null) {
// No need to process media button's actions if the player is not working, otherwise the
// player service would strangely start with nothing to play
return START_NOT_STICKY;
}
player.handleIntent(intent);
player.UIs().get(MediaSessionPlayerUi.class)
.ifPresent(ui -> ui.handleMediaButtonIntent(intent));
return START_NOT_STICKY;
}
public void stopForImmediateReusing() {
if (DEBUG) {
Log.d(TAG, "stopForImmediateReusing() called");
}
if (!player.exoPlayerIsNull()) {
player.saveWasPlaying();
// Releases wifi & cpu, disables keepScreenOn, etc.
// We can't just pause the player here because it will make transition
// from one stream to a new stream not smooth
player.smoothStopForImmediateReusing();
}
}
@Override
public void onTaskRemoved(final Intent rootIntent) {
super.onTaskRemoved(rootIntent);
if (!player.videoPlayerSelected()) {
return;
}
onDestroy();
// Unload from memory completely
Runtime.getRuntime().halt(0);
}
@Override
public void onDestroy() {
if (DEBUG) {
Log.d(TAG, "destroy() called");
}
cleanup();
}
private void cleanup() {
if (player != null) {
player.destroy();
player = null;
}
}
public void stopService() {
cleanup();
stopSelf();
}
@Override
protected void attachBaseContext(final Context base) {
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base));
}
@Override
public IBinder onBind(final Intent intent) {
return mBinder;
}
public class LocalBinder extends Binder {
public PlayerService getService() {
return PlayerService.this;
}
public Player getPlayer() {
return PlayerService.this.player;
}
}
}

View File

@ -1,17 +0,0 @@
package org.schabi.newpipe.player;
import android.os.Binder;
import androidx.annotation.NonNull;
class PlayerServiceBinder extends Binder {
private final Player player;
PlayerServiceBinder(@NonNull final Player player) {
this.player = player;
}
Player getPlayerInstance() {
return player;
}
}

View File

@ -1,79 +0,0 @@
package org.schabi.newpipe.player;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import java.io.Serializable;
public class PlayerState implements Serializable {
@NonNull
private final PlayQueue playQueue;
private final int repeatMode;
private final float playbackSpeed;
private final float playbackPitch;
@Nullable
private final String playbackQuality;
private final boolean playbackSkipSilence;
private final boolean wasPlaying;
PlayerState(@NonNull final PlayQueue playQueue, final int repeatMode,
final float playbackSpeed, final float playbackPitch,
final boolean playbackSkipSilence, final boolean wasPlaying) {
this(playQueue, repeatMode, playbackSpeed, playbackPitch, null,
playbackSkipSilence, wasPlaying);
}
PlayerState(@NonNull final PlayQueue playQueue, final int repeatMode,
final float playbackSpeed, final float playbackPitch,
@Nullable final String playbackQuality, final boolean playbackSkipSilence,
final boolean wasPlaying) {
this.playQueue = playQueue;
this.repeatMode = repeatMode;
this.playbackSpeed = playbackSpeed;
this.playbackPitch = playbackPitch;
this.playbackQuality = playbackQuality;
this.playbackSkipSilence = playbackSkipSilence;
this.wasPlaying = wasPlaying;
}
/*//////////////////////////////////////////////////////////////////////////
// Serdes
//////////////////////////////////////////////////////////////////////////*/
/*//////////////////////////////////////////////////////////////////////////
// Getters
//////////////////////////////////////////////////////////////////////////*/
@NonNull
public PlayQueue getPlayQueue() {
return playQueue;
}
public int getRepeatMode() {
return repeatMode;
}
public float getPlaybackSpeed() {
return playbackSpeed;
}
public float getPlaybackPitch() {
return playbackPitch;
}
@Nullable
public String getPlaybackQuality() {
return playbackQuality;
}
public boolean isPlaybackSkipSilence() {
return playbackSkipSilence;
}
public boolean wasPlaying() {
return wasPlaying;
}
}

View File

@ -0,0 +1,32 @@
package org.schabi.newpipe.player;
import static org.schabi.newpipe.player.Player.PLAYER_TYPE;
import android.content.Intent;
public enum PlayerType {
MAIN,
AUDIO,
POPUP;
/**
* @return an integer representing this {@link PlayerType}, to be used to save it in intents
* @see #retrieveFromIntent(Intent) Use retrieveFromIntent() to retrieve and convert player type
* integers from an intent
*/
public int valueForIntent() {
return ordinal();
}
/**
* @param intent the intent to retrieve a player type from
* @return the player type integer retrieved from the intent, converted back into a {@link
* PlayerType}, or {@link PlayerType#MAIN} if there is no player type extra in the
* intent
* @throws ArrayIndexOutOfBoundsException if the intent contains an invalid player type integer
* @see #valueForIntent() Use valueForIntent() to obtain valid player type integers
*/
public static PlayerType retrieveFromIntent(final Intent intent) {
return values()[intent.getIntExtra(PLAYER_TYPE, MAIN.valueForIntent())];
}
}

View File

@ -1,5 +1,5 @@
/*
* Based on ExoPlayer's DefaultHttpDataSource, version 2.17.1.
* Based on ExoPlayer's DefaultHttpDataSource, version 2.18.1.
*
* Original source code copyright (C) 2016 The Android Open Source Project, licensed under the
* Apache License, Version 2.0.

View File

@ -1,520 +0,0 @@
package org.schabi.newpipe.player.event
import android.content.Context
import android.os.Handler
import android.util.Log
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.player.MainPlayer
import org.schabi.newpipe.player.Player
import org.schabi.newpipe.player.helper.PlayerHelper
import org.schabi.newpipe.player.helper.PlayerHelper.savePopupPositionAndSizeToPrefs
import kotlin.math.abs
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.min
/**
* Base gesture handling for [Player]
*
* This class contains the logic for the player gestures like View preparations
* and provides some abstract methods to make it easier separating the logic from the UI.
*/
abstract class BasePlayerGestureListener(
@JvmField
protected val player: Player,
@JvmField
protected val service: MainPlayer
) : GestureDetector.SimpleOnGestureListener(), View.OnTouchListener {
// ///////////////////////////////////////////////////////////////////
// Abstract methods for VIDEO and POPUP
// ///////////////////////////////////////////////////////////////////
abstract fun onDoubleTap(event: MotionEvent, portion: DisplayPortion)
abstract fun onSingleTap(playerType: MainPlayer.PlayerType)
abstract fun onScroll(
playerType: MainPlayer.PlayerType,
portion: DisplayPortion,
initialEvent: MotionEvent,
movingEvent: MotionEvent,
distanceX: Float,
distanceY: Float
)
abstract fun onScrollEnd(playerType: MainPlayer.PlayerType, event: MotionEvent)
// ///////////////////////////////////////////////////////////////////
// Abstract methods for POPUP (exclusive)
// ///////////////////////////////////////////////////////////////////
abstract fun onPopupResizingStart()
abstract fun onPopupResizingEnd()
private var initialPopupX: Int = -1
private var initialPopupY: Int = -1
private var isMovingInMain = false
private var isMovingInPopup = false
private var isResizing = false
private val tossFlingVelocity = PlayerHelper.getTossFlingVelocity()
// [popup] initial coordinates and distance between fingers
private var initPointerDistance = -1.0
private var initFirstPointerX = -1f
private var initFirstPointerY = -1f
private var initSecPointerX = -1f
private var initSecPointerY = -1f
// ///////////////////////////////////////////////////////////////////
// onTouch implementation
// ///////////////////////////////////////////////////////////////////
override fun onTouch(v: View, event: MotionEvent): Boolean {
return if (player.popupPlayerSelected()) {
onTouchInPopup(v, event)
} else {
onTouchInMain(v, event)
}
}
private fun onTouchInMain(v: View, event: MotionEvent): Boolean {
player.gestureDetector.onTouchEvent(event)
if (event.action == MotionEvent.ACTION_UP && isMovingInMain) {
isMovingInMain = false
onScrollEnd(MainPlayer.PlayerType.VIDEO, event)
}
return when (event.action) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
v.parent.requestDisallowInterceptTouchEvent(player.isFullscreen)
true
}
MotionEvent.ACTION_UP -> {
v.parent.requestDisallowInterceptTouchEvent(false)
false
}
else -> true
}
}
private fun onTouchInPopup(v: View, event: MotionEvent): Boolean {
player.gestureDetector.onTouchEvent(event)
if (event.pointerCount == 2 && !isMovingInPopup && !isResizing) {
if (DEBUG) {
Log.d(TAG, "onTouch() 2 finger pointer detected, enabling resizing.")
}
onPopupResizingStart()
// record coordinates of fingers
initFirstPointerX = event.getX(0)
initFirstPointerY = event.getY(0)
initSecPointerX = event.getX(1)
initSecPointerY = event.getY(1)
// record distance between fingers
initPointerDistance = hypot(
initFirstPointerX - initSecPointerX.toDouble(),
initFirstPointerY - initSecPointerY.toDouble()
)
isResizing = true
}
if (event.action == MotionEvent.ACTION_MOVE && !isMovingInPopup && isResizing) {
if (DEBUG) {
Log.d(
TAG,
"onTouch() ACTION_MOVE > v = [$v], e1.getRaw =" +
"[${event.rawX}, ${event.rawY}]"
)
}
return handleMultiDrag(event)
}
if (event.action == MotionEvent.ACTION_UP) {
if (DEBUG) {
Log.d(
TAG,
"onTouch() ACTION_UP > v = [$v], e1.getRaw =" +
" [${event.rawX}, ${event.rawY}]"
)
}
if (isMovingInPopup) {
isMovingInPopup = false
onScrollEnd(MainPlayer.PlayerType.POPUP, event)
}
if (isResizing) {
isResizing = false
initPointerDistance = (-1).toDouble()
initFirstPointerX = (-1).toFloat()
initFirstPointerY = (-1).toFloat()
initSecPointerX = (-1).toFloat()
initSecPointerY = (-1).toFloat()
onPopupResizingEnd()
player.changeState(player.currentState)
}
if (!player.isPopupClosing) {
savePopupPositionAndSizeToPrefs(player)
}
}
v.performClick()
return true
}
private fun handleMultiDrag(event: MotionEvent): Boolean {
if (initPointerDistance != -1.0 && event.pointerCount == 2) {
// get the movements of the fingers
val firstPointerMove = hypot(
event.getX(0) - initFirstPointerX.toDouble(),
event.getY(0) - initFirstPointerY.toDouble()
)
val secPointerMove = hypot(
event.getX(1) - initSecPointerX.toDouble(),
event.getY(1) - initSecPointerY.toDouble()
)
// minimum threshold beyond which pinch gesture will work
val minimumMove = ViewConfiguration.get(service).scaledTouchSlop
if (max(firstPointerMove, secPointerMove) > minimumMove) {
// calculate current distance between the pointers
val currentPointerDistance = hypot(
event.getX(0) - event.getX(1).toDouble(),
event.getY(0) - event.getY(1).toDouble()
)
val popupWidth = player.popupLayoutParams!!.width.toDouble()
// change co-ordinates of popup so the center stays at the same position
val newWidth = popupWidth * currentPointerDistance / initPointerDistance
initPointerDistance = currentPointerDistance
player.popupLayoutParams!!.x += ((popupWidth - newWidth) / 2.0).toInt()
player.checkPopupPositionBounds()
player.updateScreenSize()
player.changePopupSize(min(player.screenWidth.toDouble(), newWidth).toInt())
return true
}
}
return false
}
// ///////////////////////////////////////////////////////////////////
// Simple gestures
// ///////////////////////////////////////////////////////////////////
override fun onDown(e: MotionEvent): Boolean {
if (DEBUG)
Log.d(TAG, "onDown called with e = [$e]")
if (isDoubleTapping && isDoubleTapEnabled) {
doubleTapControls?.onDoubleTapProgressDown(getDisplayPortion(e))
return true
}
return if (player.popupPlayerSelected())
onDownInPopup(e)
else
true
}
private fun onDownInPopup(e: MotionEvent): Boolean {
// Fix popup position when the user touch it, it may have the wrong one
// because the soft input is visible (the draggable area is currently resized).
player.updateScreenSize()
player.checkPopupPositionBounds()
player.popupLayoutParams?.let {
initialPopupX = it.x
initialPopupY = it.y
}
return super.onDown(e)
}
override fun onDoubleTap(e: MotionEvent): Boolean {
if (DEBUG)
Log.d(TAG, "onDoubleTap called with e = [$e]")
onDoubleTap(e, getDisplayPortion(e))
return true
}
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
if (DEBUG)
Log.d(TAG, "onSingleTapConfirmed() called with: e = [$e]")
if (isDoubleTapping)
return true
if (player.popupPlayerSelected()) {
if (player.exoPlayerIsNull())
return false
onSingleTap(MainPlayer.PlayerType.POPUP)
return true
} else {
super.onSingleTapConfirmed(e)
if (player.currentState == Player.STATE_BLOCKED)
return true
onSingleTap(MainPlayer.PlayerType.VIDEO)
}
return true
}
override fun onLongPress(e: MotionEvent?) {
if (player.popupPlayerSelected()) {
player.updateScreenSize()
player.checkPopupPositionBounds()
player.changePopupSize(player.screenWidth.toInt())
}
}
override fun onScroll(
initialEvent: MotionEvent,
movingEvent: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
return if (player.popupPlayerSelected()) {
onScrollInPopup(initialEvent, movingEvent, distanceX, distanceY)
} else {
onScrollInMain(initialEvent, movingEvent, distanceX, distanceY)
}
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
return if (player.popupPlayerSelected()) {
val absVelocityX = abs(velocityX)
val absVelocityY = abs(velocityY)
if (absVelocityX.coerceAtLeast(absVelocityY) > tossFlingVelocity) {
if (absVelocityX > tossFlingVelocity) {
player.popupLayoutParams!!.x = velocityX.toInt()
}
if (absVelocityY > tossFlingVelocity) {
player.popupLayoutParams!!.y = velocityY.toInt()
}
player.checkPopupPositionBounds()
player.windowManager!!.updateViewLayout(player.rootView, player.popupLayoutParams)
return true
}
return false
} else {
true
}
}
private fun onScrollInMain(
initialEvent: MotionEvent,
movingEvent: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
if (!player.isFullscreen) {
return false
}
val isTouchingStatusBar: Boolean = initialEvent.y < getStatusBarHeight(service)
val isTouchingNavigationBar: Boolean =
initialEvent.y > (player.rootView.height - getNavigationBarHeight(service))
if (isTouchingStatusBar || isTouchingNavigationBar) {
return false
}
val insideThreshold = abs(movingEvent.y - initialEvent.y) <= MOVEMENT_THRESHOLD
if (
!isMovingInMain && (insideThreshold || abs(distanceX) > abs(distanceY)) ||
player.currentState == Player.STATE_COMPLETED
) {
return false
}
isMovingInMain = true
onScroll(
MainPlayer.PlayerType.VIDEO,
getDisplayHalfPortion(initialEvent),
initialEvent,
movingEvent,
distanceX,
distanceY
)
return true
}
private fun onScrollInPopup(
initialEvent: MotionEvent,
movingEvent: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
if (isResizing) {
return super.onScroll(initialEvent, movingEvent, distanceX, distanceY)
}
if (!isMovingInPopup) {
player.closeOverlayButton.animate(true, 200)
}
isMovingInPopup = true
val diffX: Float = (movingEvent.rawX - initialEvent.rawX)
var posX: Float = (initialPopupX + diffX)
val diffY: Float = (movingEvent.rawY - initialEvent.rawY)
var posY: Float = (initialPopupY + diffY)
if (posX > player.screenWidth - player.popupLayoutParams!!.width) {
posX = (player.screenWidth - player.popupLayoutParams!!.width)
} else if (posX < 0) {
posX = 0f
}
if (posY > player.screenHeight - player.popupLayoutParams!!.height) {
posY = (player.screenHeight - player.popupLayoutParams!!.height)
} else if (posY < 0) {
posY = 0f
}
player.popupLayoutParams!!.x = posX.toInt()
player.popupLayoutParams!!.y = posY.toInt()
onScroll(
MainPlayer.PlayerType.POPUP,
getDisplayHalfPortion(initialEvent),
initialEvent,
movingEvent,
distanceX,
distanceY
)
player.windowManager!!.updateViewLayout(player.rootView, player.popupLayoutParams)
return true
}
// ///////////////////////////////////////////////////////////////////
// Multi double tapping
// ///////////////////////////////////////////////////////////////////
var doubleTapControls: DoubleTapListener? = null
private set
private val isDoubleTapEnabled: Boolean
get() = doubleTapDelay > 0
var isDoubleTapping = false
private set
fun doubleTapControls(listener: DoubleTapListener) = apply {
doubleTapControls = listener
}
private var doubleTapDelay = DOUBLE_TAP_DELAY
private val doubleTapHandler: Handler = Handler()
private val doubleTapRunnable = Runnable {
if (DEBUG)
Log.d(TAG, "doubleTapRunnable called")
isDoubleTapping = false
doubleTapControls?.onDoubleTapFinished()
}
fun startMultiDoubleTap(e: MotionEvent) {
if (!isDoubleTapping) {
if (DEBUG)
Log.d(TAG, "startMultiDoubleTap called with e = [$e]")
keepInDoubleTapMode()
doubleTapControls?.onDoubleTapStarted(getDisplayPortion(e))
}
}
fun keepInDoubleTapMode() {
if (DEBUG)
Log.d(TAG, "keepInDoubleTapMode called")
isDoubleTapping = true
doubleTapHandler.removeCallbacks(doubleTapRunnable)
doubleTapHandler.postDelayed(doubleTapRunnable, doubleTapDelay)
}
fun endMultiDoubleTap() {
if (DEBUG)
Log.d(TAG, "endMultiDoubleTap called")
isDoubleTapping = false
doubleTapHandler.removeCallbacks(doubleTapRunnable)
doubleTapControls?.onDoubleTapFinished()
}
// ///////////////////////////////////////////////////////////////////
// Utils
// ///////////////////////////////////////////////////////////////////
private fun getDisplayPortion(e: MotionEvent): DisplayPortion {
return if (player.playerType == MainPlayer.PlayerType.POPUP && player.popupLayoutParams != null) {
when {
e.x < player.popupLayoutParams!!.width / 3.0 -> DisplayPortion.LEFT
e.x > player.popupLayoutParams!!.width * 2.0 / 3.0 -> DisplayPortion.RIGHT
else -> DisplayPortion.MIDDLE
}
} else /* MainPlayer.PlayerType.VIDEO */ {
when {
e.x < player.rootView.width / 3.0 -> DisplayPortion.LEFT
e.x > player.rootView.width * 2.0 / 3.0 -> DisplayPortion.RIGHT
else -> DisplayPortion.MIDDLE
}
}
}
// Currently needed for scrolling since there is no action more the middle portion
private fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion {
return if (player.playerType == MainPlayer.PlayerType.POPUP) {
when {
e.x < player.popupLayoutParams!!.width / 2.0 -> DisplayPortion.LEFT_HALF
else -> DisplayPortion.RIGHT_HALF
}
} else /* MainPlayer.PlayerType.VIDEO */ {
when {
e.x < player.rootView.width / 2.0 -> DisplayPortion.LEFT_HALF
else -> DisplayPortion.RIGHT_HALF
}
}
}
private fun getNavigationBarHeight(context: Context): Int {
val resId = context.resources
.getIdentifier("navigation_bar_height", "dimen", "android")
return if (resId > 0) {
context.resources.getDimensionPixelSize(resId)
} else 0
}
private fun getStatusBarHeight(context: Context): Int {
val resId = context.resources
.getIdentifier("status_bar_height", "dimen", "android")
return if (resId > 0) {
context.resources.getDimensionPixelSize(resId)
} else 0
}
companion object {
private const val TAG = "BasePlayerGestListener"
private val DEBUG = Player.DEBUG
private const val DOUBLE_TAP_DELAY = 550L
private const val MOVEMENT_THRESHOLD = 40
}
}

View File

@ -1,7 +0,0 @@
package org.schabi.newpipe.player.event
interface DoubleTapListener {
fun onDoubleTapStarted(portion: DisplayPortion) {}
fun onDoubleTapProgressDown(portion: DisplayPortion) {}
fun onDoubleTapFinished() {}
}

View File

@ -1,6 +1,5 @@
package org.schabi.newpipe.player.event;
import com.google.android.exoplayer2.PlaybackParameters;
import org.schabi.newpipe.extractor.stream.StreamInfo;

View File

@ -1,256 +0,0 @@
package org.schabi.newpipe.player.event;
import static org.schabi.newpipe.ktx.AnimationType.ALPHA;
import static org.schabi.newpipe.ktx.AnimationType.SCALE_AND_ALPHA;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.player.Player.DEFAULT_CONTROLS_DURATION;
import static org.schabi.newpipe.player.Player.DEFAULT_CONTROLS_HIDE_TIME;
import static org.schabi.newpipe.player.Player.STATE_PLAYING;
import android.app.Activity;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;
import android.view.Window;
import android.view.WindowManager;
import android.widget.ProgressBar;
import androidx.annotation.NonNull;
import androidx.appcompat.content.res.AppCompatResources;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.player.MainPlayer;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.helper.PlayerHelper;
/**
* GestureListener for the player
*
* While {@link BasePlayerGestureListener} contains the logic behind the single gestures
* this class focuses on the visual aspect like hiding and showing the controls or changing
* volume/brightness during scrolling for specific events.
*/
public class PlayerGestureListener
extends BasePlayerGestureListener
implements View.OnTouchListener {
private static final String TAG = PlayerGestureListener.class.getSimpleName();
private static final boolean DEBUG = MainActivity.DEBUG;
private final int maxVolume;
public PlayerGestureListener(final Player player, final MainPlayer service) {
super(player, service);
maxVolume = player.getAudioReactor().getMaxVolume();
}
@Override
public void onDoubleTap(@NonNull final MotionEvent event,
@NonNull final DisplayPortion portion) {
if (DEBUG) {
Log.d(TAG, "onDoubleTap called with playerType = ["
+ player.getPlayerType() + "], portion = [" + portion + "]");
}
if (player.isSomePopupMenuVisible()) {
player.hideControls(0, 0);
}
if (portion == DisplayPortion.LEFT || portion == DisplayPortion.RIGHT) {
startMultiDoubleTap(event);
} else if (portion == DisplayPortion.MIDDLE) {
player.playPause();
}
}
@Override
public void onSingleTap(@NonNull final MainPlayer.PlayerType playerType) {
if (DEBUG) {
Log.d(TAG, "onSingleTap called with playerType = [" + player.getPlayerType() + "]");
}
if (player.isControlsVisible()) {
player.hideControls(150, 0);
return;
}
// -- Controls are not visible --
// When player is completed show controls and don't hide them later
if (player.getCurrentState() == Player.STATE_COMPLETED) {
player.showControls(0);
} else {
player.showControlsThenHide();
}
}
@Override
public void onScroll(@NonNull final MainPlayer.PlayerType playerType,
@NonNull final DisplayPortion portion,
@NonNull final MotionEvent initialEvent,
@NonNull final MotionEvent movingEvent,
final float distanceX, final float distanceY) {
if (DEBUG) {
Log.d(TAG, "onScroll called with playerType = ["
+ player.getPlayerType() + "], portion = [" + portion + "]");
}
if (playerType == MainPlayer.PlayerType.VIDEO) {
// -- Brightness and Volume control --
final boolean isBrightnessGestureEnabled =
PlayerHelper.isBrightnessGestureEnabled(service);
final boolean isVolumeGestureEnabled = PlayerHelper.isVolumeGestureEnabled(service);
if (isBrightnessGestureEnabled && isVolumeGestureEnabled) {
if (portion == DisplayPortion.LEFT_HALF) {
onScrollMainBrightness(distanceX, distanceY);
} else /* DisplayPortion.RIGHT_HALF */ {
onScrollMainVolume(distanceX, distanceY);
}
} else if (isBrightnessGestureEnabled) {
onScrollMainBrightness(distanceX, distanceY);
} else if (isVolumeGestureEnabled) {
onScrollMainVolume(distanceX, distanceY);
}
} else /* MainPlayer.PlayerType.POPUP */ {
// -- Determine if the ClosingOverlayView (red X) has to be shown or hidden --
final View closingOverlayView = player.getClosingOverlayView();
final boolean showClosingOverlayView = player.isInsideClosingRadius(movingEvent);
// Check if an view is in expected state and if not animate it into the correct state
final int expectedVisibility = showClosingOverlayView ? View.VISIBLE : View.GONE;
if (closingOverlayView.getVisibility() != expectedVisibility) {
animate(closingOverlayView, showClosingOverlayView, 200);
}
}
}
private void onScrollMainVolume(final float distanceX, final float distanceY) {
// If we just started sliding, change the progress bar to match the system volume
if (player.getVolumeRelativeLayout().getVisibility() != View.VISIBLE) {
final float volumePercent = player
.getAudioReactor().getVolume() / (float) maxVolume;
player.getVolumeProgressBar().setProgress(
(int) (volumePercent * player.getMaxGestureLength()));
}
player.getVolumeProgressBar().incrementProgressBy((int) distanceY);
final float currentProgressPercent = (float) player
.getVolumeProgressBar().getProgress() / player.getMaxGestureLength();
final int currentVolume = (int) (maxVolume * currentProgressPercent);
player.getAudioReactor().setVolume(currentVolume);
if (DEBUG) {
Log.d(TAG, "onScroll().volumeControl, currentVolume = " + currentVolume);
}
player.getVolumeImageView().setImageDrawable(
AppCompatResources.getDrawable(service, currentProgressPercent <= 0
? R.drawable.ic_volume_off
: currentProgressPercent < 0.25 ? R.drawable.ic_volume_mute
: currentProgressPercent < 0.75 ? R.drawable.ic_volume_down
: R.drawable.ic_volume_up)
);
if (player.getVolumeRelativeLayout().getVisibility() != View.VISIBLE) {
animate(player.getVolumeRelativeLayout(), true, 200, SCALE_AND_ALPHA);
}
if (player.getBrightnessRelativeLayout().getVisibility() == View.VISIBLE) {
player.getBrightnessRelativeLayout().setVisibility(View.GONE);
}
}
private void onScrollMainBrightness(final float distanceX, final float distanceY) {
final Activity parent = player.getParentActivity();
if (parent == null) {
return;
}
final Window window = parent.getWindow();
final WindowManager.LayoutParams layoutParams = window.getAttributes();
final ProgressBar bar = player.getBrightnessProgressBar();
final float oldBrightness = layoutParams.screenBrightness;
bar.setProgress((int) (bar.getMax() * Math.max(0, Math.min(1, oldBrightness))));
bar.incrementProgressBy((int) distanceY);
final float currentProgressPercent = (float) bar.getProgress() / bar.getMax();
layoutParams.screenBrightness = currentProgressPercent;
window.setAttributes(layoutParams);
// Save current brightness level
PlayerHelper.setScreenBrightness(parent, currentProgressPercent);
if (DEBUG) {
Log.d(TAG, "onScroll().brightnessControl, "
+ "currentBrightness = " + currentProgressPercent);
}
player.getBrightnessImageView().setImageDrawable(
AppCompatResources.getDrawable(service,
currentProgressPercent < 0.25
? R.drawable.ic_brightness_low
: currentProgressPercent < 0.75
? R.drawable.ic_brightness_medium
: R.drawable.ic_brightness_high)
);
if (player.getBrightnessRelativeLayout().getVisibility() != View.VISIBLE) {
animate(player.getBrightnessRelativeLayout(), true, 200, SCALE_AND_ALPHA);
}
if (player.getVolumeRelativeLayout().getVisibility() == View.VISIBLE) {
player.getVolumeRelativeLayout().setVisibility(View.GONE);
}
}
@Override
public void onScrollEnd(@NonNull final MainPlayer.PlayerType playerType,
@NonNull final MotionEvent event) {
if (DEBUG) {
Log.d(TAG, "onScrollEnd called with playerType = ["
+ player.getPlayerType() + "]");
}
if (player.isControlsVisible() && player.getCurrentState() == STATE_PLAYING) {
player.hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME);
}
if (playerType == MainPlayer.PlayerType.VIDEO) {
if (player.getVolumeRelativeLayout().getVisibility() == View.VISIBLE) {
animate(player.getVolumeRelativeLayout(), false, 200, SCALE_AND_ALPHA,
200);
}
if (player.getBrightnessRelativeLayout().getVisibility() == View.VISIBLE) {
animate(player.getBrightnessRelativeLayout(), false, 200, SCALE_AND_ALPHA,
200);
}
} else /* Popup-Player */ {
if (player.isInsideClosingRadius(event)) {
player.closePopup();
} else if (!player.isPopupClosing()) {
animate(player.getCloseOverlayButton(), false, 200);
animate(player.getClosingOverlayView(), false, 200);
}
}
}
@Override
public void onPopupResizingStart() {
if (DEBUG) {
Log.d(TAG, "onPopupResizingStart called");
}
player.getLoadingPanel().setVisibility(View.GONE);
player.hideControls(0, 0);
animate(player.getFastSeekOverlay(), false, 0);
animate(player.getCurrentDisplaySeek(), false, 0, ALPHA, 0);
}
@Override
public void onPopupResizingEnd() {
if (DEBUG) {
Log.d(TAG, "onPopupResizingEnd called");
}
}
}

View File

@ -3,6 +3,8 @@ package org.schabi.newpipe.player.event;
import com.google.android.exoplayer2.PlaybackException;
public interface PlayerServiceEventListener extends PlayerEventListener {
void onViewCreated();
void onFullscreenStateChanged(boolean fullscreen);
void onScreenRotationButtonClicked();

View File

@ -1,11 +1,11 @@
package org.schabi.newpipe.player.event;
import org.schabi.newpipe.player.MainPlayer;
import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.Player;
public interface PlayerServiceExtendedEventListener extends PlayerServiceEventListener {
void onServiceConnected(Player player,
MainPlayer playerService,
PlayerService playerService,
boolean playAfterConnect);
void onServiceDisconnected();
}

View File

@ -0,0 +1,186 @@
package org.schabi.newpipe.player.gesture
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import org.schabi.newpipe.databinding.PlayerBinding
import org.schabi.newpipe.player.Player
import org.schabi.newpipe.player.ui.VideoPlayerUi
/**
* Base gesture handling for [Player]
*
* This class contains the logic for the player gestures like View preparations
* and provides some abstract methods to make it easier separating the logic from the UI.
*/
abstract class BasePlayerGestureListener(
private val playerUi: VideoPlayerUi,
) : GestureDetector.SimpleOnGestureListener(), View.OnTouchListener {
protected val player: Player = playerUi.player
protected val binding: PlayerBinding = playerUi.binding
override fun onTouch(v: View, event: MotionEvent): Boolean {
playerUi.gestureDetector.onTouchEvent(event)
return false
}
private fun onDoubleTap(
event: MotionEvent,
portion: DisplayPortion
) {
if (DEBUG) {
Log.d(
TAG,
"onDoubleTap called with playerType = [" +
player.playerType + "], portion = [" + portion + "]"
)
}
if (playerUi.isSomePopupMenuVisible) {
playerUi.hideControls(0, 0)
}
if (portion === DisplayPortion.LEFT || portion === DisplayPortion.RIGHT) {
startMultiDoubleTap(event)
} else if (portion === DisplayPortion.MIDDLE) {
player.playPause()
}
}
protected fun onSingleTap() {
if (playerUi.isControlsVisible) {
playerUi.hideControls(150, 0)
return
}
// -- Controls are not visible --
// When player is completed show controls and don't hide them later
if (player.currentState == Player.STATE_COMPLETED) {
playerUi.showControls(0)
} else {
playerUi.showControlsThenHide()
}
}
open fun onScrollEnd(event: MotionEvent) {
if (DEBUG) {
Log.d(
TAG,
"onScrollEnd called with playerType = [" +
player.playerType + "]"
)
}
if (playerUi.isControlsVisible && player.currentState == Player.STATE_PLAYING) {
playerUi.hideControls(
VideoPlayerUi.DEFAULT_CONTROLS_DURATION,
VideoPlayerUi.DEFAULT_CONTROLS_HIDE_TIME
)
}
}
// ///////////////////////////////////////////////////////////////////
// Simple gestures
// ///////////////////////////////////////////////////////////////////
override fun onDown(e: MotionEvent): Boolean {
if (DEBUG)
Log.d(TAG, "onDown called with e = [$e]")
if (isDoubleTapping && isDoubleTapEnabled) {
doubleTapControls?.onDoubleTapProgressDown(getDisplayPortion(e))
return true
}
if (onDownNotDoubleTapping(e)) {
return super.onDown(e)
}
return true
}
/**
* @return true if `super.onDown(e)` should be called, false otherwise
*/
open fun onDownNotDoubleTapping(e: MotionEvent): Boolean {
return false // do not call super.onDown(e) by default, overridden for popup player
}
override fun onDoubleTap(e: MotionEvent): Boolean {
if (DEBUG)
Log.d(TAG, "onDoubleTap called with e = [$e]")
onDoubleTap(e, getDisplayPortion(e))
return true
}
// ///////////////////////////////////////////////////////////////////
// Multi double tapping
// ///////////////////////////////////////////////////////////////////
private var doubleTapControls: DoubleTapListener? = null
private val isDoubleTapEnabled: Boolean
get() = doubleTapDelay > 0
var isDoubleTapping = false
private set
fun doubleTapControls(listener: DoubleTapListener) = apply {
doubleTapControls = listener
}
private var doubleTapDelay = DOUBLE_TAP_DELAY
private val doubleTapHandler: Handler = Handler(Looper.getMainLooper())
private val doubleTapRunnable = Runnable {
if (DEBUG)
Log.d(TAG, "doubleTapRunnable called")
isDoubleTapping = false
doubleTapControls?.onDoubleTapFinished()
}
private fun startMultiDoubleTap(e: MotionEvent) {
if (!isDoubleTapping) {
if (DEBUG)
Log.d(TAG, "startMultiDoubleTap called with e = [$e]")
keepInDoubleTapMode()
doubleTapControls?.onDoubleTapStarted(getDisplayPortion(e))
}
}
fun keepInDoubleTapMode() {
if (DEBUG)
Log.d(TAG, "keepInDoubleTapMode called")
isDoubleTapping = true
doubleTapHandler.removeCallbacks(doubleTapRunnable)
doubleTapHandler.postDelayed(doubleTapRunnable, doubleTapDelay)
}
fun endMultiDoubleTap() {
if (DEBUG)
Log.d(TAG, "endMultiDoubleTap called")
isDoubleTapping = false
doubleTapHandler.removeCallbacks(doubleTapRunnable)
doubleTapControls?.onDoubleTapFinished()
}
// ///////////////////////////////////////////////////////////////////
// Utils
// ///////////////////////////////////////////////////////////////////
abstract fun getDisplayPortion(e: MotionEvent): DisplayPortion
// Currently needed for scrolling since there is no action more the middle portion
abstract fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion
companion object {
private const val TAG = "BasePlayerGestListener"
private val DEBUG = Player.DEBUG
private const val DOUBLE_TAP_DELAY = 550L
}
}

View File

@ -1,4 +1,4 @@
package org.schabi.newpipe.player.event;
package org.schabi.newpipe.player.gesture;
import android.content.Context;
import android.graphics.Rect;
@ -8,24 +8,25 @@ import android.view.View;
import android.widget.FrameLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
import org.schabi.newpipe.R;
import java.util.Arrays;
import java.util.List;
public class CustomBottomSheetBehavior extends BottomSheetBehavior<FrameLayout> {
public CustomBottomSheetBehavior(final Context context, final AttributeSet attrs) {
public CustomBottomSheetBehavior(@NonNull final Context context,
@Nullable final AttributeSet attrs) {
super(context, attrs);
}
Rect globalRect = new Rect();
private boolean skippingInterception = false;
private final List<Integer> skipInterceptionOfElements = Arrays.asList(
private final List<Integer> skipInterceptionOfElements = List.of(
R.id.detail_content_root_layout, R.id.relatedItemsLayout,
R.id.itemsListPanel, R.id.view_pager, R.id.tab_layout, R.id.bottomControls,
R.id.playPauseButton, R.id.playPreviousButton, R.id.playNextButton);
@ -33,7 +34,7 @@ public class CustomBottomSheetBehavior extends BottomSheetBehavior<FrameLayout>
@Override
public boolean onInterceptTouchEvent(@NonNull final CoordinatorLayout parent,
@NonNull final FrameLayout child,
final MotionEvent event) {
@NonNull final MotionEvent event) {
// Drop following when action ends
if (event.getAction() == MotionEvent.ACTION_CANCEL
|| event.getAction() == MotionEvent.ACTION_UP) {
@ -57,7 +58,7 @@ public class CustomBottomSheetBehavior extends BottomSheetBehavior<FrameLayout>
if (getState() == BottomSheetBehavior.STATE_EXPANDED
&& event.getAction() == MotionEvent.ACTION_DOWN) {
// Without overriding scrolling will not work when user touches these elements
for (final Integer element : skipInterceptionOfElements) {
for (final int element : skipInterceptionOfElements) {
final View view = child.findViewById(element);
if (view != null) {
final boolean visible = view.getGlobalVisibleRect(globalRect);

View File

@ -1,4 +1,4 @@
package org.schabi.newpipe.player.event
package org.schabi.newpipe.player.gesture
enum class DisplayPortion {
LEFT, MIDDLE, RIGHT, LEFT_HALF, RIGHT_HALF

View File

@ -0,0 +1,7 @@
package org.schabi.newpipe.player.gesture
interface DoubleTapListener {
fun onDoubleTapStarted(portion: DisplayPortion)
fun onDoubleTapProgressDown(portion: DisplayPortion)
fun onDoubleTapFinished()
}

View File

@ -0,0 +1,234 @@
package org.schabi.newpipe.player.gesture
import android.util.Log
import android.view.MotionEvent
import android.view.View
import android.view.View.OnTouchListener
import android.widget.ProgressBar
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.view.isVisible
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R
import org.schabi.newpipe.ktx.AnimationType
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.player.Player
import org.schabi.newpipe.player.helper.AudioReactor
import org.schabi.newpipe.player.helper.PlayerHelper
import org.schabi.newpipe.player.ui.MainPlayerUi
import org.schabi.newpipe.util.ThemeHelper.getAndroidDimenPx
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
/**
* GestureListener for the player
*
* While [BasePlayerGestureListener] contains the logic behind the single gestures
* this class focuses on the visual aspect like hiding and showing the controls or changing
* volume/brightness during scrolling for specific events.
*/
class MainPlayerGestureListener(
private val playerUi: MainPlayerUi
) : BasePlayerGestureListener(playerUi), OnTouchListener {
private var isMoving = false
override fun onTouch(v: View, event: MotionEvent): Boolean {
super.onTouch(v, event)
if (event.action == MotionEvent.ACTION_UP && isMoving) {
isMoving = false
onScrollEnd(event)
}
return when (event.action) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
v.parent?.requestDisallowInterceptTouchEvent(playerUi.isFullscreen)
true
}
MotionEvent.ACTION_UP -> {
v.parent?.requestDisallowInterceptTouchEvent(false)
false
}
else -> true
}
}
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
if (DEBUG)
Log.d(TAG, "onSingleTapConfirmed() called with: e = [$e]")
if (isDoubleTapping)
return true
super.onSingleTapConfirmed(e)
if (player.currentState != Player.STATE_BLOCKED)
onSingleTap()
return true
}
private fun onScrollVolume(distanceY: Float) {
val bar: ProgressBar = binding.volumeProgressBar
val audioReactor: AudioReactor = player.audioReactor
// If we just started sliding, change the progress bar to match the system volume
if (!binding.volumeRelativeLayout.isVisible) {
val volumePercent: Float = audioReactor.volume / audioReactor.maxVolume.toFloat()
bar.progress = (volumePercent * bar.max).toInt()
}
// Update progress bar
binding.volumeProgressBar.incrementProgressBy(distanceY.toInt())
// Update volume
val currentProgressPercent: Float = bar.progress / bar.max.toFloat()
val currentVolume = (audioReactor.maxVolume * currentProgressPercent).toInt()
audioReactor.volume = currentVolume
if (DEBUG) {
Log.d(TAG, "onScroll().volumeControl, currentVolume = $currentVolume")
}
// Update player center image
binding.volumeImageView.setImageDrawable(
AppCompatResources.getDrawable(
player.context,
when {
currentProgressPercent <= 0 -> R.drawable.ic_volume_off
currentProgressPercent < 0.25 -> R.drawable.ic_volume_mute
currentProgressPercent < 0.75 -> R.drawable.ic_volume_down
else -> R.drawable.ic_volume_up
}
)
)
// Make sure the correct layout is visible
if (!binding.volumeRelativeLayout.isVisible) {
binding.volumeRelativeLayout.animate(true, 200, AnimationType.SCALE_AND_ALPHA)
}
binding.brightnessRelativeLayout.isVisible = false
}
private fun onScrollBrightness(distanceY: Float) {
val parent: AppCompatActivity = playerUi.parentActivity.orElse(null) ?: return
val window = parent.window
val layoutParams = window.attributes
val bar: ProgressBar = binding.brightnessProgressBar
// Update progress bar
val oldBrightness = layoutParams.screenBrightness
bar.progress = (bar.max * max(0f, min(1f, oldBrightness))).toInt()
bar.incrementProgressBy(distanceY.toInt())
// Update brightness
val currentProgressPercent = bar.progress.toFloat() / bar.max
layoutParams.screenBrightness = currentProgressPercent
window.attributes = layoutParams
// Save current brightness level
PlayerHelper.setScreenBrightness(parent, currentProgressPercent)
if (DEBUG) {
Log.d(
TAG,
"onScroll().brightnessControl, " +
"currentBrightness = " + currentProgressPercent
)
}
// Update player center image
binding.brightnessImageView.setImageDrawable(
AppCompatResources.getDrawable(
player.context,
when {
currentProgressPercent < 0.25 -> R.drawable.ic_brightness_low
currentProgressPercent < 0.75 -> R.drawable.ic_brightness_medium
else -> R.drawable.ic_brightness_high
}
)
)
// Make sure the correct layout is visible
if (!binding.brightnessRelativeLayout.isVisible) {
binding.brightnessRelativeLayout.animate(true, 200, AnimationType.SCALE_AND_ALPHA)
}
binding.volumeRelativeLayout.isVisible = false
}
override fun onScrollEnd(event: MotionEvent) {
super.onScrollEnd(event)
if (binding.volumeRelativeLayout.isVisible) {
binding.volumeRelativeLayout.animate(false, 200, AnimationType.SCALE_AND_ALPHA, 200)
}
if (binding.brightnessRelativeLayout.isVisible) {
binding.brightnessRelativeLayout.animate(false, 200, AnimationType.SCALE_AND_ALPHA, 200)
}
}
override fun onScroll(
initialEvent: MotionEvent,
movingEvent: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
if (!playerUi.isFullscreen) {
return false
}
// Calculate heights of status and navigation bars
val statusBarHeight = getAndroidDimenPx(player.context, "status_bar_height")
val navigationBarHeight = getAndroidDimenPx(player.context, "navigation_bar_height")
// Do not handle this event if initially it started from status or navigation bars
val isTouchingStatusBar = initialEvent.y < statusBarHeight
val isTouchingNavigationBar = initialEvent.y > (binding.root.height - navigationBarHeight)
if (isTouchingStatusBar || isTouchingNavigationBar) {
return false
}
val insideThreshold = abs(movingEvent.y - initialEvent.y) <= MOVEMENT_THRESHOLD
if (
!isMoving && (insideThreshold || abs(distanceX) > abs(distanceY)) ||
player.currentState == Player.STATE_COMPLETED
) {
return false
}
isMoving = true
// -- Brightness and Volume control --
val isBrightnessGestureEnabled = PlayerHelper.isBrightnessGestureEnabled(player.context)
val isVolumeGestureEnabled = PlayerHelper.isVolumeGestureEnabled(player.context)
if (isBrightnessGestureEnabled && isVolumeGestureEnabled) {
if (getDisplayHalfPortion(initialEvent) === DisplayPortion.LEFT_HALF) {
onScrollBrightness(distanceY)
} else /* DisplayPortion.RIGHT_HALF */ {
onScrollVolume(distanceY)
}
} else if (isBrightnessGestureEnabled) {
onScrollBrightness(distanceY)
} else if (isVolumeGestureEnabled) {
onScrollVolume(distanceY)
}
return true
}
override fun getDisplayPortion(e: MotionEvent): DisplayPortion {
return when {
e.x < binding.root.width / 3.0 -> DisplayPortion.LEFT
e.x > binding.root.width * 2.0 / 3.0 -> DisplayPortion.RIGHT
else -> DisplayPortion.MIDDLE
}
}
override fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion {
return when {
e.x < binding.root.width / 2.0 -> DisplayPortion.LEFT_HALF
else -> DisplayPortion.RIGHT_HALF
}
}
companion object {
private val TAG = MainPlayerGestureListener::class.java.simpleName
private val DEBUG = MainActivity.DEBUG
private const val MOVEMENT_THRESHOLD = 40
}
}

View File

@ -0,0 +1,283 @@
package org.schabi.newpipe.player.gesture
import android.util.Log
import android.view.MotionEvent
import android.view.View
import android.view.ViewConfiguration
import androidx.core.math.MathUtils
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.ktx.AnimationType
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.player.ui.PopupPlayerUi
import kotlin.math.abs
import kotlin.math.hypot
import kotlin.math.max
import kotlin.math.min
class PopupPlayerGestureListener(
private val playerUi: PopupPlayerUi,
) : BasePlayerGestureListener(playerUi) {
private var isMoving = false
private var initialPopupX: Int = -1
private var initialPopupY: Int = -1
private var isResizing = false
// initial coordinates and distance between fingers
private var initPointerDistance = -1.0
private var initFirstPointerX = -1f
private var initFirstPointerY = -1f
private var initSecPointerX = -1f
private var initSecPointerY = -1f
override fun onTouch(v: View, event: MotionEvent): Boolean {
super.onTouch(v, event)
if (event.pointerCount == 2 && !isMoving && !isResizing) {
if (DEBUG) {
Log.d(TAG, "onTouch() 2 finger pointer detected, enabling resizing.")
}
onPopupResizingStart()
// record coordinates of fingers
initFirstPointerX = event.getX(0)
initFirstPointerY = event.getY(0)
initSecPointerX = event.getX(1)
initSecPointerY = event.getY(1)
// record distance between fingers
initPointerDistance = hypot(
initFirstPointerX - initSecPointerX.toDouble(),
initFirstPointerY - initSecPointerY.toDouble()
)
isResizing = true
}
if (event.action == MotionEvent.ACTION_MOVE && !isMoving && isResizing) {
if (DEBUG) {
Log.d(
TAG,
"onTouch() ACTION_MOVE > v = [$v], e1.getRaw =" +
"[${event.rawX}, ${event.rawY}]"
)
}
return handleMultiDrag(event)
}
if (event.action == MotionEvent.ACTION_UP) {
if (DEBUG) {
Log.d(
TAG,
"onTouch() ACTION_UP > v = [$v], e1.getRaw =" +
" [${event.rawX}, ${event.rawY}]"
)
}
if (isMoving) {
isMoving = false
onScrollEnd(event)
}
if (isResizing) {
isResizing = false
initPointerDistance = (-1).toDouble()
initFirstPointerX = (-1).toFloat()
initFirstPointerY = (-1).toFloat()
initSecPointerX = (-1).toFloat()
initSecPointerY = (-1).toFloat()
onPopupResizingEnd()
player.changeState(player.currentState)
}
if (!playerUi.isPopupClosing) {
playerUi.savePopupPositionAndSizeToPrefs()
}
}
v.performClick()
return true
}
override fun onScrollEnd(event: MotionEvent) {
super.onScrollEnd(event)
if (playerUi.isInsideClosingRadius(event)) {
playerUi.closePopup()
} else if (!playerUi.isPopupClosing) {
playerUi.closeOverlayBinding.closeButton.animate(false, 200)
binding.closingOverlay.animate(false, 200)
}
}
private fun handleMultiDrag(event: MotionEvent): Boolean {
if (initPointerDistance == -1.0 || event.pointerCount != 2) {
return false
}
// get the movements of the fingers
val firstPointerMove = hypot(
event.getX(0) - initFirstPointerX.toDouble(),
event.getY(0) - initFirstPointerY.toDouble()
)
val secPointerMove = hypot(
event.getX(1) - initSecPointerX.toDouble(),
event.getY(1) - initSecPointerY.toDouble()
)
// minimum threshold beyond which pinch gesture will work
val minimumMove = ViewConfiguration.get(player.context).scaledTouchSlop
if (max(firstPointerMove, secPointerMove) <= minimumMove) {
return false
}
// calculate current distance between the pointers
val currentPointerDistance = hypot(
event.getX(0) - event.getX(1).toDouble(),
event.getY(0) - event.getY(1).toDouble()
)
val popupWidth = playerUi.popupLayoutParams.width.toDouble()
// change co-ordinates of popup so the center stays at the same position
val newWidth = popupWidth * currentPointerDistance / initPointerDistance
initPointerDistance = currentPointerDistance
playerUi.popupLayoutParams.x += ((popupWidth - newWidth) / 2.0).toInt()
playerUi.checkPopupPositionBounds()
playerUi.updateScreenSize()
playerUi.changePopupSize(min(playerUi.screenWidth.toDouble(), newWidth).toInt())
return true
}
private fun onPopupResizingStart() {
if (DEBUG) {
Log.d(TAG, "onPopupResizingStart called")
}
binding.loadingPanel.visibility = View.GONE
playerUi.hideControls(0, 0)
binding.fastSeekOverlay.animate(false, 0)
binding.currentDisplaySeek.animate(false, 0, AnimationType.ALPHA, 0)
}
private fun onPopupResizingEnd() {
if (DEBUG) {
Log.d(TAG, "onPopupResizingEnd called")
}
}
override fun onLongPress(e: MotionEvent?) {
playerUi.updateScreenSize()
playerUi.checkPopupPositionBounds()
playerUi.changePopupSize(playerUi.screenWidth)
}
override fun onFling(
e1: MotionEvent?,
e2: MotionEvent?,
velocityX: Float,
velocityY: Float
): Boolean {
return if (player.popupPlayerSelected()) {
val absVelocityX = abs(velocityX)
val absVelocityY = abs(velocityY)
if (absVelocityX.coerceAtLeast(absVelocityY) > TOSS_FLING_VELOCITY) {
if (absVelocityX > TOSS_FLING_VELOCITY) {
playerUi.popupLayoutParams.x = velocityX.toInt()
}
if (absVelocityY > TOSS_FLING_VELOCITY) {
playerUi.popupLayoutParams.y = velocityY.toInt()
}
playerUi.checkPopupPositionBounds()
playerUi.windowManager.updateViewLayout(binding.root, playerUi.popupLayoutParams)
return true
}
return false
} else {
true
}
}
override fun onDownNotDoubleTapping(e: MotionEvent): Boolean {
// Fix popup position when the user touch it, it may have the wrong one
// because the soft input is visible (the draggable area is currently resized).
playerUi.updateScreenSize()
playerUi.checkPopupPositionBounds()
playerUi.popupLayoutParams.let {
initialPopupX = it.x
initialPopupY = it.y
}
return true // we want `super.onDown(e)` to be called
}
override fun onSingleTapConfirmed(e: MotionEvent): Boolean {
if (DEBUG)
Log.d(TAG, "onSingleTapConfirmed() called with: e = [$e]")
if (isDoubleTapping)
return true
if (player.exoPlayerIsNull())
return false
onSingleTap()
return true
}
override fun onScroll(
initialEvent: MotionEvent,
movingEvent: MotionEvent,
distanceX: Float,
distanceY: Float
): Boolean {
if (isResizing) {
return super.onScroll(initialEvent, movingEvent, distanceX, distanceY)
}
if (!isMoving) {
playerUi.closeOverlayBinding.closeButton.animate(true, 200)
}
isMoving = true
val diffX = (movingEvent.rawX - initialEvent.rawX)
val posX = MathUtils.clamp(
initialPopupX + diffX,
0f, (playerUi.screenWidth - playerUi.popupLayoutParams.width).toFloat()
)
val diffY = (movingEvent.rawY - initialEvent.rawY)
val posY = MathUtils.clamp(
initialPopupY + diffY,
0f, (playerUi.screenHeight - playerUi.popupLayoutParams.height).toFloat()
)
playerUi.popupLayoutParams.x = posX.toInt()
playerUi.popupLayoutParams.y = posY.toInt()
// -- Determine if the ClosingOverlayView (red X) has to be shown or hidden --
val showClosingOverlayView: Boolean = playerUi.isInsideClosingRadius(movingEvent)
// Check if an view is in expected state and if not animate it into the correct state
val expectedVisibility = if (showClosingOverlayView) View.VISIBLE else View.GONE
if (binding.closingOverlay.visibility != expectedVisibility) {
binding.closingOverlay.animate(showClosingOverlayView, 200)
}
playerUi.windowManager.updateViewLayout(binding.root, playerUi.popupLayoutParams)
return true
}
override fun getDisplayPortion(e: MotionEvent): DisplayPortion {
return when {
e.x < playerUi.popupLayoutParams.width / 3.0 -> DisplayPortion.LEFT
e.x > playerUi.popupLayoutParams.width * 2.0 / 3.0 -> DisplayPortion.RIGHT
else -> DisplayPortion.MIDDLE
}
}
override fun getDisplayHalfPortion(e: MotionEvent): DisplayPortion {
return when {
e.x < playerUi.popupLayoutParams.width / 2.0 -> DisplayPortion.LEFT_HALF
else -> DisplayPortion.RIGHT_HALF
}
}
companion object {
private val TAG = PopupPlayerGestureListener::class.java.simpleName
private val DEBUG = MainActivity.DEBUG
private const val TOSS_FLING_VELOCITY = 2500
}
}

View File

@ -39,8 +39,8 @@ final class CacheFactory implements DataSource.Factory {
.createDataSource();
final FileDataSource fileSource = new FileDataSource();
final CacheDataSink dataSink
= new CacheDataSink(cache, PlayerHelper.getPreferredFileSize());
final CacheDataSink dataSink =
new CacheDataSink(cache, PlayerHelper.getPreferredFileSize());
return new CacheDataSource(cache, dataSource, fileSource, dataSink, CACHE_FLAGS, null);
}
}

View File

@ -1,226 +0,0 @@
package org.schabi.newpipe.player.helper;
import android.content.Context;
import android.content.Intent;
import android.graphics.Bitmap;
import android.support.v4.media.MediaMetadataCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.support.v4.media.session.PlaybackStateCompat;
import android.util.Log;
import android.view.KeyEvent;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.media.session.MediaButtonReceiver;
import com.google.android.exoplayer2.ForwardingPlayer;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.player.mediasession.MediaSessionCallback;
import org.schabi.newpipe.player.mediasession.PlayQueueNavigator;
import java.util.Optional;
public class MediaSessionManager {
private static final String TAG = MediaSessionManager.class.getSimpleName();
public static final boolean DEBUG = MainActivity.DEBUG;
@NonNull
private final MediaSessionCompat mediaSession;
@NonNull
private final MediaSessionConnector sessionConnector;
private int lastTitleHashCode;
private int lastArtistHashCode;
private long lastDuration;
private int lastAlbumArtHashCode;
public MediaSessionManager(@NonNull final Context context,
@NonNull final Player player,
@NonNull final MediaSessionCallback callback) {
mediaSession = new MediaSessionCompat(context, TAG);
mediaSession.setActive(true);
mediaSession.setPlaybackState(new PlaybackStateCompat.Builder()
.setState(PlaybackStateCompat.STATE_NONE, -1, 1)
.setActions(PlaybackStateCompat.ACTION_SEEK_TO
| PlaybackStateCompat.ACTION_PLAY
| PlaybackStateCompat.ACTION_PAUSE // was play and pause now play/pause
| PlaybackStateCompat.ACTION_SKIP_TO_NEXT
| PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS
| PlaybackStateCompat.ACTION_SET_REPEAT_MODE
| PlaybackStateCompat.ACTION_STOP)
.build());
sessionConnector = new MediaSessionConnector(mediaSession);
sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, callback));
sessionConnector.setPlayer(new ForwardingPlayer(player) {
@Override
public void play() {
callback.play();
}
@Override
public void pause() {
callback.pause();
}
});
}
@Nullable
@SuppressWarnings("UnusedReturnValue")
public KeyEvent handleMediaButtonIntent(final Intent intent) {
return MediaButtonReceiver.handleIntent(mediaSession, intent);
}
public MediaSessionCompat.Token getSessionToken() {
return mediaSession.getSessionToken();
}
/**
* sets the Metadata - if required.
*
* @param title {@link MediaMetadataCompat#METADATA_KEY_TITLE}
* @param artist {@link MediaMetadataCompat#METADATA_KEY_ARTIST}
* @param optAlbumArt {@link MediaMetadataCompat#METADATA_KEY_ALBUM_ART}
* @param duration {@link MediaMetadataCompat#METADATA_KEY_DURATION}
* - should be a negative value for unknown durations, e.g. for livestreams
*/
public void setMetadata(@NonNull final String title,
@NonNull final String artist,
@NonNull final Optional<Bitmap> optAlbumArt,
final long duration
) {
if (DEBUG) {
Log.d(TAG, "setMetadata called:"
+ " t: " + title
+ " a: " + artist
+ " thumb: " + (
optAlbumArt.isPresent()
? optAlbumArt.get().hashCode()
: "<none>")
+ " d: " + duration);
}
if (!mediaSession.isActive()) {
if (DEBUG) {
Log.d(TAG, "setMetadata: mediaSession not active - exiting");
}
return;
}
if (!checkIfMetadataShouldBeSet(title, artist, optAlbumArt, duration)) {
if (DEBUG) {
Log.d(TAG, "setMetadata: No update required - exiting");
}
return;
}
if (DEBUG) {
Log.d(TAG, "setMetadata: N_Metadata update:"
+ " t: " + title
+ " a: " + artist
+ " thumb: " + (
optAlbumArt.isPresent()
? optAlbumArt.get().hashCode()
: "<none>")
+ " d: " + duration);
}
final MediaMetadataCompat.Builder builder = new MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, artist)
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration);
if (optAlbumArt.isPresent()) {
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, optAlbumArt.get());
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, optAlbumArt.get());
}
mediaSession.setMetadata(builder.build());
lastTitleHashCode = title.hashCode();
lastArtistHashCode = artist.hashCode();
lastDuration = duration;
optAlbumArt.ifPresent(bitmap -> lastAlbumArtHashCode = bitmap.hashCode());
}
private boolean checkIfMetadataShouldBeSet(
@NonNull final String title,
@NonNull final String artist,
@NonNull final Optional<Bitmap> optAlbumArt,
final long duration
) {
// Check if the values have changed since the last time
if (title.hashCode() != lastTitleHashCode
|| artist.hashCode() != lastArtistHashCode
|| duration != lastDuration
|| (optAlbumArt.isPresent() && optAlbumArt.get().hashCode() != lastAlbumArtHashCode)
) {
if (DEBUG) {
Log.d(TAG,
"checkIfMetadataShouldBeSet: true - reason: changed values since last");
}
return true;
}
// Check if the currently set metadata is valid
if (getMetadataTitle() == null
|| getMetadataArtist() == null
// Note that the duration can be <= 0 for live streams
) {
if (DEBUG) {
if (getMetadataTitle() == null) {
Log.d(TAG,
"N_getMetadataTitle: title == null");
} else if (getMetadataArtist() == null) {
Log.d(TAG,
"N_getMetadataArtist: artist == null");
}
}
return true;
}
// If we got an album art check if the current set AlbumArt is null
if (optAlbumArt.isPresent() && getMetadataAlbumArt() == null) {
if (DEBUG) {
Log.d(TAG, "N_getMetadataAlbumArt: thumb == null");
}
return true;
}
// Default - no update required
return false;
}
@Nullable
private Bitmap getMetadataAlbumArt() {
return mediaSession.getController().getMetadata()
.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART);
}
@Nullable
private String getMetadataTitle() {
return mediaSession.getController().getMetadata()
.getString(MediaMetadataCompat.METADATA_KEY_TITLE);
}
@Nullable
private String getMetadataArtist() {
return mediaSession.getController().getMetadata()
.getString(MediaMetadataCompat.METADATA_KEY_ARTIST);
}
/**
* Should be called on player destruction to prevent leakage.
*/
public void dispose() {
sessionConnector.setPlayer(null);
sessionConnector.setQueueNavigator(null);
mediaSession.setActive(false);
mediaSession.release();
}
}

View File

@ -11,7 +11,6 @@ import android.graphics.drawable.Drawable;
import android.graphics.drawable.LayerDrawable;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.CheckBox;
import android.widget.SeekBar;
@ -21,16 +20,16 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import androidx.core.math.MathUtils;
import androidx.fragment.app.DialogFragment;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.DialogPlaybackParameterBinding;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.ui.VideoPlayerUi;
import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener;
import org.schabi.newpipe.util.SliderStrategy;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.function.Consumer;
@ -149,7 +148,7 @@ public class PlaybackParameterDialog extends DialogFragment {
assureCorrectAppLanguage(getContext());
Icepick.restoreInstanceState(this, savedInstanceState);
binding = DialogPlaybackParameterBinding.inflate(LayoutInflater.from(getContext()));
binding = DialogPlaybackParameterBinding.inflate(getLayoutInflater());
initUI();
final AlertDialog.Builder dialogBuilder = new AlertDialog.Builder(requireActivity())
@ -207,7 +206,7 @@ public class PlaybackParameterDialog extends DialogFragment {
? View.VISIBLE
: View.GONE);
animateRotation(binding.pitchToogleControlModes,
Player.DEFAULT_CONTROLS_DURATION,
VideoPlayerUi.DEFAULT_CONTROLS_DURATION,
isCurrentlyVisible ? 180 : 0);
});
@ -334,10 +333,8 @@ public class PlaybackParameterDialog extends DialogFragment {
}
private Map<Boolean, TextView> getPitchControlModeComponentMappings() {
final Map<Boolean, TextView> mappings = new HashMap<>();
mappings.put(PITCH_CTRL_MODE_PERCENT, binding.pitchControlModePercent);
mappings.put(PITCH_CTRL_MODE_SEMITONE, binding.pitchControlModeSemitone);
return mappings;
return Map.of(PITCH_CTRL_MODE_PERCENT, binding.pitchControlModePercent,
PITCH_CTRL_MODE_SEMITONE, binding.pitchControlModeSemitone);
}
private void changePitchControlMode(final boolean semitones) {
@ -407,13 +404,11 @@ public class PlaybackParameterDialog extends DialogFragment {
}
private Map<Double, TextView> getStepSizeComponentMappings() {
final Map<Double, TextView> mappings = new HashMap<>();
mappings.put(STEP_1_PERCENT_VALUE, binding.stepSizeOnePercent);
mappings.put(STEP_5_PERCENT_VALUE, binding.stepSizeFivePercent);
mappings.put(STEP_10_PERCENT_VALUE, binding.stepSizeTenPercent);
mappings.put(STEP_25_PERCENT_VALUE, binding.stepSizeTwentyFivePercent);
mappings.put(STEP_100_PERCENT_VALUE, binding.stepSizeOneHundredPercent);
return mappings;
return Map.of(STEP_1_PERCENT_VALUE, binding.stepSizeOnePercent,
STEP_5_PERCENT_VALUE, binding.stepSizeFivePercent,
STEP_10_PERCENT_VALUE, binding.stepSizeTenPercent,
STEP_25_PERCENT_VALUE, binding.stepSizeTwentyFivePercent,
STEP_100_PERCENT_VALUE, binding.stepSizeOneHundredPercent);
}
private void setStepSizeToUI(final double newStepSize) {
@ -532,7 +527,7 @@ public class PlaybackParameterDialog extends DialogFragment {
}
private void setAndUpdateTempo(final double newTempo) {
this.tempo = calcValidTempo(newTempo);
this.tempo = MathUtils.clamp(newTempo, MIN_PITCH_OR_SPEED, MAX_PITCH_OR_SPEED);
binding.tempoSeekbar.setProgress(QUADRATIC_STRATEGY.progressOf(tempo));
setText(binding.tempoCurrentText, PlayerHelper::formatSpeed, tempo);
@ -551,13 +546,8 @@ public class PlaybackParameterDialog extends DialogFragment {
pitchPercent);
}
private double calcValidTempo(final double newTempo) {
return Math.max(MIN_PITCH_OR_SPEED, Math.min(MAX_PITCH_OR_SPEED, newTempo));
}
private double calcValidPitch(final double newPitch) {
final double calcPitch =
Math.max(MIN_PITCH_OR_SPEED, Math.min(MAX_PITCH_OR_SPEED, newPitch));
final double calcPitch = MathUtils.clamp(newPitch, MIN_PITCH_OR_SPEED, MAX_PITCH_OR_SPEED);
if (!isCurrentPitchControlModeSemitone()) {
return calcPitch;

View File

@ -208,8 +208,8 @@ public class PlayerDataSource {
Log.w(TAG, "instantiateCacheIfNeeded: could not create cache dir");
}
final LeastRecentlyUsedCacheEvictor evictor
= new LeastRecentlyUsedCacheEvictor(PlayerHelper.getPreferredCacheSize());
final LeastRecentlyUsedCacheEvictor evictor =
new LeastRecentlyUsedCacheEvictor(PlayerHelper.getPreferredCacheSize());
cache = new SimpleCache(cacheDir, evictor, new StandaloneDatabaseProvider(context));
}
}

View File

@ -3,8 +3,6 @@ package org.schabi.newpipe.player.helper;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
import static org.schabi.newpipe.player.Player.IDLE_WINDOW_FLAGS;
import static org.schabi.newpipe.player.Player.PLAYER_TYPE;
import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_ALWAYS;
import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_NEVER;
import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_WIFI;
@ -15,14 +13,8 @@ import static java.lang.annotation.RetentionPolicy.SOURCE;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.PixelFormat;
import android.os.Build;
import android.provider.Settings;
import android.view.Gravity;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.view.accessibility.CaptioningManager;
import androidx.annotation.IntDef;
@ -49,7 +41,6 @@ import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.extractor.stream.SubtitlesStream;
import org.schabi.newpipe.extractor.utils.Utils;
import org.schabi.newpipe.player.MainPlayer;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
@ -71,25 +62,11 @@ import java.util.concurrent.TimeUnit;
public final class PlayerHelper {
private static final StringBuilder STRING_BUILDER = new StringBuilder();
private static final Formatter STRING_FORMATTER
= new Formatter(STRING_BUILDER, Locale.getDefault());
private static final Formatter STRING_FORMATTER =
new Formatter(STRING_BUILDER, Locale.getDefault());
private static final NumberFormat SPEED_FORMATTER = new DecimalFormat("0.##x");
private static final NumberFormat PITCH_FORMATTER = new DecimalFormat("##%");
/**
* Maximum opacity allowed for Android 12 and higher to allow touches on other apps when using
* NewPipe's popup player.
*
* <p>
* This value is hardcoded instead of being get dynamically with the method linked of the
* constant documentation below, because it is not static and popup player layout parameters
* are generated with static methods.
* </p>
*
* @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE
*/
private static final float MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER = 0.8f;
@Retention(SOURCE)
@IntDef({AUTOPLAY_TYPE_ALWAYS, AUTOPLAY_TYPE_WIFI,
AUTOPLAY_TYPE_NEVER})
@ -339,10 +316,6 @@ public final class PlayerHelper {
return true;
}
public static int getTossFlingVelocity() {
return 2500;
}
@NonNull
public static CaptionStyleCompat getCaptionStyle(@NonNull final Context context) {
final CaptioningManager captioningManager = ContextCompat.getSystemService(context,
@ -452,12 +425,6 @@ public final class PlayerHelper {
// Utils used by player
////////////////////////////////////////////////////////////////////////////
public static MainPlayer.PlayerType retrievePlayerTypeFromIntent(final Intent intent) {
// If you want to open popup from the app just include Constants.POPUP_ONLY into an extra
return MainPlayer.PlayerType.values()[
intent.getIntExtra(PLAYER_TYPE, MainPlayer.PlayerType.VIDEO.ordinal())];
}
public static boolean isPlaybackResumeEnabled(final Player player) {
return player.getPrefs().getBoolean(
player.getContext().getString(R.string.enable_watch_history_key), true)
@ -528,90 +495,10 @@ public final class PlayerHelper {
.apply();
}
/**
* @param player {@code screenWidth} and {@code screenHeight} must have been initialized
* @return the popup starting layout params
*/
@SuppressLint("RtlHardcoded")
public static WindowManager.LayoutParams retrievePopupLayoutParamsFromPrefs(
final Player player) {
final boolean popupRememberSizeAndPos = player.getPrefs().getBoolean(
player.getContext().getString(R.string.popup_remember_size_pos_key), true);
final float defaultSize =
player.getContext().getResources().getDimension(R.dimen.popup_default_width);
final float popupWidth = popupRememberSizeAndPos
? player.getPrefs().getFloat(player.getContext().getString(
R.string.popup_saved_width_key), defaultSize)
: defaultSize;
final float popupHeight = getMinimumVideoHeight(popupWidth);
final WindowManager.LayoutParams popupLayoutParams = new WindowManager.LayoutParams(
(int) popupWidth, (int) popupHeight,
popupLayoutParamType(),
IDLE_WINDOW_FLAGS,
PixelFormat.TRANSLUCENT);
popupLayoutParams.gravity = Gravity.LEFT | Gravity.TOP;
popupLayoutParams.softInputMode = WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
final int centerX = (int) (player.getScreenWidth() / 2f - popupWidth / 2f);
final int centerY = (int) (player.getScreenHeight() / 2f - popupHeight / 2f);
popupLayoutParams.x = popupRememberSizeAndPos
? player.getPrefs().getInt(player.getContext().getString(
R.string.popup_saved_x_key), centerX) : centerX;
popupLayoutParams.y = popupRememberSizeAndPos
? player.getPrefs().getInt(player.getContext().getString(
R.string.popup_saved_y_key), centerY) : centerY;
return popupLayoutParams;
}
public static void savePopupPositionAndSizeToPrefs(final Player player) {
if (player.getPopupLayoutParams() != null) {
player.getPrefs().edit()
.putFloat(player.getContext().getString(R.string.popup_saved_width_key),
player.getPopupLayoutParams().width)
.putInt(player.getContext().getString(R.string.popup_saved_x_key),
player.getPopupLayoutParams().x)
.putInt(player.getContext().getString(R.string.popup_saved_y_key),
player.getPopupLayoutParams().y)
.apply();
}
}
public static float getMinimumVideoHeight(final float width) {
return width / (16.0f / 9.0f); // Respect the 16:9 ratio that most videos have
}
@SuppressLint("RtlHardcoded")
public static WindowManager.LayoutParams buildCloseOverlayLayoutParams() {
final int flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE
| WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM;
final WindowManager.LayoutParams closeOverlayLayoutParams = new WindowManager.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
popupLayoutParamType(),
flags,
PixelFormat.TRANSLUCENT);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// Setting maximum opacity allowed for touch events to other apps for Android 12 and
// higher to prevent non interaction when using other apps with the popup player
closeOverlayLayoutParams.alpha = MAXIMUM_OPACITY_ALLOWED_FOR_S_AND_HIGHER;
}
closeOverlayLayoutParams.gravity = Gravity.LEFT | Gravity.TOP;
closeOverlayLayoutParams.softInputMode =
WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE;
return closeOverlayLayoutParams;
}
public static int popupLayoutParamType() {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.O
? WindowManager.LayoutParams.TYPE_PHONE
: WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
}
public static int retrieveSeekDurationFromPreferences(final Player player) {
return Integer.parseInt(Objects.requireNonNull(player.getPrefs().getString(
player.getContext().getString(R.string.seek_duration_key),

View File

@ -16,8 +16,9 @@ import com.google.android.exoplayer2.PlaybackParameters;
import org.schabi.newpipe.App;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.player.MainPlayer;
import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.event.PlayerServiceEventListener;
import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener;
import org.schabi.newpipe.player.playqueue.PlayQueue;
@ -42,17 +43,17 @@ public final class PlayerHolder {
private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection();
private boolean bound;
@Nullable private MainPlayer playerService;
@Nullable private PlayerService playerService;
@Nullable private Player player;
/**
* Returns the current {@link MainPlayer.PlayerType} of the {@link MainPlayer} service,
* otherwise `null` if no service running.
* Returns the current {@link PlayerType} of the {@link PlayerService} service,
* otherwise `null` if no service is running.
*
* @return Current PlayerType
*/
@Nullable
public MainPlayer.PlayerType getType() {
public PlayerType getType() {
if (player == null) {
return null;
}
@ -122,7 +123,7 @@ public final class PlayerHolder {
// and NullPointerExceptions inside the service because the service will be
// bound twice. Prevent it with unbinding first
unbind(context);
ContextCompat.startForegroundService(context, new Intent(context, MainPlayer.class));
ContextCompat.startForegroundService(context, new Intent(context, PlayerService.class));
serviceConnection.doPlayAfterConnect(playAfterConnect);
bind(context);
}
@ -130,7 +131,7 @@ public final class PlayerHolder {
public void stopService() {
final Context context = getCommonContext();
unbind(context);
context.stopService(new Intent(context, MainPlayer.class));
context.stopService(new Intent(context, PlayerService.class));
}
class PlayerServiceConnection implements ServiceConnection {
@ -156,7 +157,7 @@ public final class PlayerHolder {
if (DEBUG) {
Log.d(TAG, "Player service is connected");
}
final MainPlayer.LocalBinder localBinder = (MainPlayer.LocalBinder) service;
final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service;
playerService = localBinder.getService();
player = localBinder.getPlayer();
@ -172,7 +173,7 @@ public final class PlayerHolder {
Log.d(TAG, "bind() called");
}
final Intent serviceIntent = new Intent(context, MainPlayer.class);
final Intent serviceIntent = new Intent(context, PlayerService.class);
bound = context.bindService(serviceIntent, serviceConnection,
Context.BIND_AUTO_CREATE);
if (!bound) {
@ -211,6 +212,13 @@ public final class PlayerHolder {
private final PlayerServiceEventListener internalListener =
new PlayerServiceEventListener() {
@Override
public void onViewCreated() {
if (listener != null) {
listener.onViewCreated();
}
}
@Override
public void onFullscreenStateChanged(final boolean fullscreen) {
if (listener != null) {

View File

@ -1,5 +1,7 @@
package org.schabi.newpipe.player.helper;
import androidx.core.math.MathUtils;
/**
* Converts between percent and 12-tone equal temperament semitones.
* <br/>
@ -33,6 +35,6 @@ public final class PlayerSemitoneHelper {
}
private static int ensureSemitonesInRange(final int semitones) {
return Math.max(-SEMITONE_COUNT, Math.min(SEMITONE_COUNT, semitones));
return MathUtils.clamp(semitones, -SEMITONE_COUNT, SEMITONE_COUNT);
}
}

View File

@ -1,47 +0,0 @@
package org.schabi.newpipe.player.listeners.view
import android.util.Log
import android.view.View
import androidx.appcompat.widget.PopupMenu
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.player.Player
import org.schabi.newpipe.player.helper.PlaybackParameterDialog
/**
* Click listener for the playbackSpeed textview of the player
*/
class PlaybackSpeedClickListener(
private val player: Player,
private val playbackSpeedPopupMenu: PopupMenu
) : View.OnClickListener {
companion object {
private const val TAG: String = "PlaybSpeedClickListener"
}
override fun onClick(v: View) {
if (MainActivity.DEBUG) {
Log.d(TAG, "onPlaybackSpeedClicked() called")
}
if (player.videoPlayerSelected()) {
PlaybackParameterDialog.newInstance(
player.playbackSpeed.toDouble(),
player.playbackPitch.toDouble(),
player.playbackSkipSilence
) { speed: Float, pitch: Float, skipSilence: Boolean ->
player.setPlaybackParameters(
speed,
pitch,
skipSilence
)
}
.show(player.parentActivity!!.supportFragmentManager, null)
} else {
playbackSpeedPopupMenu.show()
player.isSomePopupMenuVisible = true
}
player.manageControlsAfterOnClick(v)
}
}

View File

@ -1,41 +0,0 @@
package org.schabi.newpipe.player.listeners.view
import android.annotation.SuppressLint
import android.util.Log
import android.view.View
import androidx.appcompat.widget.PopupMenu
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.extractor.MediaFormat
import org.schabi.newpipe.player.Player
/**
* Click listener for the qualityTextView of the player
*/
class QualityClickListener(
private val player: Player,
private val qualityPopupMenu: PopupMenu
) : View.OnClickListener {
companion object {
private const val TAG: String = "QualityClickListener"
}
@SuppressLint("SetTextI18n") // we don't need I18N because of a " "
override fun onClick(v: View) {
if (MainActivity.DEBUG) {
Log.d(TAG, "onQualitySelectorClicked() called")
}
qualityPopupMenu.show()
player.isSomePopupMenuVisible = true
val videoStream = player.selectedVideoStream
if (videoStream != null) {
player.binding.qualityTextView.text =
MediaFormat.getNameById(videoStream.formatId) + " " + videoStream.getResolution()
}
player.saveWasPlaying()
player.manageControlsAfterOnClick(v)
}
}

View File

@ -3,6 +3,7 @@ package org.schabi.newpipe.player.mediaitem;
import android.net.Uri;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.MediaItem.RequestMetadata;
import com.google.android.exoplayer2.MediaMetadata;
import com.google.android.exoplayer2.Player;
@ -76,7 +77,6 @@ public interface MediaItemTag {
@NonNull
default MediaItem asMediaItem() {
final MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setMediaUri(Uri.parse(getStreamUrl()))
.setArtworkUri(Uri.parse(getThumbnailUrl()))
.setArtist(getUploaderName())
.setDescription(getTitle())
@ -84,10 +84,15 @@ public interface MediaItemTag {
.setTitle(getTitle())
.build();
final RequestMetadata requestMetaData = new RequestMetadata.Builder()
.setMediaUri(Uri.parse(getStreamUrl()))
.build();
return MediaItem.fromUri(getStreamUrl())
.buildUpon()
.setMediaId(makeMediaId())
.setMediaMetadata(mediaMetadata)
.setRequestMetadata(requestMetaData)
.setTag(this)
.build();
}

View File

@ -1,21 +0,0 @@
package org.schabi.newpipe.player.mediasession;
import android.support.v4.media.MediaDescriptionCompat;
public interface MediaSessionCallback {
void playPrevious();
void playNext();
void playItemAtIndex(int index);
int getCurrentPlayingIndex();
int getQueueSize();
MediaDescriptionCompat getQueueMetadata(int index);
void play();
void pause();
}

View File

@ -0,0 +1,134 @@
package org.schabi.newpipe.player.mediasession;
import static org.schabi.newpipe.MainActivity.DEBUG;
import android.content.Intent;
import android.graphics.Bitmap;
import android.support.v4.media.MediaMetadataCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.media.session.MediaButtonReceiver;
import com.google.android.exoplayer2.ForwardingPlayer;
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import org.schabi.newpipe.R;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.ui.PlayerUi;
import org.schabi.newpipe.player.ui.VideoPlayerUi;
import org.schabi.newpipe.util.StreamTypeUtil;
import java.util.Optional;
public class MediaSessionPlayerUi extends PlayerUi {
private static final String TAG = "MediaSessUi";
private MediaSessionCompat mediaSession;
private MediaSessionConnector sessionConnector;
public MediaSessionPlayerUi(@NonNull final Player player) {
super(player);
}
@Override
public void initPlayer() {
super.initPlayer();
destroyPlayer(); // release previously used resources
mediaSession = new MediaSessionCompat(context, TAG);
mediaSession.setActive(true);
sessionConnector = new MediaSessionConnector(mediaSession);
sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, player));
sessionConnector.setPlayer(getForwardingPlayer());
sessionConnector.setMetadataDeduplicationEnabled(true);
sessionConnector.setMediaMetadataProvider(exoPlayer -> buildMediaMetadata());
}
@Override
public void destroyPlayer() {
super.destroyPlayer();
if (sessionConnector != null) {
sessionConnector.setPlayer(null);
sessionConnector.setQueueNavigator(null);
sessionConnector = null;
}
if (mediaSession != null) {
mediaSession.setActive(false);
mediaSession.release();
mediaSession = null;
}
}
@Override
public void onThumbnailLoaded(@Nullable final Bitmap bitmap) {
super.onThumbnailLoaded(bitmap);
if (sessionConnector != null) {
// the thumbnail is now loaded: invalidate the metadata to trigger a metadata update
sessionConnector.invalidateMediaSessionMetadata();
}
}
public void handleMediaButtonIntent(final Intent intent) {
MediaButtonReceiver.handleIntent(mediaSession, intent);
}
public Optional<MediaSessionCompat.Token> getSessionToken() {
return Optional.ofNullable(mediaSession).map(MediaSessionCompat::getSessionToken);
}
private ForwardingPlayer getForwardingPlayer() {
// ForwardingPlayer means that all media session actions called on this player are
// forwarded directly to the connected exoplayer, except for the overridden methods. So
// override play and pause since our player adds more functionality to them over exoplayer.
return new ForwardingPlayer(player.getExoPlayer()) {
@Override
public void play() {
player.play();
// hide the player controls even if the play command came from the media session
player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0));
}
@Override
public void pause() {
player.pause();
}
};
}
private MediaMetadataCompat buildMediaMetadata() {
if (DEBUG) {
Log.d(TAG, "buildMediaMetadata called");
}
// set title and artist
final MediaMetadataCompat.Builder builder = new MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, player.getVideoTitle())
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, player.getUploaderName());
// set duration (-1 for livestreams or if unknown, see the METADATA_KEY_DURATION docs)
final long duration = player.getCurrentStreamInfo()
.filter(info -> !StreamTypeUtil.isLiveStream(info.getStreamType()))
.map(info -> info.getDuration() * 1000L)
.orElse(-1L);
builder.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, duration);
// set album art, unless the user asked not to, or there is no thumbnail available
final boolean showThumbnail = player.getPrefs().getBoolean(
context.getString(R.string.show_thumbnail_key), true);
Optional.ofNullable(player.getThumbnail())
.filter(bitmap -> showThumbnail)
.ifPresent(bitmap -> {
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap);
builder.putBitmap(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON, bitmap);
});
return builder.build();
}
}

View File

@ -1,106 +1,152 @@
package org.schabi.newpipe.player.mediasession;
import android.os.Bundle;
import android.os.ResultReceiver;
import android.support.v4.media.session.MediaSessionCompat;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import com.google.android.exoplayer2.util.Util;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_NEXT;
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_PREVIOUS;
import static android.support.v4.media.session.PlaybackStateCompat.ACTION_SKIP_TO_QUEUE_ITEM;
import android.net.Uri;
import android.os.Bundle;
import android.os.ResultReceiver;
import android.support.v4.media.MediaDescriptionCompat;
import android.support.v4.media.MediaMetadataCompat;
import android.support.v4.media.session.MediaSessionCompat;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import com.google.android.exoplayer2.util.Util;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Optional;
public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator {
public static final int DEFAULT_MAX_QUEUE_SIZE = 10;
private static final int MAX_QUEUE_SIZE = 10;
private final MediaSessionCompat mediaSession;
private final MediaSessionCallback callback;
private final int maxQueueSize;
private final Player player;
private long activeQueueItemId;
public PlayQueueNavigator(@NonNull final MediaSessionCompat mediaSession,
@NonNull final MediaSessionCallback callback) {
@NonNull final Player player) {
this.mediaSession = mediaSession;
this.callback = callback;
this.maxQueueSize = DEFAULT_MAX_QUEUE_SIZE;
this.player = player;
this.activeQueueItemId = MediaSessionCompat.QueueItem.UNKNOWN_ID;
}
@Override
public long getSupportedQueueNavigatorActions(@Nullable final Player player) {
public long getSupportedQueueNavigatorActions(
@Nullable final com.google.android.exoplayer2.Player exoPlayer) {
return ACTION_SKIP_TO_NEXT | ACTION_SKIP_TO_PREVIOUS | ACTION_SKIP_TO_QUEUE_ITEM;
}
@Override
public void onTimelineChanged(@NonNull final Player player) {
public void onTimelineChanged(@NonNull final com.google.android.exoplayer2.Player exoPlayer) {
publishFloatingQueueWindow();
}
@Override
public void onCurrentMediaItemIndexChanged(@NonNull final Player player) {
public void onCurrentMediaItemIndexChanged(
@NonNull final com.google.android.exoplayer2.Player exoPlayer) {
if (activeQueueItemId == MediaSessionCompat.QueueItem.UNKNOWN_ID
|| player.getCurrentTimeline().getWindowCount() > maxQueueSize) {
|| exoPlayer.getCurrentTimeline().getWindowCount() > MAX_QUEUE_SIZE) {
publishFloatingQueueWindow();
} else if (!player.getCurrentTimeline().isEmpty()) {
activeQueueItemId = player.getCurrentMediaItemIndex();
} else if (!exoPlayer.getCurrentTimeline().isEmpty()) {
activeQueueItemId = exoPlayer.getCurrentMediaItemIndex();
}
}
@Override
public long getActiveQueueItemId(@Nullable final Player player) {
return callback.getCurrentPlayingIndex();
public long getActiveQueueItemId(
@Nullable final com.google.android.exoplayer2.Player exoPlayer) {
return Optional.ofNullable(player.getPlayQueue()).map(PlayQueue::getIndex).orElse(-1);
}
@Override
public void onSkipToPrevious(@NonNull final Player player) {
callback.playPrevious();
public void onSkipToPrevious(@NonNull final com.google.android.exoplayer2.Player exoPlayer) {
player.playPrevious();
}
@Override
public void onSkipToQueueItem(@NonNull final Player player, final long id) {
callback.playItemAtIndex((int) id);
public void onSkipToQueueItem(@NonNull final com.google.android.exoplayer2.Player exoPlayer,
final long id) {
if (player.getPlayQueue() != null) {
player.selectQueueItem(player.getPlayQueue().getItem((int) id));
}
}
@Override
public void onSkipToNext(@NonNull final Player player) {
callback.playNext();
public void onSkipToNext(@NonNull final com.google.android.exoplayer2.Player exoPlayer) {
player.playNext();
}
private void publishFloatingQueueWindow() {
if (callback.getQueueSize() == 0) {
final int windowCount = Optional.ofNullable(player.getPlayQueue())
.map(PlayQueue::size)
.orElse(0);
if (windowCount == 0) {
mediaSession.setQueue(Collections.emptyList());
activeQueueItemId = MediaSessionCompat.QueueItem.UNKNOWN_ID;
return;
}
// Yes this is almost a copypasta, got a problem with that? =\
final int windowCount = callback.getQueueSize();
final int currentWindowIndex = callback.getCurrentPlayingIndex();
final int queueSize = Math.min(maxQueueSize, windowCount);
final int currentWindowIndex = player.getPlayQueue().getIndex();
final int queueSize = Math.min(MAX_QUEUE_SIZE, windowCount);
final int startIndex = Util.constrainValue(currentWindowIndex - ((queueSize - 1) / 2), 0,
windowCount - queueSize);
final List<MediaSessionCompat.QueueItem> queue = new ArrayList<>();
for (int i = startIndex; i < startIndex + queueSize; i++) {
queue.add(new MediaSessionCompat.QueueItem(callback.getQueueMetadata(i), i));
queue.add(new MediaSessionCompat.QueueItem(getQueueMetadata(i), i));
}
mediaSession.setQueue(queue);
activeQueueItemId = currentWindowIndex;
}
public MediaDescriptionCompat getQueueMetadata(final int index) {
if (player.getPlayQueue() == null) {
return null;
}
final PlayQueueItem item = player.getPlayQueue().getItem(index);
if (item == null) {
return null;
}
final MediaDescriptionCompat.Builder descBuilder = new MediaDescriptionCompat.Builder()
.setMediaId(String.valueOf(index))
.setTitle(item.getTitle())
.setSubtitle(item.getUploader());
// set additional metadata for A2DP/AVRCP (Audio/Video Bluetooth profiles)
final Bundle additionalMetadata = new Bundle();
additionalMetadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, item.getTitle());
additionalMetadata.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, item.getUploader());
additionalMetadata
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, item.getDuration() * 1000);
additionalMetadata.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, index + 1L);
additionalMetadata
.putLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS, player.getPlayQueue().size());
descBuilder.setExtras(additionalMetadata);
final Uri thumbnailUri = Uri.parse(item.getThumbnailUrl());
if (thumbnailUri != null) {
descBuilder.setIconUri(thumbnailUri);
}
return descBuilder.build();
}
@Override
public boolean onCommand(@NonNull final Player player,
public boolean onCommand(@NonNull final com.google.android.exoplayer2.Player exoPlayer,
@NonNull final String command,
@Nullable final Bundle extras,
@Nullable final ResultReceiver cb) {

View File

@ -16,7 +16,7 @@ import org.schabi.newpipe.player.mediaitem.ExceptionTag;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
import androidx.annotation.NonNull;
@ -56,9 +56,7 @@ public class FailedMediaSource extends BaseMediaSource implements ManagedMediaSo
this.playQueueItem = playQueueItem;
this.error = error;
this.retryTimestamp = retryTimestamp;
this.mediaItem = ExceptionTag
.of(playQueueItem, Collections.singletonList(error))
.withExtras(this)
this.mediaItem = ExceptionTag.of(playQueueItem, List.of(error)).withExtras(this)
.asMediaItem();
}

View File

@ -1,4 +1,4 @@
package org.schabi.newpipe.player;
package org.schabi.newpipe.player.notification;
import android.content.Context;
import android.content.SharedPreferences;
@ -7,20 +7,48 @@ import androidx.annotation.DrawableRes;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
import org.schabi.newpipe.util.Localization;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.SortedSet;
import java.util.TreeSet;
public final class NotificationConstants {
private NotificationConstants() { }
private NotificationConstants() {
}
/*//////////////////////////////////////////////////////////////////////////
// Intent actions
//////////////////////////////////////////////////////////////////////////*/
private static final String BASE_ACTION =
App.PACKAGE_NAME + ".player.MainPlayer.";
public static final String ACTION_CLOSE =
BASE_ACTION + "CLOSE";
public static final String ACTION_PLAY_PAUSE =
BASE_ACTION + ".player.MainPlayer.PLAY_PAUSE";
public static final String ACTION_REPEAT =
BASE_ACTION + ".player.MainPlayer.REPEAT";
public static final String ACTION_PLAY_NEXT =
BASE_ACTION + ".player.MainPlayer.ACTION_PLAY_NEXT";
public static final String ACTION_PLAY_PREVIOUS =
BASE_ACTION + ".player.MainPlayer.ACTION_PLAY_PREVIOUS";
public static final String ACTION_FAST_REWIND =
BASE_ACTION + ".player.MainPlayer.ACTION_FAST_REWIND";
public static final String ACTION_FAST_FORWARD =
BASE_ACTION + ".player.MainPlayer.ACTION_FAST_FORWARD";
public static final String ACTION_SHUFFLE =
BASE_ACTION + ".player.MainPlayer.ACTION_SHUFFLE";
public static final String ACTION_RECREATE_NOTIFICATION =
BASE_ACTION + ".player.MainPlayer.ACTION_RECREATE_NOTIFICATION";
public static final int NOTHING = 0;
@ -86,7 +114,7 @@ public final class NotificationConstants {
};
public static final Integer[] SLOT_COMPACT_DEFAULTS = {0, 1, 2};
public static final List<Integer> SLOT_COMPACT_DEFAULTS = List.of(0, 1, 2);
public static final int[] SLOT_COMPACT_PREF_KEYS = {
R.string.notification_slot_compact_0_key,
@ -152,7 +180,7 @@ public final class NotificationConstants {
if (compactSlot == Integer.MAX_VALUE) {
// settings not yet populated, return default values
return new ArrayList<>(Arrays.asList(SLOT_COMPACT_DEFAULTS));
return new ArrayList<>(SLOT_COMPACT_DEFAULTS);
}
// a negative value (-1) is set when the user does not want a particular compact slot

View File

@ -0,0 +1,125 @@
package org.schabi.newpipe.player.notification;
import static org.schabi.newpipe.player.helper.PlayerHelper.MinimizeMode.MINIMIZE_ON_EXIT_MODE_NONE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_RECREATE_NOTIFICATION;
import android.content.Intent;
import android.graphics.Bitmap;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.Player.RepeatMode;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.helper.PlayerHelper;
import org.schabi.newpipe.player.ui.PlayerUi;
public final class NotificationPlayerUi extends PlayerUi {
private boolean foregroundNotificationAlreadyCreated = false;
private final NotificationUtil notificationUtil;
public NotificationPlayerUi(@NonNull final Player player) {
super(player);
notificationUtil = new NotificationUtil(player);
}
@Override
public void initPlayer() {
super.initPlayer();
if (!foregroundNotificationAlreadyCreated) {
notificationUtil.createNotificationAndStartForeground();
foregroundNotificationAlreadyCreated = true;
}
}
@Override
public void destroy() {
super.destroy();
notificationUtil.cancelNotificationAndStopForeground();
}
@Override
public void onThumbnailLoaded(@Nullable final Bitmap bitmap) {
super.onThumbnailLoaded(bitmap);
notificationUtil.updateThumbnail();
}
@Override
public void onBlocked() {
super.onBlocked();
notificationUtil.createNotificationIfNeededAndUpdate(false);
}
@Override
public void onPlaying() {
super.onPlaying();
notificationUtil.createNotificationIfNeededAndUpdate(false);
}
@Override
public void onBuffering() {
super.onBuffering();
if (notificationUtil.shouldUpdateBufferingSlot()) {
notificationUtil.createNotificationIfNeededAndUpdate(false);
}
}
@Override
public void onPaused() {
super.onPaused();
// Remove running notification when user does not want minimization to background or popup
if (PlayerHelper.getMinimizeOnExitAction(context) == MINIMIZE_ON_EXIT_MODE_NONE
&& player.videoPlayerSelected()) {
notificationUtil.cancelNotificationAndStopForeground();
} else {
notificationUtil.createNotificationIfNeededAndUpdate(false);
}
}
@Override
public void onPausedSeek() {
super.onPausedSeek();
notificationUtil.createNotificationIfNeededAndUpdate(false);
}
@Override
public void onCompleted() {
super.onCompleted();
notificationUtil.createNotificationIfNeededAndUpdate(false);
}
@Override
public void onRepeatModeChanged(@RepeatMode final int repeatMode) {
super.onRepeatModeChanged(repeatMode);
notificationUtil.createNotificationIfNeededAndUpdate(false);
}
@Override
public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) {
super.onShuffleModeEnabledChanged(shuffleModeEnabled);
notificationUtil.createNotificationIfNeededAndUpdate(false);
}
@Override
public void onBroadcastReceived(final Intent intent) {
super.onBroadcastReceived(intent);
if (ACTION_RECREATE_NOTIFICATION.equals(intent.getAction())) {
notificationUtil.createNotificationIfNeededAndUpdate(true);
}
}
@Override
public void onMetadataChanged(@NonNull final StreamInfo info) {
super.onMetadataChanged(info);
notificationUtil.createNotificationIfNeededAndUpdate(true);
}
@Override
public void onPlayQueueEdited() {
super.onPlayQueueEdited();
notificationUtil.createNotificationIfNeededAndUpdate(false);
}
}

View File

@ -1,16 +1,15 @@
package org.schabi.newpipe.player;
package org.schabi.newpipe.player.notification;
import android.annotation.SuppressLint;
import android.app.PendingIntent;
import android.app.Service;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.graphics.Bitmap;
import android.graphics.Matrix;
import android.os.Build;
import android.util.Log;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.app.NotificationCompat;
@ -20,48 +19,45 @@ import androidx.core.content.ContextCompat;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
import org.schabi.newpipe.util.NavigationHelper;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
import static androidx.media.app.NotificationCompat.MediaStyle;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
import static org.schabi.newpipe.player.MainPlayer.ACTION_CLOSE;
import static org.schabi.newpipe.player.MainPlayer.ACTION_FAST_FORWARD;
import static org.schabi.newpipe.player.MainPlayer.ACTION_FAST_REWIND;
import static org.schabi.newpipe.player.MainPlayer.ACTION_PLAY_NEXT;
import static org.schabi.newpipe.player.MainPlayer.ACTION_PLAY_PAUSE;
import static org.schabi.newpipe.player.MainPlayer.ACTION_PLAY_PREVIOUS;
import static org.schabi.newpipe.player.MainPlayer.ACTION_REPEAT;
import static org.schabi.newpipe.player.MainPlayer.ACTION_SHUFFLE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_FORWARD;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_REWIND;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_NEXT;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PREVIOUS;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_REPEAT;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE;
/**
* This is a utility class for player notifications.
*
* @author cool-student
*/
public final class NotificationUtil {
private static final String TAG = NotificationUtil.class.getSimpleName();
private static final boolean DEBUG = Player.DEBUG;
private static final int NOTIFICATION_ID = 123789;
@Nullable private static NotificationUtil instance = null;
@NotificationConstants.Action
private final int[] notificationSlots = NotificationConstants.SLOT_DEFAULTS.clone();
private NotificationManagerCompat notificationManager;
private NotificationCompat.Builder notificationBuilder;
private NotificationUtil() {
}
private final Player player;
public static NotificationUtil getInstance() {
if (instance == null) {
instance = new NotificationUtil();
}
return instance;
public NotificationUtil(final Player player) {
this.player = player;
}
@ -72,20 +68,31 @@ public final class NotificationUtil {
/**
* Creates the notification if it does not exist already and recreates it if forceRecreate is
* true. Updates the notification with the data in the player.
* @param player the player currently open, to take data from
* @param forceRecreate whether to force the recreation of the notification even if it already
* exists
*/
synchronized void createNotificationIfNeededAndUpdate(final Player player,
final boolean forceRecreate) {
public synchronized void createNotificationIfNeededAndUpdate(final boolean forceRecreate) {
if (forceRecreate || notificationBuilder == null) {
notificationBuilder = createNotification(player);
notificationBuilder = createNotification();
}
updateNotification(player);
updateNotification();
notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build());
}
private synchronized NotificationCompat.Builder createNotification(final Player player) {
public synchronized void updateThumbnail() {
if (notificationBuilder != null) {
if (DEBUG) {
Log.d(TAG, "updateThumbnail() called with thumbnail = [" + Integer.toHexString(
Optional.ofNullable(player.getThumbnail()).map(Objects::hashCode).orElse(0))
+ "], title = [" + player.getVideoTitle() + "]");
}
setLargeIcon(notificationBuilder);
notificationManager.notify(NOTIFICATION_ID, notificationBuilder.build());
}
}
private synchronized NotificationCompat.Builder createNotification() {
if (DEBUG) {
Log.d(TAG, "createNotification()");
}
@ -94,7 +101,7 @@ public final class NotificationUtil {
new NotificationCompat.Builder(player.getContext(),
player.getContext().getString(R.string.notification_channel_id));
initializeNotificationSlots(player);
initializeNotificationSlots();
// count the number of real slots, to make sure compact slots indices are not out of bound
int nonNothingSlotCount = 5;
@ -108,14 +115,15 @@ public final class NotificationUtil {
// build the compact slot indices array (need code to convert from Integer... because Java)
final List<Integer> compactSlotList = NotificationConstants.getCompactSlotsFromPreferences(
player.getContext(), player.getPrefs(), nonNothingSlotCount);
final int[] compactSlots = new int[compactSlotList.size()];
for (int i = 0; i < compactSlotList.size(); i++) {
compactSlots[i] = compactSlotList.get(i);
}
final int[] compactSlots = compactSlotList.stream().mapToInt(Integer::intValue).toArray();
builder.setStyle(new androidx.media.app.NotificationCompat.MediaStyle()
.setMediaSession(player.getMediaSessionManager().getSessionToken())
.setShowActionsInCompactView(compactSlots))
final MediaStyle mediaStyle = new MediaStyle().setShowActionsInCompactView(compactSlots);
player.UIs()
.get(MediaSessionPlayerUi.class)
.flatMap(MediaSessionPlayerUi::getSessionToken)
.ifPresent(mediaStyle::setMediaSession);
builder.setStyle(mediaStyle)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
@ -128,35 +136,33 @@ public final class NotificationUtil {
.setDeleteIntent(PendingIntent.getBroadcast(player.getContext(), NOTIFICATION_ID,
new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT));
// set the initial value for the video thumbnail, updatable with updateNotificationThumbnail
setLargeIcon(builder);
return builder;
}
/**
* Updates the notification builder and the button icons depending on the playback state.
* @param player the player currently open, to take data from
*/
private synchronized void updateNotification(final Player player) {
private synchronized void updateNotification() {
if (DEBUG) {
Log.d(TAG, "updateNotification()");
}
// also update content intent, in case the user switched players
notificationBuilder.setContentIntent(PendingIntent.getActivity(player.getContext(),
NOTIFICATION_ID, getIntentForNotification(player), FLAG_UPDATE_CURRENT));
NOTIFICATION_ID, getIntentForNotification(), FLAG_UPDATE_CURRENT));
notificationBuilder.setContentTitle(player.getVideoTitle());
notificationBuilder.setContentText(player.getUploaderName());
notificationBuilder.setTicker(player.getVideoTitle());
updateActions(notificationBuilder, player);
final boolean showThumbnail = player.getPrefs().getBoolean(
player.getContext().getString(R.string.show_thumbnail_key), true);
if (showThumbnail) {
setLargeIcon(notificationBuilder, player);
}
updateActions(notificationBuilder);
}
@SuppressLint("RestrictedApi")
boolean shouldUpdateBufferingSlot() {
public boolean shouldUpdateBufferingSlot() {
if (notificationBuilder == null) {
// if there is no notification active, there is no point in updating it
return false;
@ -174,22 +180,22 @@ public final class NotificationUtil {
}
void createNotificationAndStartForeground(final Player player, final Service service) {
public void createNotificationAndStartForeground() {
if (notificationBuilder == null) {
notificationBuilder = createNotification(player);
notificationBuilder = createNotification();
}
updateNotification(player);
updateNotification();
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
service.startForeground(NOTIFICATION_ID, notificationBuilder.build(),
player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
} else {
service.startForeground(NOTIFICATION_ID, notificationBuilder.build());
player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build());
}
}
void cancelNotificationAndStopForeground(final Service service) {
ServiceCompat.stopForeground(service, ServiceCompat.STOP_FOREGROUND_REMOVE);
public void cancelNotificationAndStopForeground() {
ServiceCompat.stopForeground(player.getService(), ServiceCompat.STOP_FOREGROUND_REMOVE);
if (notificationManager != null) {
notificationManager.cancel(NOTIFICATION_ID);
@ -203,7 +209,7 @@ public final class NotificationUtil {
// ACTIONS
/////////////////////////////////////////////////////
private void initializeNotificationSlots(final Player player) {
private void initializeNotificationSlots() {
for (int i = 0; i < 5; ++i) {
notificationSlots[i] = player.getPrefs().getInt(
player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]),
@ -212,17 +218,16 @@ public final class NotificationUtil {
}
@SuppressLint("RestrictedApi")
private void updateActions(final NotificationCompat.Builder builder, final Player player) {
private void updateActions(final NotificationCompat.Builder builder) {
builder.mActions.clear();
for (int i = 0; i < 5; ++i) {
addAction(builder, player, notificationSlots[i]);
addAction(builder, notificationSlots[i]);
}
}
private void addAction(final NotificationCompat.Builder builder,
final Player player,
@NotificationConstants.Action final int slot) {
final NotificationCompat.Action action = getAction(player, slot);
final NotificationCompat.Action action = getAction(slot);
if (action != null) {
builder.addAction(action);
}
@ -230,41 +235,40 @@ public final class NotificationUtil {
@Nullable
private NotificationCompat.Action getAction(
final Player player,
@NotificationConstants.Action final int selectedAction) {
final int baseActionIcon = NotificationConstants.ACTION_ICONS[selectedAction];
switch (selectedAction) {
case NotificationConstants.PREVIOUS:
return getAction(player, baseActionIcon,
return getAction(baseActionIcon,
R.string.exo_controls_previous_description, ACTION_PLAY_PREVIOUS);
case NotificationConstants.NEXT:
return getAction(player, baseActionIcon,
return getAction(baseActionIcon,
R.string.exo_controls_next_description, ACTION_PLAY_NEXT);
case NotificationConstants.REWIND:
return getAction(player, baseActionIcon,
return getAction(baseActionIcon,
R.string.exo_controls_rewind_description, ACTION_FAST_REWIND);
case NotificationConstants.FORWARD:
return getAction(player, baseActionIcon,
return getAction(baseActionIcon,
R.string.exo_controls_fastforward_description, ACTION_FAST_FORWARD);
case NotificationConstants.SMART_REWIND_PREVIOUS:
if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
return getAction(player, R.drawable.exo_notification_previous,
return getAction(R.drawable.exo_notification_previous,
R.string.exo_controls_previous_description, ACTION_PLAY_PREVIOUS);
} else {
return getAction(player, R.drawable.exo_controls_rewind,
return getAction(R.drawable.exo_controls_rewind,
R.string.exo_controls_rewind_description, ACTION_FAST_REWIND);
}
case NotificationConstants.SMART_FORWARD_NEXT:
if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
return getAction(player, R.drawable.exo_notification_next,
return getAction(R.drawable.exo_notification_next,
R.string.exo_controls_next_description, ACTION_PLAY_NEXT);
} else {
return getAction(player, R.drawable.exo_controls_fastforward,
return getAction(R.drawable.exo_controls_fastforward,
R.string.exo_controls_fastforward_description, ACTION_FAST_FORWARD);
}
@ -278,44 +282,45 @@ public final class NotificationUtil {
null);
}
// fallthrough
case NotificationConstants.PLAY_PAUSE:
if (player.getCurrentState() == Player.STATE_COMPLETED) {
return getAction(player, R.drawable.ic_replay,
return getAction(R.drawable.ic_replay,
R.string.exo_controls_pause_description, ACTION_PLAY_PAUSE);
} else if (player.isPlaying()
|| player.getCurrentState() == Player.STATE_PREFLIGHT
|| player.getCurrentState() == Player.STATE_BLOCKED
|| player.getCurrentState() == Player.STATE_BUFFERING) {
return getAction(player, R.drawable.exo_notification_pause,
return getAction(R.drawable.exo_notification_pause,
R.string.exo_controls_pause_description, ACTION_PLAY_PAUSE);
} else {
return getAction(player, R.drawable.exo_notification_play,
return getAction(R.drawable.exo_notification_play,
R.string.exo_controls_play_description, ACTION_PLAY_PAUSE);
}
case NotificationConstants.REPEAT:
if (player.getRepeatMode() == REPEAT_MODE_ALL) {
return getAction(player, R.drawable.exo_media_action_repeat_all,
return getAction(R.drawable.exo_media_action_repeat_all,
R.string.exo_controls_repeat_all_description, ACTION_REPEAT);
} else if (player.getRepeatMode() == REPEAT_MODE_ONE) {
return getAction(player, R.drawable.exo_media_action_repeat_one,
return getAction(R.drawable.exo_media_action_repeat_one,
R.string.exo_controls_repeat_one_description, ACTION_REPEAT);
} else /* player.getRepeatMode() == REPEAT_MODE_OFF */ {
return getAction(player, R.drawable.exo_media_action_repeat_off,
return getAction(R.drawable.exo_media_action_repeat_off,
R.string.exo_controls_repeat_off_description, ACTION_REPEAT);
}
case NotificationConstants.SHUFFLE:
if (player.getPlayQueue() != null && player.getPlayQueue().isShuffled()) {
return getAction(player, R.drawable.exo_controls_shuffle_on,
return getAction(R.drawable.exo_controls_shuffle_on,
R.string.exo_controls_shuffle_on_description, ACTION_SHUFFLE);
} else {
return getAction(player, R.drawable.exo_controls_shuffle_off,
return getAction(R.drawable.exo_controls_shuffle_off,
R.string.exo_controls_shuffle_off_description, ACTION_SHUFFLE);
}
case NotificationConstants.CLOSE:
return getAction(player, R.drawable.ic_close,
return getAction(R.drawable.ic_close,
R.string.close, ACTION_CLOSE);
case NotificationConstants.NOTHING:
@ -325,8 +330,7 @@ public final class NotificationUtil {
}
}
private NotificationCompat.Action getAction(final Player player,
@DrawableRes final int drawable,
private NotificationCompat.Action getAction(@DrawableRes final int drawable,
@StringRes final int title,
final String intentAction) {
return new NotificationCompat.Action(drawable, player.getContext().getString(title),
@ -334,7 +338,7 @@ public final class NotificationUtil {
new Intent(intentAction), FLAG_UPDATE_CURRENT));
}
private Intent getIntentForNotification(final Player player) {
private Intent getIntentForNotification() {
if (player.audioPlayerSelected() || player.popupPlayerSelected()) {
// Means we play in popup or audio only. Let's show the play queue
return NavigationHelper.getPlayQueueActivityIntent(player.getContext());
@ -354,28 +358,34 @@ public final class NotificationUtil {
// BITMAP
/////////////////////////////////////////////////////
private void setLargeIcon(final NotificationCompat.Builder builder, final Player player) {
private void setLargeIcon(final NotificationCompat.Builder builder) {
final boolean showThumbnail = player.getPrefs().getBoolean(
player.getContext().getString(R.string.show_thumbnail_key), true);
final Bitmap thumbnail = player.getThumbnail();
if (thumbnail == null || !showThumbnail) {
// since the builder is reused, make sure the thumbnail is unset if there is not one
builder.setLargeIcon(null);
return;
}
final boolean scaleImageToSquareAspectRatio = player.getPrefs().getBoolean(
player.getContext().getString(R.string.scale_to_square_image_in_notifications_key),
false);
if (scaleImageToSquareAspectRatio) {
builder.setLargeIcon(getBitmapWithSquareAspectRatio(player.getThumbnail()));
builder.setLargeIcon(getBitmapWithSquareAspectRatio(thumbnail));
} else {
builder.setLargeIcon(player.getThumbnail());
builder.setLargeIcon(thumbnail);
}
}
private Bitmap getBitmapWithSquareAspectRatio(final Bitmap bitmap) {
return getResizedBitmap(bitmap, bitmap.getWidth(), bitmap.getWidth());
}
private Bitmap getResizedBitmap(final Bitmap bitmap, final int newWidth, final int newHeight) {
final int width = bitmap.getWidth();
final int height = bitmap.getHeight();
final float scaleWidth = ((float) newWidth) / width;
final float scaleHeight = ((float) newHeight) / height;
final Matrix matrix = new Matrix();
matrix.postScale(scaleWidth, scaleHeight);
return Bitmap.createBitmap(bitmap, 0, 0, width, height, matrix, false);
private Bitmap getBitmapWithSquareAspectRatio(@NonNull final Bitmap bitmap) {
// Find the smaller dimension and then take a center portion of the image that
// has that size.
final int w = bitmap.getWidth();
final int h = bitmap.getHeight();
final int dstSize = Math.min(w, h);
final int x = (w - dstSize) / 2;
final int y = (h - dstSize) / 2;
return Bitmap.createBitmap(bitmap, x, y, dstSize, dstSize);
}
}

View File

@ -1,99 +0,0 @@
package org.schabi.newpipe.player.playback;
import android.net.Uri;
import android.os.Bundle;
import android.support.v4.media.MediaDescriptionCompat;
import android.support.v4.media.MediaMetadataCompat;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.mediasession.MediaSessionCallback;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
public class PlayerMediaSession implements MediaSessionCallback {
private final Player player;
public PlayerMediaSession(final Player player) {
this.player = player;
}
@Override
public void playPrevious() {
player.playPrevious();
}
@Override
public void playNext() {
player.playNext();
}
@Override
public void playItemAtIndex(final int index) {
if (player.getPlayQueue() == null) {
return;
}
player.selectQueueItem(player.getPlayQueue().getItem(index));
}
@Override
public int getCurrentPlayingIndex() {
if (player.getPlayQueue() == null) {
return -1;
}
return player.getPlayQueue().getIndex();
}
@Override
public int getQueueSize() {
if (player.getPlayQueue() == null) {
return -1;
}
return player.getPlayQueue().size();
}
@Override
public MediaDescriptionCompat getQueueMetadata(final int index) {
if (player.getPlayQueue() == null) {
return null;
}
final PlayQueueItem item = player.getPlayQueue().getItem(index);
if (item == null) {
return null;
}
final MediaDescriptionCompat.Builder descriptionBuilder
= new MediaDescriptionCompat.Builder()
.setMediaId(String.valueOf(index))
.setTitle(item.getTitle())
.setSubtitle(item.getUploader());
// set additional metadata for A2DP/AVRCP
final Bundle additionalMetadata = new Bundle();
additionalMetadata.putString(MediaMetadataCompat.METADATA_KEY_TITLE, item.getTitle());
additionalMetadata.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, item.getUploader());
additionalMetadata
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, item.getDuration() * 1000);
additionalMetadata.putLong(MediaMetadataCompat.METADATA_KEY_TRACK_NUMBER, index + 1);
additionalMetadata
.putLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS, player.getPlayQueue().size());
descriptionBuilder.setExtras(additionalMetadata);
final Uri thumbnailUri = Uri.parse(item.getThumbnailUrl());
if (thumbnailUri != null) {
descriptionBuilder.setIconUri(thumbnailUri);
}
return descriptionBuilder.build();
}
@Override
public void play() {
player.play();
// hide the player controls even if the play command came from the media session
player.hideControls(0, 0);
}
@Override
public void pause() {
player.pause();
}
}

View File

@ -4,7 +4,7 @@ import android.content.Context;
import android.view.SurfaceHolder;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.video.DummySurface;
import com.google.android.exoplayer2.video.PlaceholderSurface;
/**
* Prevent error message: 'Unrecoverable player error occurred'
@ -26,7 +26,7 @@ public final class SurfaceHolderCallback implements SurfaceHolder.Callback {
private final Context context;
private final Player player;
private DummySurface dummySurface;
private PlaceholderSurface placeholderSurface;
public SurfaceHolderCallback(final Context context, final Player player) {
this.context = context;
@ -47,16 +47,16 @@ public final class SurfaceHolderCallback implements SurfaceHolder.Callback {
@Override
public void surfaceDestroyed(final SurfaceHolder holder) {
if (dummySurface == null) {
dummySurface = DummySurface.newInstanceV17(context, false);
if (placeholderSurface == null) {
placeholderSurface = PlaceholderSurface.newInstanceV17(context, false);
}
player.setVideoSurface(dummySurface);
player.setVideoSurface(placeholderSurface);
}
public void release() {
if (dummySurface != null) {
dummySurface.release();
dummySurface = null;
if (placeholderSurface != null) {
placeholderSurface.release();
placeholderSurface = null;
}
}
}

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