mirror of
https://github.com/TeamNewPipe/NewPipe.git
synced 2025-10-03 09:49:21 +02:00
Compare commits
307 commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
965eea2124 | ||
![]() |
59dfdda95e | ||
![]() |
3a2d427a46 | ||
![]() |
c25f83da6c | ||
![]() |
e2026dc378 | ||
![]() |
00f6203904 | ||
![]() |
980e8f3951 | ||
![]() |
4e9a480fdd | ||
![]() |
aa2b4821e2 | ||
![]() |
92a07a3445 | ||
![]() |
eed09f8a1d | ||
![]() |
fd3f030d0b | ||
![]() |
45c22c0db8 | ||
![]() |
2b7c72eb69 | ||
![]() |
89c4eb5237 | ||
![]() |
803aba4935 | ||
![]() |
1723bf0e8a | ||
![]() |
21e24c9e34 | ||
![]() |
eb277fe14b | ||
![]() |
d77771a60c | ||
![]() |
01f9a3de33 | ||
![]() |
150649aea9 | ||
![]() |
3803d49489 | ||
![]() |
25a4a9a253 | ||
![]() |
d534946550 | ||
![]() |
8fb3e90fe1 | ||
![]() |
5750ef6aa8 | ||
![]() |
ab7d1377e5 | ||
![]() |
fd24c08529 | ||
![]() |
e14ec3a4f9 | ||
![]() |
b592403a66 | ||
![]() |
90e1ac56ef | ||
![]() |
32eb3afe16 | ||
![]() |
83a0abddcc | ||
![]() |
35c7f2f5d1 | ||
![]() |
8afb00d2f0 | ||
![]() |
f27ec53c08 | ||
![]() |
a3ddd616f9 | ||
![]() |
79980e2078 | ||
![]() |
b204fad9d5 | ||
![]() |
08f51abefb | ||
![]() |
204df4c45a | ||
![]() |
989c0cfd28 | ||
![]() |
a369deeef4 | ||
![]() |
1bde2dcd9f | ||
![]() |
29a3ca83b5 | ||
![]() |
38064be702 | ||
![]() |
d17eae9bad | ||
![]() |
74562db965 | ||
![]() |
386d5197d8 | ||
![]() |
ccd76dea1f | ||
![]() |
9282cce6a8 | ||
![]() |
7644066c5a | ||
![]() |
9bc8139b8c | ||
![]() |
ff3526b28d | ||
![]() |
d6c0dc32d1 | ||
![]() |
124ab56c5f | ||
![]() |
95a0e0ca39 | ||
![]() |
4d97a7653d | ||
![]() |
5aefa4aff2 | ||
![]() |
b846746119 | ||
![]() |
b7b836e941 | ||
![]() |
d96c0aebb1 | ||
![]() |
8400a9ae8e | ||
![]() |
7cecd11f72 | ||
![]() |
ed93603815 | ||
![]() |
56f79fac13 | ||
![]() |
86efde5996 | ||
![]() |
ca9fc14c2a | ||
![]() |
7130adb4ec | ||
![]() |
e08d2d8726 | ||
![]() |
ef29c318b0 | ||
![]() |
6516fb96fd | ||
![]() |
e9922fe162 | ||
![]() |
eea2b7417e | ||
![]() |
893a1cb699 | ||
![]() |
ebd5e1a318 | ||
![]() |
70841db92f | ||
![]() |
859555e129 | ||
![]() |
c1cef19b33 | ||
![]() |
9ba30887f9 | ||
![]() |
0ef38e3a4d | ||
![]() |
9f11db8e06 | ||
![]() |
fece0741e5 | ||
![]() |
a9ce2e9605 | ||
![]() |
b9b47fc520 | ||
![]() |
59db955493 | ||
![]() |
22a709d53b | ||
![]() |
329d76c857 | ||
![]() |
9f526e8e8f | ||
![]() |
50caba6606 | ||
![]() |
26443f9f14 | ||
![]() |
366129eee2 | ||
![]() |
4c8d44b6ba | ||
![]() |
14cd562ebd | ||
![]() |
04ef608f7a | ||
![]() |
71fcc5ebce | ||
![]() |
30e33d59e8 | ||
![]() |
a4bd82be8a | ||
![]() |
45589dbf26 | ||
![]() |
99ae3fdd4e | ||
![]() |
f48e73eb2a | ||
![]() |
99003bab07 | ||
![]() |
9e14f93186 | ||
![]() |
abd9aade87 | ||
![]() |
b8f9c125cd | ||
![]() |
893a227ab1 | ||
![]() |
0db859e225 | ||
![]() |
e61f98bd47 | ||
![]() |
991d9ea3df | ||
![]() |
f94892166d | ||
![]() |
9697112db6 | ||
![]() |
f64dba0107 | ||
![]() |
9bf01e1241 | ||
![]() |
474efbebc1 | ||
![]() |
fe58ec85ed | ||
![]() |
941f85781b | ||
![]() |
7e0ee4eb7a | ||
![]() |
4a41214df4 | ||
![]() |
938265d127 | ||
![]() |
ba4e7a3c7f | ||
![]() |
58b5ccb66f | ||
![]() |
4e94b2602d | ||
![]() |
4ddc0648ef | ||
![]() |
4c920a4406 | ||
![]() |
1c0eabf75c | ||
![]() |
f119a368d8 | ||
![]() |
f3c20d43be | ||
![]() |
c9559fa801 | ||
![]() |
8ab79488e9 | ||
![]() |
f0b26e208b | ||
![]() |
79084568f2 | ||
![]() |
705b5e5580 | ||
![]() |
a4d457b2b2 | ||
![]() |
834c93f22a | ||
![]() |
a0adeb0099 | ||
![]() |
2dd11f70a3 | ||
![]() |
d048bca8b4 | ||
![]() |
0c9f5ddcaf | ||
![]() |
aa75a1449f | ||
![]() |
16e32dfc96 | ||
![]() |
8c4a789f78 | ||
![]() |
769e98acd0 | ||
![]() |
8e036b5e69 | ||
![]() |
571b7bc74b | ||
![]() |
033cc08c26 | ||
![]() |
205d18f4c4 | ||
![]() |
712724211c | ||
![]() |
fd09e6147f | ||
![]() |
279caac915 | ||
![]() |
f8ed8e575e | ||
![]() |
436626fa83 | ||
![]() |
7c3989ff93 | ||
![]() |
e6c4690e7d | ||
![]() |
86869f0a14 | ||
![]() |
aa0b45c05f | ||
![]() |
55bf74b4a7 | ||
![]() |
de3d11568d | ||
![]() |
16077dee80 | ||
![]() |
c9155f7834 | ||
![]() |
7dd1abdf9c | ||
![]() |
f3858e70a3 | ||
![]() |
76202e6b4b | ||
![]() |
90e2f234e7 | ||
![]() |
42a52b7118 | ||
![]() |
d9dccfa8af | ||
![]() |
e554c77f2e | ||
![]() |
81b4e3f970 | ||
![]() |
ef068e1eca | ||
![]() |
8407b5aefd | ||
![]() |
b6aa07545a | ||
![]() |
1dcb1953ba | ||
![]() |
862a8e8f26 | ||
![]() |
88395fa852 | ||
![]() |
8d679626f0 | ||
![]() |
d2dc20c551 | ||
![]() |
e7f3750f5e | ||
![]() |
48e826e912 | ||
![]() |
088cb8353e | ||
![]() |
5ca544bc42 | ||
![]() |
aa1b7f8584 | ||
![]() |
ce16c6df5f | ||
![]() |
276bf390b2 | ||
![]() |
f39eda086f | ||
![]() |
756327da39 | ||
![]() |
5840d3a437 | ||
![]() |
e1dedd45ed | ||
![]() |
912f07a1dd | ||
![]() |
205466c56a | ||
![]() |
7f10312d0a | ||
![]() |
63be3220e7 | ||
![]() |
536b78f2e6 | ||
![]() |
6d6b73ef73 | ||
![]() |
196c27792b | ||
![]() |
b3789315ad | ||
![]() |
c7bf498c04 | ||
![]() |
35abb99dac | ||
![]() |
70416e73f3 | ||
![]() |
a0b76c3385 | ||
![]() |
c232193a46 | ||
![]() |
f289bea6b3 | ||
![]() |
48b200868a | ||
![]() |
54bf7f0ced | ||
![]() |
980a35a708 | ||
![]() |
da106e2361 | ||
![]() |
3532ac96b4 | ||
![]() |
87693a2ad1 | ||
![]() |
d321e57620 | ||
![]() |
fb4a65a14a | ||
![]() |
3047704e1c | ||
![]() |
3dcfdaf510 | ||
![]() |
2ceb70236e | ||
![]() |
be097f26c8 | ||
![]() |
098f60d593 | ||
![]() |
eb0568044a | ||
![]() |
f3b3d5c3e7 | ||
![]() |
b888dc72cf | ||
![]() |
599d86151a | ||
![]() |
587df093ea | ||
![]() |
8830e87242 | ||
![]() |
f96b8f7b2a | ||
![]() |
c28478ae53 | ||
![]() |
10110397fd | ||
![]() |
d81244e77c | ||
![]() |
ea20ca9e72 | ||
![]() |
f0c89494dd | ||
![]() |
0fd2d4fed6 | ||
![]() |
3c7b026d7d | ||
![]() |
998d84de6c | ||
![]() |
76a02d5858 | ||
![]() |
24bb71a23f | ||
![]() |
49b71942ad | ||
![]() |
c9ec257a5e | ||
![]() |
b1f995a78c | ||
![]() |
acac50a1d1 | ||
![]() |
c6b87cd316 | ||
![]() |
94d4c21cc7 | ||
![]() |
a7a7dc5363 | ||
![]() |
126f4b0e30 | ||
![]() |
6558794d26 | ||
![]() |
1d12874937 | ||
![]() |
1d98518bfa | ||
![]() |
e5458bcb14 | ||
![]() |
dc62d211f5 | ||
![]() |
ec6612dd71 | ||
![]() |
064e1d39c7 | ||
![]() |
4c88a193bd | ||
![]() |
3fcac10e7f | ||
![]() |
6cedd117fe | ||
![]() |
5eabcb52b5 | ||
![]() |
690b40d0c4 | ||
![]() |
9bb2c0b484 | ||
![]() |
1e08cc8c8f | ||
![]() |
7d17468266 | ||
![]() |
5819546ea9 | ||
![]() |
cfb6e114d6 | ||
![]() |
b764ad33c4 | ||
![]() |
430b4eb916 | ||
![]() |
2339f51ad4 | ||
![]() |
c6e1721884 | ||
![]() |
94684fe380 | ||
![]() |
398a2f55ce | ||
![]() |
1f7b3b5b06 | ||
![]() |
909ed616c4 | ||
![]() |
dd223af28d | ||
![]() |
dbee8d8128 | ||
![]() |
b62a09b5b3 | ||
![]() |
87317c6faf | ||
![]() |
53b599b042 | ||
![]() |
21df24abfd | ||
![]() |
ca4592a935 | ||
![]() |
3fc487310b | ||
![]() |
056809cb0d | ||
![]() |
a60bb3e7af | ||
![]() |
ecd3f6c2ee | ||
![]() |
70ff47b810 | ||
![]() |
b8e050f6c4 | ||
![]() |
46d0bc1004 | ||
![]() |
e7fe84f2c7 | ||
![]() |
2b183a0576 | ||
![]() |
f856bd9306 | ||
![]() |
0066b322e1 | ||
![]() |
3bdae81c0a | ||
![]() |
6010c4ea7f | ||
![]() |
690b3410e9 | ||
![]() |
ba86ce137b | ||
![]() |
410c01547c | ||
![]() |
47263f5254 | ||
![]() |
01bf855015 | ||
![]() |
ebf3008729 | ||
![]() |
33ecfb757e | ||
![]() |
ffe26d882b | ||
![]() |
83f8141fe7 | ||
![]() |
9253640fae | ||
![]() |
8b5aa5cd9b | ||
![]() |
a955408053 | ||
![]() |
86203d6800 | ||
![]() |
edd19641ac | ||
![]() |
65749cbac0 | ||
![]() |
658ddfc921 | ||
![]() |
f7d0fd545d | ||
![]() |
27e6be792f | ||
![]() |
3fc0147f47 | ||
![]() |
c6b05c6094 | ||
![]() |
240a2fe36b | ||
![]() |
de46e3abb3 | ||
![]() |
70748fa0bc |
552 changed files with 9874 additions and 2881 deletions
2
.github/CONTRIBUTING.md
vendored
2
.github/CONTRIBUTING.md
vendored
|
@ -6,7 +6,7 @@ NewPipe contribution guidelines
|
|||
## Crash reporting
|
||||
|
||||
Report crashes through the **automated crash report system** of NewPipe.
|
||||
This way all the data needed for debugging is included in your bugreport for GitHub.
|
||||
This way all the data needed for debugging is included in your bug report for GitHub.
|
||||
You'll see *exactly* what is sent, be able to add **your comments**, and then send it.
|
||||
|
||||
## Issue reporting/feature requests
|
||||
|
|
2
.github/workflows/build-release-apk.yml
vendored
2
.github/workflows/build-release-apk.yml
vendored
|
@ -14,7 +14,7 @@ jobs:
|
|||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
java-version: '21'
|
||||
cache: 'gradle'
|
||||
|
||||
- name: "Build release APK"
|
||||
|
|
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
|
@ -72,8 +72,8 @@ jobs:
|
|||
- api-level: 21
|
||||
target: default
|
||||
arch: x86
|
||||
- api-level: 33
|
||||
target: google_apis # emulator API 33 only exists with Google APIs
|
||||
- api-level: 35
|
||||
target: default
|
||||
arch: x86_64
|
||||
|
||||
permissions:
|
||||
|
@ -111,6 +111,7 @@ jobs:
|
|||
path: app/build/reports/androidTests/connected/**
|
||||
|
||||
sonar:
|
||||
if: ${{ false }} # the key has expired and needs to be regenerated by the sonar admins
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
|
|
8
.github/workflows/image-minimizer.js
vendored
8
.github/workflows/image-minimizer.js
vendored
|
@ -32,12 +32,12 @@ module.exports = async ({github, context}) => {
|
|||
}
|
||||
|
||||
// Regex for finding images (simple variant) 
|
||||
const REGEX_USER_CONTENT_IMAGE_LOOKUP = /\!\[(.*)\]\((https:\/\/[-a-z0-9]+\.githubusercontent\.com\/\d+\/[-0-9a-f]{32,512}\.(jpg|gif|png))\)/gm;
|
||||
const REGEX_ASSETS_IMAGE_LOCKUP = /\!\[(.*)\]\((https:\/\/github\.com\/[-\w\d]+\/[-\w\d]+\/assets\/\d+\/[\-0-9a-f]{32,512})\)/gm;
|
||||
const REGEX_USER_CONTENT_IMAGE_LOOKUP = /\!\[([^\]]*)\]\((https:\/\/[-a-z0-9]+\.githubusercontent\.com\/\d+\/[-0-9a-f]{32,512}\.(jpg|gif|png))\)/gm;
|
||||
const REGEX_ASSETS_IMAGE_LOOKUP = /\!\[([^\]]*)\]\((https:\/\/github\.com\/(?:user-attachments\/assets|[-\w\d]+\/[-\w\d]+\/assets\/\d+)\/[\-0-9a-f]{32,512})\)/gm;
|
||||
|
||||
// Check if we found something
|
||||
let foundSimpleImages = REGEX_USER_CONTENT_IMAGE_LOOKUP.test(initialBody)
|
||||
|| REGEX_ASSETS_IMAGE_LOCKUP.test(initialBody);
|
||||
|| REGEX_ASSETS_IMAGE_LOOKUP.test(initialBody);
|
||||
if (!foundSimpleImages) {
|
||||
console.log('Found no simple images to process');
|
||||
return;
|
||||
|
@ -52,7 +52,7 @@ module.exports = async ({github, context}) => {
|
|||
|
||||
// Try to find and replace the images with minimized ones
|
||||
let newBody = await replaceAsync(initialBody, REGEX_USER_CONTENT_IMAGE_LOOKUP, minimizeAsync);
|
||||
newBody = await replaceAsync(newBody, REGEX_ASSETS_IMAGE_LOCKUP, minimizeAsync);
|
||||
newBody = await replaceAsync(newBody, REGEX_ASSETS_IMAGE_LOOKUP, minimizeAsync);
|
||||
|
||||
if (!wasMatchModified) {
|
||||
console.log('Nothing was modified. Skipping update');
|
||||
|
|
16
README.md
16
README.md
|
@ -1,20 +1,26 @@
|
|||
<h3 align="center">We are planning to <i>rewrite</i> large chunks of the codebase, to bring about <a href="https://github.com/TeamNewPipe/NewPipe/discussions/10118">a new, modern and stable NewPipe</a>!</h3>
|
||||
<h4 align="center">Please do <b>not</b> open pull requests for <i>new features</i> now, only bugfix PRs will be accepted.</h4>
|
||||
<h3 align="center">We are <i>rewriting</i> large chunks of the codebase, to bring about <a href="https://newpipe.net/blog/pinned/announcement/newpipe-0.27.6-rewrite-team-states/#the-refactor">a modern and stable NewPipe</a>! You can download nightly builds <a href="https://github.com/TeamNewPipe/NewPipe-refactor-nightly/releases">here</a>.</h3>
|
||||
<h4 align="center">Please work on the <code>refactor</code> branch if you want to contribute <i>new features</i>. The current codebase is in maintenance mode and will only receive <i>bugfixes</i>.</h4>
|
||||
|
||||
<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 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>
|
||||
<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" width=206/></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe/releases" alt="GitHub release"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe.svg" ></a>
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe/releases" alt="GitHub NewPipe releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe.svg" ></a>
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe-nightly/releases" alt="GitHub NewPipe nightly releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe-nightly.svg?labelColor=purple&label=dev%20nightly"></a>
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe-refactor-nightly/releases" alt="GitHub NewPipe refactor nightly releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe-refactor-nightly.svg?labelColor=purple&label=refactor%20nightly"></a>
|
||||
<a href="https://www.gnu.org/licenses/gpl-3.0" alt="License: GPLv3"><img src="https://img.shields.io/badge/License-GPL%20v3-blue.svg"></a>
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe/actions" alt="Build Status"><img src="https://github.com/TeamNewPipe/NewPipe/workflows/CI/badge.svg?branch=dev&event=push"></a>
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe/actions" alt="Build Status"><img src="https://github.com/TeamNewPipe/NewPipe/actions/workflows/ci.yml/badge.svg?branch=dev&event=push"></a>
|
||||
<a href="https://hosted.weblate.org/engage/newpipe/" alt="Translation Status"><img src="https://hosted.weblate.org/widgets/newpipe/-/svg-badge.svg"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://web.libera.chat/#newpipe" alt="IRC channel: #newpipe"><img src="https://img.shields.io/badge/IRC%20chat-%23newpipe-brightgreen.svg"></a>
|
||||
<a href="https://matrix.to/#/#newpipe:matrix.newpipe-ev.de" alt="Matrix channel: #newpipe"><img src="https://img.shields.io/badge/Matrix%20chat-%23newpipe-blue"></a>
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
<p align="center"><a href="#screenshots">Screenshots</a> • <a href="#supported-services">Supported Services</a> • <a href="#description">Description</a> • <a href="#features">Features</a> • <a href="#installation-and-updates">Installation and updates</a> • <a href="#contribution">Contribution</a> • <a href="#donate">Donate</a> • <a href="#license">License</a></p>
|
||||
<p align="center"><a href="https://newpipe.net">Website</a> • <a href="https://newpipe.net/blog/">Blog</a> • <a href="https://newpipe.net/FAQ/">FAQ</a> • <a href="https://newpipe.net/press/">Press</a></p>
|
||||
|
|
|
@ -12,20 +12,20 @@ plugins {
|
|||
}
|
||||
|
||||
android {
|
||||
compileSdk 34
|
||||
compileSdk 36
|
||||
namespace 'org.schabi.newpipe'
|
||||
|
||||
defaultConfig {
|
||||
applicationId "org.schabi.newpipe"
|
||||
resValue "string", "app_name", "NewPipe"
|
||||
minSdk 21
|
||||
targetSdk 33
|
||||
targetSdk 35
|
||||
if (System.properties.containsKey('versionCodeOverride')) {
|
||||
versionCode System.getProperty('versionCodeOverride') as Integer
|
||||
} else {
|
||||
versionCode 1002
|
||||
versionCode 1005
|
||||
}
|
||||
versionName "0.27.5"
|
||||
versionName "0.28.0"
|
||||
if (System.properties.containsKey('versionNameSuffix')) {
|
||||
versionNameSuffix System.getProperty('versionNameSuffix')
|
||||
}
|
||||
|
@ -97,6 +97,10 @@ android {
|
|||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
buildConfig true
|
||||
|
@ -205,10 +209,12 @@ dependencies {
|
|||
// Or you can use a commit you pushed to GitHub by just replacing TeamNewPipe with your GitHub
|
||||
// name and the commit hash with the commit hash of the (pushed) commit you want to test
|
||||
// This works thanks to JitPack: https://jitpack.io/
|
||||
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
|
||||
implementation 'com.github.TeamNewPipe:nanojson:e9d656ddb49a412a5a0a5d5ef20ca7ef09549996'
|
||||
// WORKAROUND: if you get errors with the NewPipeExtractor dependency, replace `v0.24.3` with
|
||||
// the corresponding commit hash, since JitPack is sometimes buggy
|
||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.24.4'
|
||||
// the corresponding commit hash, since JitPack sometimes deletes artifacts.
|
||||
// If there’s already a git hash, just add more of it to the end (or remove a letter)
|
||||
// to cause jitpack to regenerate the artifact.
|
||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:0023b22095a2d62a60cdfc87f4b5cd85c8b266c3'
|
||||
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
|
||||
|
||||
/** Checkstyle **/
|
||||
|
@ -219,7 +225,7 @@ dependencies {
|
|||
implementation "org.jetbrains.kotlin:kotlin-stdlib:${kotlin_version}"
|
||||
|
||||
/** AndroidX **/
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.appcompat:appcompat:1.7.1'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
|
@ -241,6 +247,7 @@ dependencies {
|
|||
implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}"
|
||||
implementation "androidx.work:work-rxjava3:${androidxWorkVersion}"
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
implementation "androidx.webkit:webkit:1.9.0"
|
||||
|
||||
/** Third-party libraries **/
|
||||
// Instance state boilerplate elimination
|
||||
|
@ -273,7 +280,7 @@ dependencies {
|
|||
implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}"
|
||||
|
||||
// Image loading
|
||||
//noinspection GradleDependency --> 2.8 is the last version, not 2.71828!
|
||||
//noinspection NewerVersionAvailable,GradleDependency --> 2.8 is the last version, not 2.71828!
|
||||
implementation "com.squareup.picasso:picasso:2.8"
|
||||
|
||||
// Markdown library for Android
|
||||
|
|
7
app/proguard-rules.pro
vendored
7
app/proguard-rules.pro
vendored
|
@ -5,10 +5,17 @@
|
|||
|
||||
## Rules for NewPipeExtractor
|
||||
-keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; }
|
||||
## Rules for Rhino and Rhino Engine
|
||||
-keep class org.mozilla.javascript.* { *; }
|
||||
-keep class org.mozilla.javascript.** { *; }
|
||||
-keep class org.mozilla.javascript.engine.** { *; }
|
||||
-keep class org.mozilla.classfile.ClassFileWriter
|
||||
-dontwarn org.mozilla.javascript.JavaToJSONConverters
|
||||
-dontwarn org.mozilla.javascript.tools.**
|
||||
-keep class javax.script.** { *; }
|
||||
-dontwarn javax.script.**
|
||||
-keep class jdk.dynalink.** { *; }
|
||||
-dontwarn jdk.dynalink.**
|
||||
|
||||
## Rules for ExoPlayer
|
||||
-keep class com.google.android.exoplayer2.** { *; }
|
||||
|
|
|
@ -12,6 +12,7 @@ import org.schabi.newpipe.extractor.ServiceList;
|
|||
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
@ -23,8 +24,23 @@ import static org.junit.Assert.assertTrue;
|
|||
@LargeTest
|
||||
public class ErrorInfoTest {
|
||||
|
||||
/**
|
||||
* @param errorInfo the error info to access
|
||||
* @return the private field errorInfo.message.stringRes using reflection
|
||||
*/
|
||||
private int getMessageFromErrorInfo(final ErrorInfo errorInfo)
|
||||
throws NoSuchFieldException, IllegalAccessException {
|
||||
final var message = ErrorInfo.class.getDeclaredField("message");
|
||||
message.setAccessible(true);
|
||||
final var messageValue = (ErrorInfo.Companion.ErrorMessage) message.get(errorInfo);
|
||||
|
||||
final var stringRes = ErrorInfo.Companion.ErrorMessage.class.getDeclaredField("stringRes");
|
||||
stringRes.setAccessible(true);
|
||||
return (int) Objects.requireNonNull(stringRes.get(messageValue));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void errorInfoTestParcelable() {
|
||||
public void errorInfoTestParcelable() throws NoSuchFieldException, IllegalAccessException {
|
||||
final ErrorInfo info = new ErrorInfo(new ParsingException("Hello"),
|
||||
UserAction.USER_REPORT, "request", ServiceList.YouTube.getServiceId());
|
||||
// Obtain a Parcel object and write the parcelable object to it:
|
||||
|
@ -39,7 +55,7 @@ public class ErrorInfoTest {
|
|||
assertEquals(ServiceList.YouTube.getServiceInfo().getName(),
|
||||
infoFromParcel.getServiceName());
|
||||
assertEquals("request", infoFromParcel.getRequest());
|
||||
assertEquals(R.string.parsing_error, infoFromParcel.getMessageStringId());
|
||||
assertEquals(R.string.parsing_error, getMessageFromErrorInfo(infoFromParcel));
|
||||
|
||||
parcel.recycle();
|
||||
}
|
||||
|
|
|
@ -9,6 +9,8 @@
|
|||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<!-- We need to be able to open links in the browser on API 30+ -->
|
||||
|
@ -57,6 +59,15 @@
|
|||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
android:enabled="false"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".player.PlayerService"
|
||||
android:exported="true"
|
||||
|
@ -64,6 +75,9 @@
|
|||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<activity
|
||||
|
@ -82,9 +96,22 @@
|
|||
android:exported="false"
|
||||
android:label="@string/title_activity_about" />
|
||||
|
||||
<service android:name=".local.subscription.services.SubscriptionsImportService" />
|
||||
<service android:name=".local.subscription.services.SubscriptionsExportService" />
|
||||
<service android:name=".local.feed.service.FeedLoadService" />
|
||||
<service
|
||||
android:name=".local.subscription.services.SubscriptionsImportService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name=".local.subscription.services.SubscriptionsExportService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name=".local.feed.service.FeedLoadService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
|
||||
<activity
|
||||
android:name=".PanicResponderActivity"
|
||||
|
@ -116,7 +143,8 @@
|
|||
android:label="@string/app_name"
|
||||
android:launchMode="singleTask" />
|
||||
|
||||
<service android:name="us.shandian.giga.service.DownloadManagerService" />
|
||||
<service android:name="us.shandian.giga.service.DownloadManagerService"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<activity
|
||||
android:name=".util.FilePickerActivityHelper"
|
||||
|
@ -409,6 +437,7 @@
|
|||
</activity>
|
||||
<service
|
||||
android:name=".RouterActivity$FetcherService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- opting out of sending metrics to Google in Android System WebView -->
|
||||
|
@ -424,5 +453,10 @@
|
|||
<meta-data
|
||||
android:name="com.samsung.android.multidisplay.keep_process_alive"
|
||||
android:value="true" />
|
||||
<!-- Android Auto -->
|
||||
<meta-data android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
|
||||
android:resource="@mipmap/ic_launcher" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
127
app/src/main/assets/po_token.html
Normal file
127
app/src/main/assets/po_token.html
Normal file
|
@ -0,0 +1,127 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en"><head><title></title><script>
|
||||
/**
|
||||
* Factory method to create and load a BotGuardClient instance.
|
||||
* @param options - Configuration options for the BotGuardClient.
|
||||
* @returns A promise that resolves to a loaded BotGuardClient instance.
|
||||
*/
|
||||
function loadBotGuard(challengeData) {
|
||||
this.vm = this[challengeData.globalName];
|
||||
this.program = challengeData.program;
|
||||
this.vmFunctions = {};
|
||||
this.syncSnapshotFunction = null;
|
||||
|
||||
if (!this.vm)
|
||||
throw new Error('[BotGuardClient]: VM not found in the global object');
|
||||
|
||||
if (!this.vm.a)
|
||||
throw new Error('[BotGuardClient]: Could not load program');
|
||||
|
||||
const vmFunctionsCallback = function (
|
||||
asyncSnapshotFunction,
|
||||
shutdownFunction,
|
||||
passEventFunction,
|
||||
checkCameraFunction
|
||||
) {
|
||||
this.vmFunctions = {
|
||||
asyncSnapshotFunction: asyncSnapshotFunction,
|
||||
shutdownFunction: shutdownFunction,
|
||||
passEventFunction: passEventFunction,
|
||||
checkCameraFunction: checkCameraFunction
|
||||
};
|
||||
};
|
||||
|
||||
this.syncSnapshotFunction = this.vm.a(this.program, vmFunctionsCallback, true, this.userInteractionElement, function () {/** no-op */ }, [ [], [] ])[0]
|
||||
|
||||
// an asynchronous function runs in the background and it will eventually call
|
||||
// `vmFunctionsCallback`, however we need to manually tell JavaScript to pass
|
||||
// control to the things running in the background by interrupting this async
|
||||
// function in any way, e.g. with a delay of 1ms. The loop is most probably not
|
||||
// needed but is there just because.
|
||||
return new Promise(function (resolve, reject) {
|
||||
i = 0
|
||||
refreshIntervalId = setInterval(function () {
|
||||
if (!!this.vmFunctions.asyncSnapshotFunction) {
|
||||
resolve(this)
|
||||
clearInterval(refreshIntervalId);
|
||||
}
|
||||
if (i >= 10000) {
|
||||
reject("asyncSnapshotFunction is null even after 10 seconds")
|
||||
clearInterval(refreshIntervalId);
|
||||
}
|
||||
i += 1;
|
||||
}, 1);
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a snapshot asynchronously.
|
||||
* @returns The snapshot result.
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = await botguard.snapshot({
|
||||
* contentBinding: {
|
||||
* c: "a=6&a2=10&b=SZWDwKVIuixOp7Y4euGTgwckbJA&c=1729143849&d=1&t=7200&c1a=1&c6a=1&c6b=1&hh=HrMb5mRWTyxGJphDr0nW2Oxonh0_wl2BDqWuLHyeKLo",
|
||||
* e: "ENGAGEMENT_TYPE_VIDEO_LIKE",
|
||||
* encryptedVideoId: "P-vC09ZJcnM"
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* console.log(result);
|
||||
* ```
|
||||
*/
|
||||
function snapshot(args) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
if (!this.vmFunctions.asyncSnapshotFunction)
|
||||
return reject(new Error('[BotGuardClient]: Async snapshot function not found'));
|
||||
|
||||
this.vmFunctions.asyncSnapshotFunction(function (response) { resolve(response) }, [
|
||||
args.contentBinding,
|
||||
args.signedTimestamp,
|
||||
args.webPoSignalOutput,
|
||||
args.skipPrivacyBuffer
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
function runBotGuard(challengeData) {
|
||||
const interpreterJavascript = challengeData.interpreterJavascript.privateDoNotAccessOrElseSafeScriptWrappedValue;
|
||||
|
||||
if (interpreterJavascript) {
|
||||
new Function(interpreterJavascript)();
|
||||
} else throw new Error('Could not load VM');
|
||||
|
||||
const webPoSignalOutput = [];
|
||||
return loadBotGuard({
|
||||
globalName: challengeData.globalName,
|
||||
globalObj: this,
|
||||
program: challengeData.program
|
||||
}).then(function (botguard) {
|
||||
return botguard.snapshot({ webPoSignalOutput: webPoSignalOutput })
|
||||
}).then(function (botguardResponse) {
|
||||
return { webPoSignalOutput: webPoSignalOutput, botguardResponse: botguardResponse }
|
||||
})
|
||||
}
|
||||
|
||||
function obtainPoToken(webPoSignalOutput, integrityToken, identifier) {
|
||||
const getMinter = webPoSignalOutput[0];
|
||||
|
||||
if (!getMinter)
|
||||
throw new Error('PMD:Undefined');
|
||||
|
||||
const mintCallback = getMinter(integrityToken);
|
||||
|
||||
if (!(mintCallback instanceof Function))
|
||||
throw new Error('APF:Failed');
|
||||
|
||||
const result = mintCallback(identifier);
|
||||
|
||||
if (!result)
|
||||
throw new Error('YNJ:Undefined');
|
||||
|
||||
if (!(result instanceof Uint8Array))
|
||||
throw new Error('ODM:Invalid');
|
||||
|
||||
return result;
|
||||
}
|
||||
</script></head><body></body></html>
|
|
@ -17,6 +17,7 @@ import org.acra.config.CoreConfigurationBuilder;
|
|||
import org.schabi.newpipe.error.ReCaptchaActivity;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.downloader.Downloader;
|
||||
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor;
|
||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
||||
import org.schabi.newpipe.settings.NewPipeSettings;
|
||||
import org.schabi.newpipe.util.BridgeStateSaverInitializer;
|
||||
|
@ -26,6 +27,7 @@ import org.schabi.newpipe.util.StateSaver;
|
|||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.image.PreferredImageQuality;
|
||||
import org.schabi.newpipe.util.potoken.PoTokenProviderImpl;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InterruptedIOException;
|
||||
|
@ -100,7 +102,7 @@ public class App extends Application {
|
|||
NewPipe.init(getDownloader(),
|
||||
Localization.getPreferredLocalization(this),
|
||||
Localization.getPreferredContentCountry(this));
|
||||
Localization.initPrettyTime(Localization.resolvePrettyTime(getApplicationContext()));
|
||||
Localization.initPrettyTime(Localization.resolvePrettyTime());
|
||||
|
||||
BridgeStateSaverInitializer.init(this);
|
||||
StateSaver.init(this);
|
||||
|
@ -118,6 +120,8 @@ public class App extends Application {
|
|||
&& prefs.getBoolean(getString(R.string.show_image_indicators_key), false));
|
||||
|
||||
configureRxJavaErrorHandler();
|
||||
|
||||
YoutubeStreamExtractor.setPoTokenProvider(PoTokenProviderImpl.INSTANCE);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -29,7 +29,7 @@ import okhttp3.ResponseBody;
|
|||
|
||||
public final class DownloaderImpl extends Downloader {
|
||||
public static final String USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0";
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.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";
|
||||
|
@ -137,7 +137,8 @@ public final class DownloaderImpl extends Downloader {
|
|||
}
|
||||
|
||||
final okhttp3.Request.Builder requestBuilder = new okhttp3.Request.Builder()
|
||||
.method(httpMethod, requestBody).url(url)
|
||||
.method(httpMethod, requestBody)
|
||||
.url(url)
|
||||
.addHeader("User-Agent", USER_AGENT);
|
||||
|
||||
final String cookies = getCookies(url);
|
||||
|
@ -145,38 +146,33 @@ public final class DownloaderImpl extends Downloader {
|
|||
requestBuilder.addHeader("Cookie", cookies);
|
||||
}
|
||||
|
||||
for (final Map.Entry<String, List<String>> pair : headers.entrySet()) {
|
||||
final String headerName = pair.getKey();
|
||||
final List<String> headerValueList = pair.getValue();
|
||||
headers.forEach((headerName, headerValueList) -> {
|
||||
requestBuilder.removeHeader(headerName);
|
||||
headerValueList.forEach(headerValue ->
|
||||
requestBuilder.addHeader(headerName, headerValue));
|
||||
});
|
||||
|
||||
if (headerValueList.size() > 1) {
|
||||
requestBuilder.removeHeader(headerName);
|
||||
for (final String headerValue : headerValueList) {
|
||||
requestBuilder.addHeader(headerName, headerValue);
|
||||
}
|
||||
} else if (headerValueList.size() == 1) {
|
||||
requestBuilder.header(headerName, headerValueList.get(0));
|
||||
try (
|
||||
okhttp3.Response response = client.newCall(requestBuilder.build()).execute()
|
||||
) {
|
||||
if (response.code() == 429) {
|
||||
throw new ReCaptchaException("reCaptcha Challenge requested", url);
|
||||
}
|
||||
|
||||
String responseBodyToReturn = null;
|
||||
try (ResponseBody body = response.body()) {
|
||||
if (body != null) {
|
||||
responseBodyToReturn = body.string();
|
||||
}
|
||||
}
|
||||
|
||||
final String latestUrl = response.request().url().toString();
|
||||
return new Response(
|
||||
response.code(),
|
||||
response.message(),
|
||||
response.headers().toMultimap(),
|
||||
responseBodyToReturn,
|
||||
latestUrl);
|
||||
}
|
||||
|
||||
final okhttp3.Response response = client.newCall(requestBuilder.build()).execute();
|
||||
|
||||
if (response.code() == 429) {
|
||||
response.close();
|
||||
|
||||
throw new ReCaptchaException("reCaptcha Challenge requested", url);
|
||||
}
|
||||
|
||||
final ResponseBody body = response.body();
|
||||
String responseBodyToReturn = null;
|
||||
|
||||
if (body != null) {
|
||||
responseBodyToReturn = body.string();
|
||||
}
|
||||
|
||||
final String latestUrl = response.request().url().toString();
|
||||
return new Response(response.code(), response.message(), response.headers().toMultimap(),
|
||||
responseBodyToReturn, latestUrl);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,8 +20,6 @@
|
|||
|
||||
package org.schabi.newpipe;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
|
@ -38,6 +36,7 @@ import android.view.Menu;
|
|||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.webkit.WebView;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.FrameLayout;
|
||||
|
@ -49,6 +48,7 @@ import androidx.appcompat.app.ActionBar;
|
|||
import androidx.appcompat.app.ActionBarDrawerToggle;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.view.GravityCompat;
|
||||
import androidx.drawerlayout.widget.DrawerLayout;
|
||||
import androidx.fragment.app.Fragment;
|
||||
|
@ -80,6 +80,7 @@ import org.schabi.newpipe.player.event.OnKeyDownListener;
|
|||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.settings.UpdateSettingsFragment;
|
||||
import org.schabi.newpipe.settings.migration.MigrationManager;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.KioskTranslator;
|
||||
|
@ -92,6 +93,7 @@ import org.schabi.newpipe.util.SerializedCache;
|
|||
import org.schabi.newpipe.util.ServiceHelper;
|
||||
import org.schabi.newpipe.util.StateSaver;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
import org.schabi.newpipe.views.FocusOverlayView;
|
||||
|
||||
import java.util.ArrayList;
|
||||
|
@ -120,10 +122,14 @@ public class MainActivity extends AppCompatActivity {
|
|||
private static final int ITEM_ID_DOWNLOADS = -4;
|
||||
private static final int ITEM_ID_HISTORY = -5;
|
||||
private static final int ITEM_ID_SETTINGS = 0;
|
||||
private static final int ITEM_ID_ABOUT = 1;
|
||||
private static final int ITEM_ID_DONATION = 1;
|
||||
private static final int ITEM_ID_ABOUT = 2;
|
||||
|
||||
private static final int ORDER = 0;
|
||||
public static final String KEY_IS_IN_BACKGROUND = "is_in_background";
|
||||
|
||||
private SharedPreferences sharedPreferences;
|
||||
private SharedPreferences.Editor sharedPrefEditor;
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Activity's LifeCycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
@ -135,11 +141,26 @@ public class MainActivity extends AppCompatActivity {
|
|||
+ "savedInstanceState = [" + savedInstanceState + "]");
|
||||
}
|
||||
|
||||
Localization.migrateAppLanguageSettingIfNecessary(getApplicationContext());
|
||||
ThemeHelper.setDayNightMode(this);
|
||||
ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this));
|
||||
|
||||
assureCorrectAppLanguage(this);
|
||||
// Fixes text color turning black in dark/black mode:
|
||||
// https://github.com/TeamNewPipe/NewPipe/issues/12016
|
||||
// For further reference see: https://issuetracker.google.com/issues/37124582
|
||||
if (DeviceUtils.supportsWebView()) {
|
||||
try {
|
||||
new WebView(this);
|
||||
} catch (final Throwable e) {
|
||||
if (DEBUG) {
|
||||
Log.e(TAG, "Failed to create WebView", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
|
||||
sharedPrefEditor = sharedPreferences.edit();
|
||||
|
||||
mainBinding = ActivityMainBinding.inflate(getLayoutInflater());
|
||||
drawerLayoutBinding = mainBinding.drawerLayout;
|
||||
|
@ -174,6 +195,8 @@ public class MainActivity extends AppCompatActivity {
|
|||
&& ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
|
||||
UpdateSettingsFragment.askForConsentToUpdateChecks(this);
|
||||
}
|
||||
|
||||
MigrationManager.showUserInfoIfPresent(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -181,16 +204,29 @@ public class MainActivity extends AppCompatActivity {
|
|||
super.onPostCreate(savedInstanceState);
|
||||
|
||||
final App app = App.getApp();
|
||||
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
|
||||
|
||||
if (prefs.getBoolean(app.getString(R.string.update_app_key), false)
|
||||
&& prefs.getBoolean(app.getString(R.string.update_check_consent_key), false)) {
|
||||
if (sharedPreferences.getBoolean(app.getString(R.string.update_app_key), false)
|
||||
&& sharedPreferences
|
||||
.getBoolean(app.getString(R.string.update_check_consent_key), false)) {
|
||||
// Start the worker which is checking all conditions
|
||||
// and eventually searching for a new version.
|
||||
NewVersionWorker.enqueueNewVersionCheckingWork(app, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
sharedPrefEditor.putBoolean(KEY_IS_IN_BACKGROUND, false).apply();
|
||||
Log.d(TAG, "App moved to foreground");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
sharedPrefEditor.putBoolean(KEY_IS_IN_BACKGROUND, true).apply();
|
||||
Log.d(TAG, "App moved to background");
|
||||
}
|
||||
private void setupDrawer() throws ExtractionException {
|
||||
addDrawerMenuForCurrentService();
|
||||
|
||||
|
@ -228,19 +264,6 @@ public class MainActivity extends AppCompatActivity {
|
|||
*/
|
||||
private void addDrawerMenuForCurrentService() throws ExtractionException {
|
||||
//Tabs
|
||||
final int currentServiceId = ServiceHelper.getSelectedServiceId(this);
|
||||
final StreamingService service = NewPipe.getService(currentServiceId);
|
||||
|
||||
int kioskMenuItemId = 0;
|
||||
|
||||
for (final String ks : service.getKioskList().getAvailableKiosks()) {
|
||||
drawerLayoutBinding.navigation.getMenu()
|
||||
.add(R.id.menu_tabs_group, kioskMenuItemId, 0, KioskTranslator
|
||||
.getTranslatedKioskName(ks, this))
|
||||
.setIcon(KioskTranslator.getKioskIcon(ks));
|
||||
kioskMenuItemId++;
|
||||
}
|
||||
|
||||
drawerLayoutBinding.navigation.getMenu()
|
||||
.add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER,
|
||||
R.string.tab_subscriptions)
|
||||
|
@ -258,10 +281,28 @@ public class MainActivity extends AppCompatActivity {
|
|||
.add(R.id.menu_tabs_group, ITEM_ID_HISTORY, ORDER, R.string.action_history)
|
||||
.setIcon(R.drawable.ic_history);
|
||||
|
||||
//Kiosks
|
||||
final int currentServiceId = ServiceHelper.getSelectedServiceId(this);
|
||||
final StreamingService service = NewPipe.getService(currentServiceId);
|
||||
|
||||
int kioskMenuItemId = 0;
|
||||
|
||||
for (final String ks : service.getKioskList().getAvailableKiosks()) {
|
||||
drawerLayoutBinding.navigation.getMenu()
|
||||
.add(R.id.menu_kiosks_group, kioskMenuItemId, 0, KioskTranslator
|
||||
.getTranslatedKioskName(ks, this))
|
||||
.setIcon(KioskTranslator.getKioskIcon(ks));
|
||||
kioskMenuItemId++;
|
||||
}
|
||||
|
||||
//Settings and About
|
||||
drawerLayoutBinding.navigation.getMenu()
|
||||
.add(R.id.menu_options_about_group, ITEM_ID_SETTINGS, ORDER, R.string.settings)
|
||||
.setIcon(R.drawable.ic_settings);
|
||||
drawerLayoutBinding.navigation.getMenu()
|
||||
.add(R.id.menu_options_about_group, ITEM_ID_DONATION, ORDER,
|
||||
R.string.donation_title)
|
||||
.setIcon(R.drawable.volunteer_activism_ic);
|
||||
drawerLayoutBinding.navigation.getMenu()
|
||||
.add(R.id.menu_options_about_group, ITEM_ID_ABOUT, ORDER, R.string.tab_about)
|
||||
.setIcon(R.drawable.ic_info_outline);
|
||||
|
@ -273,10 +314,13 @@ public class MainActivity extends AppCompatActivity {
|
|||
changeService(item);
|
||||
break;
|
||||
case R.id.menu_tabs_group:
|
||||
tabSelected(item);
|
||||
break;
|
||||
case R.id.menu_kiosks_group:
|
||||
try {
|
||||
tabSelected(item);
|
||||
kioskSelected(item);
|
||||
} catch (final Exception e) {
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Selecting main page tab", e);
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Selecting drawer kiosk", e);
|
||||
}
|
||||
break;
|
||||
case R.id.menu_options_about_group:
|
||||
|
@ -300,7 +344,7 @@ public class MainActivity extends AppCompatActivity {
|
|||
.setChecked(true);
|
||||
}
|
||||
|
||||
private void tabSelected(final MenuItem item) throws ExtractionException {
|
||||
private void tabSelected(final MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case ITEM_ID_SUBSCRIPTIONS:
|
||||
NavigationHelper.openSubscriptionFragment(getSupportFragmentManager());
|
||||
|
@ -317,18 +361,19 @@ public class MainActivity extends AppCompatActivity {
|
|||
case ITEM_ID_HISTORY:
|
||||
NavigationHelper.openStatisticFragment(getSupportFragmentManager());
|
||||
break;
|
||||
default:
|
||||
final StreamingService currentService = ServiceHelper.getSelectedService(this);
|
||||
int kioskMenuItemId = 0;
|
||||
for (final String kioskId : currentService.getKioskList().getAvailableKiosks()) {
|
||||
if (kioskMenuItemId == item.getItemId()) {
|
||||
NavigationHelper.openKioskFragment(getSupportFragmentManager(),
|
||||
currentService.getServiceId(), kioskId);
|
||||
break;
|
||||
}
|
||||
kioskMenuItemId++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void kioskSelected(final MenuItem item) throws ExtractionException {
|
||||
final StreamingService currentService = ServiceHelper.getSelectedService(this);
|
||||
int kioskMenuItemId = 0;
|
||||
for (final String kioskId : currentService.getKioskList().getAvailableKiosks()) {
|
||||
if (kioskMenuItemId == item.getItemId()) {
|
||||
NavigationHelper.openKioskFragment(getSupportFragmentManager(),
|
||||
currentService.getServiceId(), kioskId);
|
||||
break;
|
||||
}
|
||||
kioskMenuItemId++;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -337,6 +382,9 @@ public class MainActivity extends AppCompatActivity {
|
|||
case ITEM_ID_SETTINGS:
|
||||
NavigationHelper.openSettings(this);
|
||||
break;
|
||||
case ITEM_ID_DONATION:
|
||||
ShareUtils.openUrlInBrowser(this, getString(R.string.donation_url));
|
||||
break;
|
||||
case ITEM_ID_ABOUT:
|
||||
NavigationHelper.openAbout(this);
|
||||
break;
|
||||
|
@ -366,6 +414,7 @@ public class MainActivity extends AppCompatActivity {
|
|||
|
||||
drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_services_group);
|
||||
drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_tabs_group);
|
||||
drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_kiosks_group);
|
||||
drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_options_about_group);
|
||||
|
||||
// Show up or down arrow
|
||||
|
@ -459,9 +508,8 @@ public class MainActivity extends AppCompatActivity {
|
|||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
assureCorrectAppLanguage(this);
|
||||
// Change the date format to match the selected language on resume
|
||||
Localization.initPrettyTime(Localization.resolvePrettyTime(getApplicationContext()));
|
||||
Localization.initPrettyTime(Localization.resolvePrettyTime());
|
||||
super.onResume();
|
||||
|
||||
// Close drawer on return, and don't show animation,
|
||||
|
@ -483,13 +531,11 @@ public class MainActivity extends AppCompatActivity {
|
|||
ErrorUtil.showUiErrorSnackbar(this, "Setting up service toggle", e);
|
||||
}
|
||||
|
||||
final SharedPreferences sharedPreferences =
|
||||
PreferenceManager.getDefaultSharedPreferences(this);
|
||||
if (sharedPreferences.getBoolean(Constants.KEY_THEME_CHANGE, false)) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Theme has changed, recreating activity...");
|
||||
}
|
||||
sharedPreferences.edit().putBoolean(Constants.KEY_THEME_CHANGE, false).apply();
|
||||
sharedPrefEditor.putBoolean(Constants.KEY_THEME_CHANGE, false).apply();
|
||||
ActivityCompat.recreate(this);
|
||||
}
|
||||
|
||||
|
@ -497,7 +543,7 @@ public class MainActivity extends AppCompatActivity {
|
|||
if (DEBUG) {
|
||||
Log.d(TAG, "main page has changed, recreating main fragment...");
|
||||
}
|
||||
sharedPreferences.edit().putBoolean(Constants.KEY_MAIN_PAGE_CHANGE, false).apply();
|
||||
sharedPrefEditor.putBoolean(Constants.KEY_MAIN_PAGE_CHANGE, false).apply();
|
||||
NavigationHelper.openMainActivity(this);
|
||||
}
|
||||
|
||||
|
@ -839,7 +885,8 @@ public class MainActivity extends AppCompatActivity {
|
|||
@Override
|
||||
public void onReceive(final Context context, final Intent intent) {
|
||||
if (Objects.equals(intent.getAction(),
|
||||
VideoDetailFragment.ACTION_PLAYER_STARTED)) {
|
||||
VideoDetailFragment.ACTION_PLAYER_STARTED)
|
||||
&& PlayerHolder.getInstance().isPlayerOpen()) {
|
||||
openMiniPlayerIfMissing();
|
||||
// At this point the player is added 100%, we can unregister. Other actions
|
||||
// are useless since the fragment will not be removed after that.
|
||||
|
@ -850,7 +897,12 @@ public class MainActivity extends AppCompatActivity {
|
|||
};
|
||||
final IntentFilter intentFilter = new IntentFilter();
|
||||
intentFilter.addAction(VideoDetailFragment.ACTION_PLAYER_STARTED);
|
||||
registerReceiver(broadcastReceiver, intentFilter);
|
||||
ContextCompat.registerReceiver(this, broadcastReceiver, intentFilter,
|
||||
ContextCompat.RECEIVER_EXPORTED);
|
||||
|
||||
// If the PlayerHolder is not bound yet, but the service is running, try to bind to it.
|
||||
// Once the connection is established, the ACTION_PLAYER_STARTED will be sent.
|
||||
PlayerHolder.getInstance().tryBindIfNeeded(this);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -924,4 +976,5 @@ public class MainActivity extends AppCompatActivity {
|
|||
return sheetState == BottomSheetBehavior.STATE_HIDDEN
|
||||
|| sheetState == BottomSheetBehavior.STATE_COLLAPSED;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -58,20 +58,10 @@ import org.schabi.newpipe.extractor.NewPipe;
|
|||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
import org.schabi.newpipe.extractor.StreamingService.LinkType;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException;
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException;
|
||||
import org.schabi.newpipe.extractor.exceptions.PaidContentException;
|
||||
import org.schabi.newpipe.extractor.exceptions.PrivateContentException;
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
|
||||
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException;
|
||||
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException;
|
||||
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
||||
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||
import org.schabi.newpipe.player.PlayerType;
|
||||
import org.schabi.newpipe.player.helper.PlayerHelper;
|
||||
|
@ -84,7 +74,6 @@ import org.schabi.newpipe.util.ChannelTabHelper;
|
|||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.PermissionHelper;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
@ -132,7 +121,6 @@ public class RouterActivity extends AppCompatActivity {
|
|||
ThemeHelper.setDayNightMode(this);
|
||||
setTheme(ThemeHelper.isLightThemeSelected(this)
|
||||
? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark);
|
||||
Localization.assureCorrectAppLanguage(this);
|
||||
|
||||
// Pass-through touch events to background activities
|
||||
// so that our transparent window won't lock UI in the mean time
|
||||
|
@ -262,7 +250,8 @@ public class RouterActivity extends AppCompatActivity {
|
|||
showUnsupportedUrlDialog(url);
|
||||
}
|
||||
}, throwable -> handleError(this, new ErrorInfo(throwable,
|
||||
UserAction.SHARE_TO_NEWPIPE, "Getting service from url: " + url))));
|
||||
UserAction.SHARE_TO_NEWPIPE, "Getting service from url: " + url,
|
||||
null, url))));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -271,40 +260,19 @@ public class RouterActivity extends AppCompatActivity {
|
|||
* @param errorInfo the error information
|
||||
*/
|
||||
private static void handleError(final Context context, final ErrorInfo errorInfo) {
|
||||
if (errorInfo.getThrowable() != null) {
|
||||
errorInfo.getThrowable().printStackTrace();
|
||||
}
|
||||
|
||||
if (errorInfo.getThrowable() instanceof ReCaptchaException) {
|
||||
if (errorInfo.getRecaptchaUrl() != null) {
|
||||
Toast.makeText(context, R.string.recaptcha_request_toast, Toast.LENGTH_LONG).show();
|
||||
// Starting ReCaptcha Challenge Activity
|
||||
final Intent intent = new Intent(context, ReCaptchaActivity.class);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, errorInfo.getRecaptchaUrl());
|
||||
context.startActivity(intent);
|
||||
} else if (errorInfo.getThrowable() != null
|
||||
&& ExceptionUtils.isNetworkRelated(errorInfo.getThrowable())) {
|
||||
Toast.makeText(context, R.string.network_error, Toast.LENGTH_LONG).show();
|
||||
} else if (errorInfo.getThrowable() instanceof AgeRestrictedContentException) {
|
||||
Toast.makeText(context, R.string.restricted_video_no_stream,
|
||||
Toast.LENGTH_LONG).show();
|
||||
} else if (errorInfo.getThrowable() instanceof GeographicRestrictionException) {
|
||||
Toast.makeText(context, R.string.georestricted_content, Toast.LENGTH_LONG).show();
|
||||
} else if (errorInfo.getThrowable() instanceof PaidContentException) {
|
||||
Toast.makeText(context, R.string.paid_content, Toast.LENGTH_LONG).show();
|
||||
} else if (errorInfo.getThrowable() instanceof PrivateContentException) {
|
||||
Toast.makeText(context, R.string.private_content, Toast.LENGTH_LONG).show();
|
||||
} else if (errorInfo.getThrowable() instanceof SoundCloudGoPlusContentException) {
|
||||
Toast.makeText(context, R.string.soundcloud_go_plus_content,
|
||||
Toast.LENGTH_LONG).show();
|
||||
} else if (errorInfo.getThrowable() instanceof YoutubeMusicPremiumContentException) {
|
||||
Toast.makeText(context, R.string.youtube_music_premium_content,
|
||||
Toast.LENGTH_LONG).show();
|
||||
} else if (errorInfo.getThrowable() instanceof ContentNotAvailableException) {
|
||||
Toast.makeText(context, R.string.content_not_available, Toast.LENGTH_LONG).show();
|
||||
} else if (errorInfo.getThrowable() instanceof ContentNotSupportedException) {
|
||||
Toast.makeText(context, R.string.content_not_supported, Toast.LENGTH_LONG).show();
|
||||
} else {
|
||||
} else if (errorInfo.isReportable()) {
|
||||
ErrorUtil.createNotification(context, errorInfo);
|
||||
} else {
|
||||
// this exception does not usually indicate a problem that should be reported,
|
||||
// so just show a toast instead of the notification
|
||||
Toast.makeText(context, errorInfo.getMessage(context), Toast.LENGTH_LONG).show();
|
||||
}
|
||||
|
||||
if (context instanceof RouterActivity) {
|
||||
|
@ -667,7 +635,8 @@ public class RouterActivity extends AppCompatActivity {
|
|||
startActivity(intent);
|
||||
finish();
|
||||
}, throwable -> handleError(this, new ErrorInfo(throwable,
|
||||
UserAction.SHARE_TO_NEWPIPE, "Starting info activity: " + currentUrl)))
|
||||
UserAction.SHARE_TO_NEWPIPE, "Starting info activity: " + currentUrl,
|
||||
null, currentUrl)))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
@ -854,10 +823,10 @@ public class RouterActivity extends AppCompatActivity {
|
|||
})
|
||||
)),
|
||||
throwable -> runOnVisible(ctx -> handleError(ctx, new ErrorInfo(
|
||||
throwable,
|
||||
UserAction.REQUESTED_STREAM,
|
||||
throwable, UserAction.REQUESTED_STREAM,
|
||||
"Tried to add " + currentUrl + " to a playlist",
|
||||
((RouterActivity) ctx).currentService.getServiceId())
|
||||
((RouterActivity) ctx).currentService.getServiceId(),
|
||||
currentUrl)
|
||||
))
|
||||
)
|
||||
);
|
||||
|
@ -997,7 +966,7 @@ public class RouterActivity extends AppCompatActivity {
|
|||
}
|
||||
}, throwable -> handleError(this, new ErrorInfo(throwable, finalUserAction,
|
||||
choice.url + " opened with " + choice.playerChoice,
|
||||
choice.serviceId)));
|
||||
choice.serviceId, choice.url)));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -16,14 +16,12 @@ import org.schabi.newpipe.BuildConfig
|
|||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.databinding.ActivityAboutBinding
|
||||
import org.schabi.newpipe.databinding.FragmentAboutBinding
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.ThemeHelper
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
|
||||
class AboutActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Localization.assureCorrectAppLanguage(this)
|
||||
super.onCreate(savedInstanceState)
|
||||
ThemeHelper.setTheme(this)
|
||||
title = getString(R.string.title_activity_about)
|
||||
|
|
|
@ -19,7 +19,6 @@ import org.schabi.newpipe.R
|
|||
import org.schabi.newpipe.databinding.FragmentLicensesBinding
|
||||
import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding
|
||||
import org.schabi.newpipe.ktx.parcelableArrayList
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
|
||||
/**
|
||||
|
@ -100,7 +99,6 @@ class LicenseFragment : Fragment() {
|
|||
val webView = WebView(context)
|
||||
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
|
||||
|
||||
Localization.assureCorrectAppLanguage(context)
|
||||
val builder = AlertDialog.Builder(requireContext())
|
||||
.setTitle(softwareComponent.name)
|
||||
.setView(webView)
|
||||
|
|
|
@ -3,6 +3,8 @@ package org.schabi.newpipe.database.history.model
|
|||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.util.image.ImageStrategy
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
data class StreamHistoryEntry(
|
||||
|
@ -27,4 +29,17 @@ data class StreamHistoryEntry(
|
|||
return this.streamEntity.uid == other.streamEntity.uid && streamId == other.streamId &&
|
||||
accessDate.isEqual(other.accessDate)
|
||||
}
|
||||
|
||||
fun toStreamInfoItem(): StreamInfoItem =
|
||||
StreamInfoItem(
|
||||
streamEntity.serviceId,
|
||||
streamEntity.url,
|
||||
streamEntity.title,
|
||||
streamEntity.streamType,
|
||||
).apply {
|
||||
duration = streamEntity.duration
|
||||
uploaderName = streamEntity.uploader
|
||||
uploaderUrl = streamEntity.uploaderUrl
|
||||
thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
package org.schabi.newpipe.database.playlist;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.schabi.newpipe.database.LocalItem;
|
||||
|
||||
public interface PlaylistLocalItem extends LocalItem {
|
||||
|
@ -10,4 +12,7 @@ public interface PlaylistLocalItem extends LocalItem {
|
|||
long getUid();
|
||||
|
||||
void setDisplayIndex(long displayIndex);
|
||||
|
||||
@Nullable
|
||||
String getThumbnailUrl();
|
||||
}
|
||||
|
|
|
@ -9,6 +9,8 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST
|
|||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public class PlaylistMetadataEntry implements PlaylistLocalItem {
|
||||
public static final String PLAYLIST_STREAM_COUNT = "streamCount";
|
||||
|
||||
|
@ -71,4 +73,10 @@ public class PlaylistMetadataEntry implements PlaylistLocalItem {
|
|||
public void setDisplayIndex(final long displayIndex) {
|
||||
this.displayIndex = displayIndex;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public String getThumbnailUrl() {
|
||||
return thumbnailUrl;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ public interface PlaylistRemoteDAO extends BasicDAO<PlaylistRemoteEntity> {
|
|||
|
||||
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
|
||||
+ REMOTE_PLAYLIST_ID + " = :playlistId")
|
||||
Flowable<List<PlaylistRemoteEntity>> getPlaylist(long playlistId);
|
||||
Flowable<PlaylistRemoteEntity> getPlaylist(long playlistId);
|
||||
|
||||
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
|
||||
+ REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
|
||||
|
|
|
@ -2,6 +2,7 @@ package org.schabi.newpipe.database.playlist.model;
|
|||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.room.ColumnInfo;
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.Ignore;
|
||||
|
@ -134,6 +135,8 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
|
|||
this.name = name;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public String getThumbnailUrl() {
|
||||
return thumbnailUrl;
|
||||
}
|
||||
|
|
|
@ -20,8 +20,6 @@ import org.schabi.newpipe.views.FocusOverlayView;
|
|||
import us.shandian.giga.service.DownloadManagerService;
|
||||
import us.shandian.giga.ui.fragment.MissionsFragment;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
public class DownloadActivity extends AppCompatActivity {
|
||||
|
||||
private static final String MISSIONS_FRAGMENT_TAG = "fragment_tag";
|
||||
|
@ -33,7 +31,6 @@ public class DownloadActivity extends AppCompatActivity {
|
|||
i.setClass(this, DownloadManagerService.class);
|
||||
startService(i);
|
||||
|
||||
assureCorrectAppLanguage(this);
|
||||
ThemeHelper.setTheme(this);
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
|
|
|
@ -2,7 +2,6 @@ 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;
|
||||
|
@ -390,8 +389,7 @@ public class DownloadDialog extends DialogFragment
|
|||
}
|
||||
}, throwable -> ErrorUtil.showSnackbar(context,
|
||||
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
|
||||
"Downloading video stream size",
|
||||
currentInfo.getServiceId()))));
|
||||
"Downloading video stream size", currentInfo))));
|
||||
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(getWrappedAudioStreams())
|
||||
.subscribe(result -> {
|
||||
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
|
||||
|
@ -400,8 +398,7 @@ public class DownloadDialog extends DialogFragment
|
|||
}
|
||||
}, throwable -> ErrorUtil.showSnackbar(context,
|
||||
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
|
||||
"Downloading audio stream size",
|
||||
currentInfo.getServiceId()))));
|
||||
"Downloading audio stream size", currentInfo))));
|
||||
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedSubtitleStreams)
|
||||
.subscribe(result -> {
|
||||
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
|
||||
|
@ -410,8 +407,7 @@ public class DownloadDialog extends DialogFragment
|
|||
}
|
||||
}, throwable -> ErrorUtil.showSnackbar(context,
|
||||
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
|
||||
"Downloading subtitle stream size",
|
||||
currentInfo.getServiceId()))));
|
||||
"Downloading subtitle stream size", currentInfo))));
|
||||
}
|
||||
|
||||
private void setupAudioTrackSpinner() {
|
||||
|
@ -751,7 +747,6 @@ public class DownloadDialog extends DialogFragment
|
|||
}
|
||||
|
||||
private void showFailedDialog(@StringRes final int msg) {
|
||||
assureCorrectAppLanguage(requireContext());
|
||||
new AlertDialog.Builder(context)
|
||||
.setTitle(R.string.general_error)
|
||||
.setMessage(msg)
|
||||
|
|
|
@ -36,8 +36,8 @@ public class AcraReportSender implements ReportSender {
|
|||
ErrorUtil.openActivity(context, new ErrorInfo(
|
||||
new String[]{report.getString(ReportField.STACK_TRACE)},
|
||||
UserAction.UI_ERROR,
|
||||
ErrorInfo.SERVICE_NONE,
|
||||
"ACRA report",
|
||||
null,
|
||||
R.string.app_ui_crash));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
package org.schabi.newpipe.error;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
|
@ -26,7 +24,7 @@ import org.schabi.newpipe.util.Localization;
|
|||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Arrays;
|
||||
import java.util.stream.Collectors;
|
||||
|
@ -67,10 +65,6 @@ public class ErrorActivity extends AppCompatActivity {
|
|||
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");
|
||||
|
||||
|
||||
private ErrorInfo errorInfo;
|
||||
private String currentTimeStamp;
|
||||
|
||||
|
@ -83,7 +77,6 @@ public class ErrorActivity extends AppCompatActivity {
|
|||
|
||||
@Override
|
||||
protected void onCreate(final Bundle savedInstanceState) {
|
||||
assureCorrectAppLanguage(this);
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
ThemeHelper.setDayNightMode(this);
|
||||
|
@ -107,7 +100,9 @@ public class ErrorActivity extends AppCompatActivity {
|
|||
|
||||
// important add guru meditation
|
||||
addGuruMeditation();
|
||||
currentTimeStamp = CURRENT_TIMESTAMP_FORMATTER.format(LocalDateTime.now());
|
||||
// print current time, as zoned ISO8601 timestamp
|
||||
final ZonedDateTime now = ZonedDateTime.now();
|
||||
currentTimeStamp = now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
|
||||
|
||||
activityErrorBinding.errorReportEmailButton.setOnClickListener(v ->
|
||||
openPrivacyPolicyDialog(this, "EMAIL"));
|
||||
|
@ -120,7 +115,7 @@ public class ErrorActivity extends AppCompatActivity {
|
|||
|
||||
// normal bugreport
|
||||
buildInfo(errorInfo);
|
||||
activityErrorBinding.errorMessageView.setText(errorInfo.getMessageStringId());
|
||||
activityErrorBinding.errorMessageView.setText(errorInfo.getMessage(this));
|
||||
activityErrorBinding.errorView.setText(formErrorText(errorInfo.getStackTraces()));
|
||||
|
||||
// print stack trace once again for debugging:
|
||||
|
@ -250,6 +245,9 @@ public class ErrorActivity extends AppCompatActivity {
|
|||
.append("\n* __Content Language:__ ").append(getContentLanguageString())
|
||||
.append("\n* __App Language:__ ").append(getAppLanguage())
|
||||
.append("\n* __Service:__ ").append(errorInfo.getServiceName())
|
||||
.append("\n* __Timestamp:__ ").append(currentTimeStamp)
|
||||
.append("\n* __Package:__ ").append(getPackageName())
|
||||
.append("\n* __Service:__ ").append(errorInfo.getServiceName())
|
||||
.append("\n* __Version:__ ").append(BuildConfig.VERSION_NAME)
|
||||
.append("\n* __OS:__ ").append(getOsString()).append("\n");
|
||||
|
||||
|
@ -305,7 +303,7 @@ public class ErrorActivity extends AppCompatActivity {
|
|||
}
|
||||
|
||||
private String getAppLanguage() {
|
||||
return Localization.getAppLocale(getApplicationContext()).toString();
|
||||
return Localization.getAppLocale().toString();
|
||||
}
|
||||
|
||||
private String getOsString() {
|
||||
|
|
|
@ -1,115 +1,304 @@
|
|||
package org.schabi.newpipe.error
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.android.exoplayer2.ExoPlaybackException
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import com.google.android.exoplayer2.upstream.HttpDataSource
|
||||
import com.google.android.exoplayer2.upstream.Loader
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.Info
|
||||
import org.schabi.newpipe.extractor.ServiceList
|
||||
import org.schabi.newpipe.extractor.ServiceList.YouTube
|
||||
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
||||
import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException
|
||||
import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException
|
||||
import org.schabi.newpipe.extractor.exceptions.PaidContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.PrivateContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||
import org.schabi.newpipe.extractor.exceptions.SignInConfirmNotBotException
|
||||
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.UnsupportedContentInCountryException
|
||||
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException
|
||||
import org.schabi.newpipe.ktx.isNetworkRelated
|
||||
import org.schabi.newpipe.util.ServiceHelper
|
||||
import org.schabi.newpipe.player.mediasource.FailedMediaSource
|
||||
import org.schabi.newpipe.player.resolver.PlaybackResolver
|
||||
import java.net.UnknownHostException
|
||||
|
||||
/**
|
||||
* An error has occurred in the app. This class contains plain old parcelable data that can be used
|
||||
* to report the error and to show it to the user along with correct action buttons.
|
||||
*/
|
||||
@Parcelize
|
||||
class ErrorInfo(
|
||||
class ErrorInfo private constructor(
|
||||
val stackTraces: Array<String>,
|
||||
val userAction: UserAction,
|
||||
val serviceName: String,
|
||||
val request: String,
|
||||
val messageStringId: Int
|
||||
val serviceId: Int?,
|
||||
private val message: ErrorMessage,
|
||||
/**
|
||||
* If `true`, a report button will be shown for this error. Otherwise the error is not something
|
||||
* that can really be reported (e.g. a network issue, or content not being available at all).
|
||||
*/
|
||||
val isReportable: Boolean,
|
||||
/**
|
||||
* If `true`, the process causing this error can be retried, otherwise not.
|
||||
*/
|
||||
val isRetryable: Boolean,
|
||||
/**
|
||||
* If present, indicates that the exception was a ReCaptchaException, and this is the URL
|
||||
* provided by the service that can be used to solve the ReCaptcha challenge.
|
||||
*/
|
||||
val recaptchaUrl: String?,
|
||||
/**
|
||||
* If present, this resource can alternatively be opened in browser (useful if NewPipe is
|
||||
* badly broken).
|
||||
*/
|
||||
val openInBrowserUrl: String?,
|
||||
) : Parcelable {
|
||||
|
||||
// no need to store throwable, all data for report is in other variables
|
||||
// also, the throwable might not be serializable, see TeamNewPipe/NewPipe#7302
|
||||
@IgnoredOnParcel
|
||||
var throwable: Throwable? = null
|
||||
|
||||
private constructor(
|
||||
@JvmOverloads
|
||||
constructor(
|
||||
throwable: Throwable,
|
||||
userAction: UserAction,
|
||||
serviceName: String,
|
||||
request: String
|
||||
request: String,
|
||||
serviceId: Int? = null,
|
||||
openInBrowserUrl: String? = null,
|
||||
) : this(
|
||||
throwableToStringList(throwable),
|
||||
userAction,
|
||||
serviceName,
|
||||
request,
|
||||
getMessageStringId(throwable, userAction)
|
||||
) {
|
||||
this.throwable = throwable
|
||||
}
|
||||
serviceId,
|
||||
getMessage(throwable, userAction, serviceId),
|
||||
isReportable(throwable),
|
||||
isRetryable(throwable),
|
||||
(throwable as? ReCaptchaException)?.url,
|
||||
openInBrowserUrl,
|
||||
)
|
||||
|
||||
private constructor(
|
||||
throwable: List<Throwable>,
|
||||
@JvmOverloads
|
||||
constructor(
|
||||
throwables: List<Throwable>,
|
||||
userAction: UserAction,
|
||||
serviceName: String,
|
||||
request: String
|
||||
request: String,
|
||||
serviceId: Int? = null,
|
||||
openInBrowserUrl: String? = null,
|
||||
) : this(
|
||||
throwableListToStringList(throwable),
|
||||
throwableListToStringList(throwables),
|
||||
userAction,
|
||||
serviceName,
|
||||
request,
|
||||
getMessageStringId(throwable.firstOrNull(), userAction)
|
||||
) {
|
||||
this.throwable = throwable.firstOrNull()
|
||||
serviceId,
|
||||
getMessage(throwables.firstOrNull(), userAction, serviceId),
|
||||
throwables.any(::isReportable),
|
||||
throwables.isEmpty() || throwables.any(::isRetryable),
|
||||
throwables.firstNotNullOfOrNull { it as? ReCaptchaException }?.url,
|
||||
openInBrowserUrl,
|
||||
)
|
||||
|
||||
// constructor to manually build ErrorInfo when no throwable is available
|
||||
constructor(
|
||||
stackTraces: Array<String>,
|
||||
userAction: UserAction,
|
||||
request: String,
|
||||
serviceId: Int?,
|
||||
@StringRes message: Int
|
||||
) :
|
||||
this(
|
||||
stackTraces, userAction, request, serviceId, ErrorMessage(message),
|
||||
true, false, null, null
|
||||
)
|
||||
|
||||
// constructor with only one throwable to extract service id and openInBrowserUrl from an Info
|
||||
constructor(
|
||||
throwable: Throwable,
|
||||
userAction: UserAction,
|
||||
request: String,
|
||||
info: Info?,
|
||||
) :
|
||||
this(throwable, userAction, request, info?.serviceId, info?.url)
|
||||
|
||||
// constructor with multiple throwables to extract service id and openInBrowserUrl from an Info
|
||||
constructor(
|
||||
throwables: List<Throwable>,
|
||||
userAction: UserAction,
|
||||
request: String,
|
||||
info: Info?,
|
||||
) :
|
||||
this(throwables, userAction, request, info?.serviceId, info?.url)
|
||||
|
||||
fun getServiceName(): String {
|
||||
return getServiceName(serviceId)
|
||||
}
|
||||
|
||||
// constructors with single throwable
|
||||
constructor(throwable: Throwable, userAction: UserAction, request: String) :
|
||||
this(throwable, userAction, SERVICE_NONE, request)
|
||||
constructor(throwable: Throwable, userAction: UserAction, request: String, serviceId: Int) :
|
||||
this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request)
|
||||
constructor(throwable: Throwable, userAction: UserAction, request: String, info: Info?) :
|
||||
this(throwable, userAction, getInfoServiceName(info), request)
|
||||
|
||||
// constructors with list of throwables
|
||||
constructor(throwable: List<Throwable>, userAction: UserAction, request: String) :
|
||||
this(throwable, userAction, SERVICE_NONE, request)
|
||||
constructor(throwable: List<Throwable>, userAction: UserAction, request: String, serviceId: Int) :
|
||||
this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request)
|
||||
constructor(throwable: List<Throwable>, userAction: UserAction, request: String, info: Info?) :
|
||||
this(throwable, userAction, getInfoServiceName(info), request)
|
||||
fun getMessage(context: Context): String {
|
||||
return message.getString(context)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SERVICE_NONE = "none"
|
||||
@Parcelize
|
||||
class ErrorMessage(
|
||||
@StringRes
|
||||
private val stringRes: Int,
|
||||
private vararg val formatArgs: String,
|
||||
) : Parcelable {
|
||||
fun getString(context: Context): String {
|
||||
return if (formatArgs.isEmpty()) {
|
||||
// use ContextCompat.getString() just in case context is not AppCompatActivity
|
||||
ContextCompat.getString(context, stringRes)
|
||||
} else {
|
||||
// ContextCompat.getString() with formatArgs does not exist, so we just
|
||||
// replicate its source code but with formatArgs
|
||||
ContextCompat.getContextForLanguage(context).getString(stringRes, *formatArgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const val SERVICE_NONE = "<unknown_service>"
|
||||
|
||||
private fun getServiceName(serviceId: Int?) =
|
||||
// not using getNameOfServiceById since we want to accept a nullable serviceId and we
|
||||
// want to default to SERVICE_NONE
|
||||
ServiceList.all()?.firstOrNull { it.serviceId == serviceId }?.serviceInfo?.name
|
||||
?: SERVICE_NONE
|
||||
|
||||
fun throwableToStringList(throwable: Throwable) = arrayOf(throwable.stackTraceToString())
|
||||
|
||||
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)
|
||||
|
||||
@StringRes
|
||||
private fun getMessageStringId(
|
||||
fun getMessage(
|
||||
throwable: Throwable?,
|
||||
action: UserAction
|
||||
): Int {
|
||||
action: UserAction?,
|
||||
serviceId: Int?,
|
||||
): ErrorMessage {
|
||||
return when {
|
||||
throwable is AccountTerminatedException -> R.string.account_terminated
|
||||
throwable is ContentNotAvailableException -> R.string.content_not_available
|
||||
throwable != null && throwable.isNetworkRelated -> R.string.network_error
|
||||
throwable is ContentNotSupportedException -> R.string.content_not_supported
|
||||
throwable is ExtractionException -> R.string.parsing_error
|
||||
// player exceptions
|
||||
// some may be IOException, so do these checks before isNetworkRelated!
|
||||
throwable is ExoPlaybackException -> {
|
||||
when (throwable.type) {
|
||||
ExoPlaybackException.TYPE_SOURCE -> R.string.player_stream_failure
|
||||
ExoPlaybackException.TYPE_UNEXPECTED -> R.string.player_recoverable_failure
|
||||
else -> R.string.player_unrecoverable_failure
|
||||
val cause = throwable.cause
|
||||
when {
|
||||
cause is HttpDataSource.InvalidResponseCodeException -> {
|
||||
if (cause.responseCode == 403) {
|
||||
if (serviceId == YouTube.serviceId) {
|
||||
ErrorMessage(R.string.youtube_player_http_403)
|
||||
} else {
|
||||
ErrorMessage(R.string.player_http_403)
|
||||
}
|
||||
} else {
|
||||
ErrorMessage(R.string.player_http_invalid_status, cause.responseCode.toString())
|
||||
}
|
||||
}
|
||||
cause is Loader.UnexpectedLoaderException && cause.cause is ExtractionException ->
|
||||
getMessage(throwable, action, serviceId)
|
||||
throwable.type == ExoPlaybackException.TYPE_SOURCE ->
|
||||
ErrorMessage(R.string.player_stream_failure)
|
||||
throwable.type == ExoPlaybackException.TYPE_UNEXPECTED ->
|
||||
ErrorMessage(R.string.player_recoverable_failure)
|
||||
else ->
|
||||
ErrorMessage(R.string.player_unrecoverable_failure)
|
||||
}
|
||||
}
|
||||
action == UserAction.UI_ERROR -> R.string.app_ui_crash
|
||||
action == UserAction.REQUESTED_COMMENTS -> R.string.error_unable_to_load_comments
|
||||
action == UserAction.SUBSCRIPTION_CHANGE -> R.string.subscription_change_failed
|
||||
action == UserAction.SUBSCRIPTION_UPDATE -> R.string.subscription_update_failed
|
||||
action == UserAction.LOAD_IMAGE -> R.string.could_not_load_thumbnails
|
||||
action == UserAction.DOWNLOAD_OPEN_DIALOG -> R.string.could_not_setup_download_menu
|
||||
else -> R.string.general_error
|
||||
throwable is FailedMediaSource.FailedMediaSourceException ->
|
||||
getMessage(throwable.cause, action, serviceId)
|
||||
throwable is PlaybackResolver.ResolverException ->
|
||||
ErrorMessage(R.string.player_stream_failure)
|
||||
|
||||
// content not available exceptions
|
||||
throwable is AccountTerminatedException ->
|
||||
throwable.message
|
||||
?.takeIf { reason -> !reason.isEmpty() }
|
||||
?.let { reason ->
|
||||
ErrorMessage(
|
||||
R.string.account_terminated_service_provides_reason,
|
||||
getServiceName(serviceId),
|
||||
reason
|
||||
)
|
||||
}
|
||||
?: ErrorMessage(R.string.account_terminated)
|
||||
throwable is AgeRestrictedContentException ->
|
||||
ErrorMessage(R.string.restricted_video_no_stream)
|
||||
throwable is GeographicRestrictionException ->
|
||||
ErrorMessage(R.string.georestricted_content)
|
||||
throwable is PaidContentException ->
|
||||
ErrorMessage(R.string.paid_content)
|
||||
throwable is PrivateContentException ->
|
||||
ErrorMessage(R.string.private_content)
|
||||
throwable is SoundCloudGoPlusContentException ->
|
||||
ErrorMessage(R.string.soundcloud_go_plus_content)
|
||||
throwable is UnsupportedContentInCountryException ->
|
||||
ErrorMessage(R.string.unsupported_content_in_country)
|
||||
throwable is YoutubeMusicPremiumContentException ->
|
||||
ErrorMessage(R.string.youtube_music_premium_content)
|
||||
throwable is SignInConfirmNotBotException ->
|
||||
ErrorMessage(R.string.sign_in_confirm_not_bot_error, getServiceName(serviceId))
|
||||
throwable is ContentNotAvailableException ->
|
||||
ErrorMessage(R.string.content_not_available)
|
||||
|
||||
// other extractor exceptions
|
||||
throwable is ContentNotSupportedException ->
|
||||
ErrorMessage(R.string.content_not_supported)
|
||||
// ReCaptchas will be handled in a special way anyway
|
||||
throwable is ReCaptchaException ->
|
||||
ErrorMessage(R.string.recaptcha_request_toast)
|
||||
// test this at the end as many exceptions could be a subclass of IOException
|
||||
throwable != null && throwable.isNetworkRelated ->
|
||||
ErrorMessage(R.string.network_error)
|
||||
// an extraction exception unrelated to the network
|
||||
// is likely an issue with parsing the website
|
||||
throwable is ExtractionException ->
|
||||
ErrorMessage(R.string.parsing_error)
|
||||
|
||||
// user actions (in case the exception is null or unrecognizable)
|
||||
action == UserAction.UI_ERROR ->
|
||||
ErrorMessage(R.string.app_ui_crash)
|
||||
action == UserAction.REQUESTED_COMMENTS ->
|
||||
ErrorMessage(R.string.error_unable_to_load_comments)
|
||||
action == UserAction.SUBSCRIPTION_CHANGE ->
|
||||
ErrorMessage(R.string.subscription_change_failed)
|
||||
action == UserAction.SUBSCRIPTION_UPDATE ->
|
||||
ErrorMessage(R.string.subscription_update_failed)
|
||||
action == UserAction.LOAD_IMAGE ->
|
||||
ErrorMessage(R.string.could_not_load_thumbnails)
|
||||
action == UserAction.DOWNLOAD_OPEN_DIALOG ->
|
||||
ErrorMessage(R.string.could_not_setup_download_menu)
|
||||
else ->
|
||||
ErrorMessage(R.string.error_snackbar_message)
|
||||
}
|
||||
}
|
||||
|
||||
fun isReportable(throwable: Throwable?): Boolean {
|
||||
return when (throwable) {
|
||||
// we don't have an exception, so this is a manually built error, which likely
|
||||
// indicates that it's important and is thus reportable
|
||||
null -> true
|
||||
// the service explicitly said that content is not available (e.g. age restrictions,
|
||||
// video deleted, etc.), there is no use in letting users report it
|
||||
is ContentNotAvailableException -> false
|
||||
// we know the content is not supported, no need to let the user report it
|
||||
is ContentNotSupportedException -> false
|
||||
// happens often when there is no internet connection; we don't use
|
||||
// `throwable.isNetworkRelated` since any `IOException` would make that function
|
||||
// return true, but not all `IOException`s are network related
|
||||
is UnknownHostException -> false
|
||||
// by default, this is an unexpected exception, which the user could report
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
|
||||
fun isRetryable(throwable: Throwable?): Boolean {
|
||||
return when (throwable) {
|
||||
// we know the content is not available, retrying won't help
|
||||
is ContentNotAvailableException -> false
|
||||
// we know the content is not supported, retrying won't help
|
||||
is ContentNotSupportedException -> false
|
||||
// by default (including if throwable is null), enable retrying (though the retry
|
||||
// button will be shown only if a way to perform the retry is implemented)
|
||||
else -> true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ package org.schabi.newpipe.error
|
|||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.TextView
|
||||
|
@ -14,28 +13,14 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
|||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import org.schabi.newpipe.MainActivity
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
|
||||
import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
|
||||
import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException
|
||||
import org.schabi.newpipe.extractor.exceptions.PaidContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.PrivateContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException
|
||||
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException
|
||||
import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
|
||||
import org.schabi.newpipe.ktx.animate
|
||||
import org.schabi.newpipe.ktx.isInterruptedCaused
|
||||
import org.schabi.newpipe.ktx.isNetworkRelated
|
||||
import org.schabi.newpipe.util.ServiceHelper
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class ErrorPanelHelper(
|
||||
private val fragment: Fragment,
|
||||
rootView: View,
|
||||
onRetry: Runnable
|
||||
onRetry: Runnable?,
|
||||
) {
|
||||
private val context: Context = rootView.context!!
|
||||
|
||||
|
@ -56,12 +41,15 @@ class ErrorPanelHelper(
|
|||
errorPanelRoot.findViewById(R.id.error_open_in_browser)
|
||||
|
||||
private var errorDisposable: Disposable? = null
|
||||
private var retryShouldBeShown: Boolean = (onRetry != null)
|
||||
|
||||
init {
|
||||
errorDisposable = errorRetryButton.clicks()
|
||||
.debounce(300, TimeUnit.MILLISECONDS)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { onRetry.run() }
|
||||
if (onRetry != null) {
|
||||
errorDisposable = errorRetryButton.clicks()
|
||||
.debounce(300, TimeUnit.MILLISECONDS)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { onRetry.run() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureDefaultVisibility() {
|
||||
|
@ -75,64 +63,32 @@ class ErrorPanelHelper(
|
|||
}
|
||||
|
||||
fun showError(errorInfo: ErrorInfo) {
|
||||
|
||||
if (errorInfo.throwable != null && errorInfo.throwable!!.isInterruptedCaused) {
|
||||
if (DEBUG) {
|
||||
Log.w(TAG, "onError() isInterruptedCaused! = [$errorInfo.throwable]")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ensureDefaultVisibility()
|
||||
errorTextView.text = errorInfo.getMessage(context)
|
||||
|
||||
if (errorInfo.throwable is ReCaptchaException) {
|
||||
errorTextView.setText(R.string.recaptcha_request_toast)
|
||||
|
||||
showAndSetErrorButtonAction(
|
||||
R.string.recaptcha_solve
|
||||
) {
|
||||
if (errorInfo.recaptchaUrl != null) {
|
||||
showAndSetErrorButtonAction(R.string.recaptcha_solve) {
|
||||
// Starting ReCaptcha Challenge Activity
|
||||
val intent = Intent(context, ReCaptchaActivity::class.java)
|
||||
intent.putExtra(
|
||||
ReCaptchaActivity.RECAPTCHA_URL_EXTRA,
|
||||
(errorInfo.throwable as ReCaptchaException).url
|
||||
)
|
||||
intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, errorInfo.recaptchaUrl)
|
||||
fragment.startActivityForResult(intent, ReCaptchaActivity.RECAPTCHA_REQUEST)
|
||||
errorActionButton.setOnClickListener(null)
|
||||
}
|
||||
|
||||
errorRetryButton.isVisible = true
|
||||
showAndSetOpenInBrowserButtonAction(errorInfo)
|
||||
} else if (errorInfo.throwable is AccountTerminatedException) {
|
||||
errorTextView.setText(R.string.account_terminated)
|
||||
|
||||
if (!isNullOrEmpty((errorInfo.throwable as AccountTerminatedException).message)) {
|
||||
errorServiceInfoTextView.text = context.resources.getString(
|
||||
R.string.service_provides_reason,
|
||||
ServiceHelper.getSelectedService(context)?.serviceInfo?.name ?: "<unknown>"
|
||||
)
|
||||
errorServiceInfoTextView.isVisible = true
|
||||
|
||||
errorServiceExplanationTextView.text =
|
||||
(errorInfo.throwable as AccountTerminatedException).message
|
||||
errorServiceExplanationTextView.isVisible = true
|
||||
}
|
||||
} else {
|
||||
showAndSetErrorButtonAction(
|
||||
R.string.error_snackbar_action
|
||||
) {
|
||||
} else if (errorInfo.isReportable) {
|
||||
showAndSetErrorButtonAction(R.string.error_snackbar_action) {
|
||||
ErrorUtil.openActivity(context, errorInfo)
|
||||
}
|
||||
}
|
||||
|
||||
errorTextView.setText(getExceptionDescription(errorInfo.throwable))
|
||||
if (errorInfo.isRetryable) {
|
||||
errorRetryButton.isVisible = retryShouldBeShown
|
||||
}
|
||||
|
||||
if (errorInfo.throwable !is ContentNotAvailableException &&
|
||||
errorInfo.throwable !is ContentNotSupportedException
|
||||
) {
|
||||
// show retry button only for content which is not unavailable or unsupported
|
||||
errorRetryButton.isVisible = true
|
||||
if (errorInfo.openInBrowserUrl != null) {
|
||||
errorOpenInBrowserButton.isVisible = true
|
||||
errorOpenInBrowserButton.setOnClickListener {
|
||||
ShareUtils.openUrlInBrowser(context, errorInfo.openInBrowserUrl)
|
||||
}
|
||||
showAndSetOpenInBrowserButtonAction(errorInfo)
|
||||
}
|
||||
|
||||
setRootVisible()
|
||||
|
@ -150,15 +106,6 @@ class ErrorPanelHelper(
|
|||
errorActionButton.setOnClickListener(listener)
|
||||
}
|
||||
|
||||
fun showAndSetOpenInBrowserButtonAction(
|
||||
errorInfo: ErrorInfo
|
||||
) {
|
||||
errorOpenInBrowserButton.isVisible = true
|
||||
errorOpenInBrowserButton.setOnClickListener {
|
||||
ShareUtils.openUrlInBrowser(context, errorInfo.request)
|
||||
}
|
||||
}
|
||||
|
||||
fun showTextError(errorString: String) {
|
||||
ensureDefaultVisibility()
|
||||
|
||||
|
@ -189,27 +136,5 @@ class ErrorPanelHelper(
|
|||
companion object {
|
||||
val TAG: String = ErrorPanelHelper::class.simpleName!!
|
||||
val DEBUG: Boolean = MainActivity.DEBUG
|
||||
|
||||
@StringRes
|
||||
fun getExceptionDescription(throwable: Throwable?): Int {
|
||||
return when (throwable) {
|
||||
is AgeRestrictedContentException -> R.string.restricted_video_no_stream
|
||||
is GeographicRestrictionException -> R.string.georestricted_content
|
||||
is PaidContentException -> R.string.paid_content
|
||||
is PrivateContentException -> R.string.private_content
|
||||
is SoundCloudGoPlusContentException -> R.string.soundcloud_go_plus_content
|
||||
is YoutubeMusicPremiumContentException -> R.string.youtube_music_premium_content
|
||||
is ContentNotAvailableException -> R.string.content_not_available
|
||||
is ContentNotSupportedException -> R.string.content_not_supported
|
||||
else -> {
|
||||
// show retry button only for content which is not unavailable or unsupported
|
||||
if (throwable != null && throwable.isNetworkRelated) {
|
||||
R.string.network_error
|
||||
} else {
|
||||
R.string.error_snackbar_message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,8 +10,11 @@ import android.widget.Toast
|
|||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.schabi.newpipe.MainActivity
|
||||
import org.schabi.newpipe.R
|
||||
|
||||
/**
|
||||
|
@ -35,12 +38,20 @@ class ErrorUtil {
|
|||
* activity (since the workflow would be interrupted anyway in that case). So never use this
|
||||
* for background services.
|
||||
*
|
||||
* If the crashed occurred while the app was in the background open a notification instead
|
||||
*
|
||||
* @param context the context to use to start the new activity
|
||||
* @param errorInfo the error info to be reported
|
||||
*/
|
||||
@JvmStatic
|
||||
fun openActivity(context: Context, errorInfo: ErrorInfo) {
|
||||
context.startActivity(getErrorActivityIntent(context, errorInfo))
|
||||
if (PreferenceManager.getDefaultSharedPreferences(context)
|
||||
.getBoolean(MainActivity.KEY_IS_IN_BACKGROUND, true)
|
||||
) {
|
||||
createNotification(context, errorInfo)
|
||||
} else {
|
||||
context.startActivity(getErrorActivityIntent(context, errorInfo))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -111,7 +122,7 @@ class ErrorUtil {
|
|||
)
|
||||
.setSmallIcon(R.drawable.ic_bug_report)
|
||||
.setContentTitle(context.getString(R.string.error_report_notification_title))
|
||||
.setContentText(context.getString(errorInfo.messageStringId))
|
||||
.setContentText(errorInfo.getMessage(context))
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(
|
||||
PendingIntentCompat.getActivity(
|
||||
|
@ -126,9 +137,11 @@ class ErrorUtil {
|
|||
NotificationManagerCompat.from(context)
|
||||
.notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build())
|
||||
|
||||
// since the notification is silent, also show a toast, otherwise the user is confused
|
||||
Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
ContextCompat.getMainExecutor(context).execute {
|
||||
// since the notification is silent, also show a toast, otherwise the user is confused
|
||||
Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun getErrorActivityIntent(context: Context, errorInfo: ErrorInfo): Intent {
|
||||
|
@ -143,10 +156,10 @@ class ErrorUtil {
|
|||
// fallback to showing a notification if no root view is available
|
||||
createNotification(context, errorInfo)
|
||||
} else {
|
||||
Snackbar.make(rootView, R.string.error_snackbar_message, Snackbar.LENGTH_LONG)
|
||||
Snackbar.make(rootView, errorInfo.getMessage(context), Snackbar.LENGTH_LONG)
|
||||
.setActionTextColor(Color.YELLOW)
|
||||
.setAction(context.getString(R.string.error_snackbar_action).uppercase()) {
|
||||
openActivity(context, errorInfo)
|
||||
context.startActivity(getErrorActivityIntent(context, errorInfo))
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,7 +32,10 @@ public enum UserAction {
|
|||
PREFERENCES_MIGRATION("migration of preferences"),
|
||||
SHARE_TO_NEWPIPE("share to newpipe"),
|
||||
CHECK_FOR_NEW_APP_VERSION("check for new app version"),
|
||||
OPEN_INFO_ITEM_DIALOG("open info item dialog");
|
||||
OPEN_INFO_ITEM_DIALOG("open info item dialog"),
|
||||
GETTING_MAIN_SCREEN_TAB("getting main screen tab"),
|
||||
PLAY_ON_POPUP("play on popup"),
|
||||
SUBSCRIPTIONS("loading subscriptions");
|
||||
|
||||
private final String message;
|
||||
|
||||
|
|
|
@ -7,16 +7,57 @@ import android.view.ViewGroup;
|
|||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.evernote.android.state.State;
|
||||
|
||||
import org.schabi.newpipe.BaseFragment;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorPanelHelper;
|
||||
|
||||
public class BlankFragment extends BaseFragment {
|
||||
|
||||
@State
|
||||
@Nullable
|
||||
ErrorInfo errorInfo;
|
||||
@Nullable
|
||||
ErrorPanelHelper errorPanel = null;
|
||||
|
||||
/**
|
||||
* Builds a blank fragment that just says the app name and suggests clicking on search.
|
||||
*/
|
||||
public BlankFragment() {
|
||||
this(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param errorInfo if null acts like {@link BlankFragment}, else shows an error panel.
|
||||
*/
|
||||
public BlankFragment(@Nullable final ErrorInfo errorInfo) {
|
||||
this.errorInfo = errorInfo;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container,
|
||||
final Bundle savedInstanceState) {
|
||||
setTitle("NewPipe");
|
||||
return inflater.inflate(R.layout.fragment_blank, container, false);
|
||||
final View view = inflater.inflate(R.layout.fragment_blank, container, false);
|
||||
if (errorInfo != null) {
|
||||
errorPanel = new ErrorPanelHelper(this, view, null);
|
||||
errorPanel.showError(errorInfo);
|
||||
view.findViewById(R.id.blank_page_content).setVisibility(View.GONE);
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
|
||||
if (errorPanel != null) {
|
||||
errorPanel.dispose();
|
||||
errorPanel = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -36,8 +36,9 @@ import com.google.android.material.tabs.TabLayout;
|
|||
import org.schabi.newpipe.BaseFragment;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.FragmentMainBinding;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
|
||||
import org.schabi.newpipe.settings.tabs.Tab;
|
||||
import org.schabi.newpipe.settings.tabs.TabsManager;
|
||||
|
@ -303,9 +304,9 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
|||
final Fragment fragment;
|
||||
try {
|
||||
fragment = tab.getFragment(context);
|
||||
} catch (final ExtractionException e) {
|
||||
ErrorUtil.showUiErrorSnackbar(context, "Getting fragment item", e);
|
||||
return new BlankFragment();
|
||||
} catch (final Throwable t) {
|
||||
return new BlankFragment(new ErrorInfo(t, UserAction.GETTING_MAIN_SCREEN_TAB,
|
||||
"Tab " + tab.getClass().getSimpleName() + ":" + tab.getTabName(context)));
|
||||
}
|
||||
|
||||
if (fragment instanceof BaseFragment) {
|
||||
|
|
|
@ -93,7 +93,7 @@ public class DescriptionFragment extends BaseDescriptionFragment {
|
|||
|
||||
if (streamInfo.getLanguageInfo() != null) {
|
||||
addMetadataItem(inflater, layout, false, R.string.metadata_language,
|
||||
streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale(getContext())));
|
||||
streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale()));
|
||||
}
|
||||
|
||||
addMetadataItem(inflater, layout, true, R.string.metadata_support,
|
||||
|
|
|
@ -93,6 +93,7 @@ import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
|||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
|
||||
import org.schabi.newpipe.player.Player;
|
||||
import org.schabi.newpipe.player.PlayerIntentType;
|
||||
import org.schabi.newpipe.player.PlayerService;
|
||||
import org.schabi.newpipe.player.PlayerType;
|
||||
import org.schabi.newpipe.player.event.OnKeyDownListener;
|
||||
|
@ -236,11 +237,14 @@ public final class VideoDetailFragment
|
|||
// Service management
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@Override
|
||||
public void onServiceConnected(final Player connectedPlayer,
|
||||
final PlayerService connectedPlayerService,
|
||||
final boolean playAfterConnect) {
|
||||
player = connectedPlayer;
|
||||
public void onServiceConnected(@NonNull final PlayerService connectedPlayerService) {
|
||||
playerService = connectedPlayerService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerConnected(@NonNull final Player connectedPlayer,
|
||||
final boolean playAfterConnect) {
|
||||
player = connectedPlayer;
|
||||
|
||||
// It will do nothing if the player is not in fullscreen mode
|
||||
hideSystemUiIfNeeded();
|
||||
|
@ -272,22 +276,29 @@ public final class VideoDetailFragment
|
|||
updateOverlayPlayQueueButtonVisibility();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerDisconnected() {
|
||||
player = null;
|
||||
// the binding could be null at this point, if the app is finishing
|
||||
if (binding != null) {
|
||||
restoreDefaultBrightness();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected() {
|
||||
playerService = null;
|
||||
player = null;
|
||||
restoreDefaultBrightness();
|
||||
}
|
||||
|
||||
|
||||
/*////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
public static VideoDetailFragment getInstance(final int serviceId,
|
||||
@Nullable final String videoUrl,
|
||||
@Nullable final String url,
|
||||
@NonNull final String name,
|
||||
@Nullable final PlayQueue queue) {
|
||||
final VideoDetailFragment instance = new VideoDetailFragment();
|
||||
instance.setInitialData(serviceId, videoUrl, name, queue);
|
||||
instance.setInitialData(serviceId, url, name, queue);
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
@ -866,7 +877,7 @@ public final class VideoDetailFragment
|
|||
}
|
||||
}
|
||||
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_STREAM,
|
||||
url == null ? "no url" : url, serviceId)));
|
||||
url == null ? "no url" : url, serviceId, url)));
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
|
@ -1156,8 +1167,12 @@ public final class VideoDetailFragment
|
|||
final PlayQueue queue = setupPlayQueueForIntent(false);
|
||||
tryAddVideoPlayerView();
|
||||
|
||||
final Intent playerIntent = NavigationHelper.getPlayerIntent(requireContext(),
|
||||
PlayerService.class, queue, true, autoPlayEnabled);
|
||||
final Context context = requireContext();
|
||||
final Intent playerIntent =
|
||||
NavigationHelper.getPlayerIntent(context, PlayerService.class, queue,
|
||||
PlayerIntentType.AllOthers)
|
||||
.putExtra(Player.PLAY_WHEN_READY, autoPlayEnabled)
|
||||
.putExtra(Player.RESUME_PLAYBACK, true);
|
||||
ContextCompat.startForegroundService(activity, playerIntent);
|
||||
}
|
||||
|
||||
|
@ -1401,10 +1416,8 @@ public final class VideoDetailFragment
|
|||
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
|
||||
}
|
||||
// Rebound to the service if it was closed via notification or mini player
|
||||
if (!playerHolder.isBound()) {
|
||||
playerHolder.startService(
|
||||
false, VideoDetailFragment.this);
|
||||
}
|
||||
playerHolder.setListener(VideoDetailFragment.this);
|
||||
playerHolder.tryBindIfNeeded(context);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -1413,7 +1426,8 @@ public final class VideoDetailFragment
|
|||
intentFilter.addAction(ACTION_SHOW_MAIN_PLAYER);
|
||||
intentFilter.addAction(ACTION_HIDE_MAIN_PLAYER);
|
||||
intentFilter.addAction(ACTION_PLAYER_STARTED);
|
||||
activity.registerReceiver(broadcastReceiver, intentFilter);
|
||||
ContextCompat.registerReceiver(activity, broadcastReceiver, intentFilter,
|
||||
ContextCompat.RECEIVER_EXPORTED);
|
||||
}
|
||||
|
||||
|
||||
|
@ -1582,8 +1596,8 @@ public final class VideoDetailFragment
|
|||
}
|
||||
|
||||
if (!info.getErrors().isEmpty()) {
|
||||
showSnackBarError(new ErrorInfo(info.getErrors(),
|
||||
UserAction.REQUESTED_STREAM, info.getUrl(), info));
|
||||
showSnackBarError(new ErrorInfo(info.getErrors(), UserAction.REQUESTED_STREAM,
|
||||
"Some info not extracted: " + info.getUrl(), info));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1736,7 +1750,7 @@ public final class VideoDetailFragment
|
|||
playQueue = queue;
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onQueueUpdate() called with: serviceId = ["
|
||||
+ serviceId + "], videoUrl = [" + url + "], name = ["
|
||||
+ serviceId + "], url = [" + url + "], name = ["
|
||||
+ title + "], playQueue = [" + playQueue + "]");
|
||||
}
|
||||
|
||||
|
@ -1848,13 +1862,16 @@ public final class VideoDetailFragment
|
|||
|
||||
@Override
|
||||
public void onServiceStopped() {
|
||||
setOverlayPlayPauseImage(false);
|
||||
if (currentInfo != null) {
|
||||
updateOverlayData(currentInfo.getName(),
|
||||
currentInfo.getUploaderName(),
|
||||
currentInfo.getThumbnails());
|
||||
// the binding could be null at this point, if the app is finishing
|
||||
if (binding != null) {
|
||||
setOverlayPlayPauseImage(false);
|
||||
if (currentInfo != null) {
|
||||
updateOverlayData(currentInfo.getName(),
|
||||
currentInfo.getUploaderName(),
|
||||
currentInfo.getThumbnails());
|
||||
}
|
||||
updateOverlayPlayQueueButtonVisibility();
|
||||
}
|
||||
updateOverlayPlayQueueButtonVisibility();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -8,6 +8,7 @@ import android.util.Log;
|
|||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.evernote.android.state.State;
|
||||
|
||||
|
@ -42,6 +43,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
|
|||
|
||||
private final UserAction errorUserAction;
|
||||
protected L currentInfo;
|
||||
@Nullable
|
||||
protected Page currentNextPage;
|
||||
protected Disposable currentWorker;
|
||||
|
||||
|
@ -151,7 +153,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
|
|||
handleResult(result);
|
||||
}, throwable ->
|
||||
showError(new ErrorInfo(throwable, errorUserAction,
|
||||
"Start loading: " + url, serviceId)));
|
||||
"Start loading: " + url, serviceId, url)));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -182,7 +184,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
|
|||
handleNextItems(infoItemsPage);
|
||||
}, (@NonNull Throwable throwable) ->
|
||||
dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(throwable,
|
||||
errorUserAction, "Loading more items: " + url, serviceId)));
|
||||
errorUserAction, "Loading more items: " + url, serviceId, url)));
|
||||
}
|
||||
|
||||
private void forbidDownwardFocusScroll() {
|
||||
|
@ -208,7 +210,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
|
|||
|
||||
if (!result.getErrors().isEmpty()) {
|
||||
dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(result.getErrors(), errorUserAction,
|
||||
"Get next items of: " + url, serviceId));
|
||||
"Get next items of: " + url, serviceId, url));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -248,7 +250,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
|
|||
|
||||
if (!errors.isEmpty()) {
|
||||
dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(result.getErrors(),
|
||||
errorUserAction, "Start loading: " + url, serviceId));
|
||||
errorUserAction, "Start loading: " + url, serviceId, url));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -81,9 +81,7 @@ public class ChannelAboutFragment extends BaseDescriptionFragment {
|
|||
|
||||
if (channelInfo.getSubscriberCount() != UNKNOWN_SUBSCRIBER_COUNT) {
|
||||
addMetadataItem(inflater, layout, false, R.string.metadata_subscribers,
|
||||
Localization.localizeNumber(
|
||||
requireContext(),
|
||||
channelInfo.getSubscriberCount()));
|
||||
Localization.localizeNumber(channelInfo.getSubscriberCount()));
|
||||
}
|
||||
|
||||
addImagesMetadataItem(inflater, layout, R.string.metadata_avatars,
|
||||
|
|
|
@ -120,67 +120,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
|||
// LifeCycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCreate(final Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
menuProvider = new MenuProvider() {
|
||||
@Override
|
||||
public void onCreateMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) {
|
||||
inflater.inflate(R.menu.menu_channel, menu);
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreateOptionsMenu() called with: "
|
||||
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPrepareMenu(@NonNull final Menu menu) {
|
||||
menuRssButton = menu.findItem(R.id.menu_item_rss);
|
||||
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
|
||||
updateRssButton();
|
||||
updateNotifyButton(channelSubscription);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onMenuItemSelected(@NonNull final MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case R.id.menu_item_notify:
|
||||
final boolean value = !item.isChecked();
|
||||
item.setEnabled(false);
|
||||
setNotify(value);
|
||||
break;
|
||||
case R.id.action_settings:
|
||||
NavigationHelper.openSettings(requireContext());
|
||||
break;
|
||||
case R.id.menu_item_rss:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl());
|
||||
}
|
||||
break;
|
||||
case R.id.menu_item_openInBrowser:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.openUrlInBrowser(requireContext(),
|
||||
currentInfo.getOriginalUrl());
|
||||
}
|
||||
break;
|
||||
case R.id.menu_item_share:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.shareText(requireContext(), name,
|
||||
currentInfo.getOriginalUrl(), currentInfo.getAvatars());
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
activity.addMenuProvider(menuProvider);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(@NonNull final Context context) {
|
||||
super.onAttach(context);
|
||||
|
@ -195,6 +134,67 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
|||
return binding.getRoot();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
|
||||
super.onViewCreated(rootView, savedInstanceState);
|
||||
menuProvider = new MenuProvider() {
|
||||
@Override
|
||||
public void onCreateMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) {
|
||||
inflater.inflate(R.menu.menu_channel, menu);
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreateOptionsMenu() called with: "
|
||||
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPrepareMenu(@NonNull final Menu menu) {
|
||||
menuRssButton = menu.findItem(R.id.menu_item_rss);
|
||||
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
|
||||
updateRssButton();
|
||||
updateNotifyButton(channelSubscription);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onMenuItemSelected(@NonNull final MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case R.id.menu_item_notify:
|
||||
final boolean value = !item.isChecked();
|
||||
item.setEnabled(false);
|
||||
setNotify(value);
|
||||
break;
|
||||
case R.id.action_settings:
|
||||
NavigationHelper.openSettings(requireContext());
|
||||
break;
|
||||
case R.id.menu_item_rss:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl());
|
||||
}
|
||||
break;
|
||||
case R.id.menu_item_openInBrowser:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.openUrlInBrowser(requireContext(),
|
||||
currentInfo.getOriginalUrl());
|
||||
}
|
||||
break;
|
||||
case R.id.menu_item_share:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.shareText(requireContext(), name,
|
||||
currentInfo.getOriginalUrl(), currentInfo.getAvatars());
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
activity.addMenuProvider(menuProvider);
|
||||
}
|
||||
|
||||
@Override // called from onViewCreated in BaseFragment.onViewCreated
|
||||
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
||||
super.initViews(rootView, savedInstanceState);
|
||||
|
@ -232,6 +232,14 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
|||
binding.subChannelTitleView.setOnClickListener(openSubChannel);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
if (menuProvider != null) {
|
||||
activity.removeMenuProvider(menuProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
|
@ -240,7 +248,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
|||
}
|
||||
disposables.clear();
|
||||
binding = null;
|
||||
activity.removeMenuProvider(menuProvider);
|
||||
menuProvider = null;
|
||||
}
|
||||
|
||||
|
@ -570,7 +577,7 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
|||
isLoading.set(false);
|
||||
handleResult(result);
|
||||
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_CHANNEL,
|
||||
url == null ? "No URL" : url, serviceId)));
|
||||
url == null ? "No URL" : url, serviceId, url)));
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -28,6 +28,7 @@ import org.schabi.newpipe.util.NavigationHelper;
|
|||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.text.TextLinkifier;
|
||||
import org.schabi.newpipe.util.text.LongPressLinkMovementMethod;
|
||||
|
||||
import java.util.Queue;
|
||||
import java.util.function.Supplier;
|
||||
|
@ -110,7 +111,7 @@ public final class CommentRepliesFragment
|
|||
TextLinkifier.fromDescription(binding.commentContent, item.getCommentText(),
|
||||
HtmlCompat.FROM_HTML_MODE_LEGACY, getServiceById(item.getServiceId()),
|
||||
item.getUrl(), disposables, null);
|
||||
|
||||
binding.commentContent.setMovementMethod(LongPressLinkMovementMethod.getInstance());
|
||||
return binding.getRoot();
|
||||
};
|
||||
}
|
||||
|
|
|
@ -54,6 +54,7 @@ import org.schabi.newpipe.extractor.MetaInfo;
|
|||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.Page;
|
||||
import org.schabi.newpipe.extractor.StreamingService;
|
||||
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
||||
import org.schabi.newpipe.extractor.search.SearchExtractor;
|
||||
import org.schabi.newpipe.extractor.search.SearchInfo;
|
||||
import org.schabi.newpipe.extractor.services.peertube.linkHandler.PeertubeSearchQueryHandlerFactory;
|
||||
|
@ -144,6 +145,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
|||
|
||||
private final SparseArrayCompat<String> menuItemToFilterName = new SparseArrayCompat<>();
|
||||
private StreamingService service;
|
||||
@Nullable
|
||||
private Page nextPage;
|
||||
private boolean showLocalSuggestions = true;
|
||||
private boolean showRemoteSuggestions = true;
|
||||
|
@ -219,6 +221,15 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
|||
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
|
||||
searchBinding = FragmentSearchBinding.bind(rootView);
|
||||
super.onViewCreated(rootView, savedInstanceState);
|
||||
|
||||
updateService();
|
||||
// Add the service name to search string hint
|
||||
// to make it more obvious which platform is being searched.
|
||||
if (service != null) {
|
||||
searchEditText.setHint(
|
||||
getString(R.string.search_with_service_name,
|
||||
service.getServiceInfo().getName()));
|
||||
}
|
||||
showSearchOnStart();
|
||||
initSearchListeners();
|
||||
}
|
||||
|
@ -924,7 +935,21 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
|||
infoListAdapter.clearStreamItemList();
|
||||
showEmptyState();
|
||||
} else {
|
||||
showError(new ErrorInfo(exception, UserAction.SEARCHED, searchString, serviceId));
|
||||
showError(new ErrorInfo(exception, UserAction.SEARCHED, searchString, serviceId,
|
||||
getOpenInBrowserUrlForErrors()));
|
||||
}
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private String getOpenInBrowserUrlForErrors() {
|
||||
if (TextUtils.isEmpty(searchString)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return service.getSearchQHFactory().getUrl(searchString,
|
||||
Arrays.asList(contentFilter), sortFilter);
|
||||
} catch (final NullPointerException | ParsingException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -936,6 +961,20 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
|||
filterItemCheckedId = item.getItemId();
|
||||
item.setChecked(true);
|
||||
|
||||
if (service != null) {
|
||||
final boolean isNotFiltered = theContentFilter.isEmpty()
|
||||
|| "all".equals(theContentFilter.get(0));
|
||||
if (isNotFiltered) {
|
||||
searchEditText.setHint(
|
||||
getString(R.string.search_with_service_name,
|
||||
service.getServiceInfo().getName()));
|
||||
} else {
|
||||
searchEditText.setHint(getString(R.string.search_with_service_name_and_filter,
|
||||
service.getServiceInfo().getName(),
|
||||
item.getTitle()));
|
||||
}
|
||||
}
|
||||
|
||||
contentFilter = theContentFilter.toArray(new String[0]);
|
||||
|
||||
if (!TextUtils.isEmpty(searchString)) {
|
||||
|
@ -998,7 +1037,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
|||
&& !(exceptions.size() == 1
|
||||
&& exceptions.get(0) instanceof SearchExtractor.NothingFoundException)) {
|
||||
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
|
||||
searchString, serviceId));
|
||||
searchString, serviceId, getOpenInBrowserUrlForErrors()));
|
||||
}
|
||||
|
||||
searchSuggestion = result.getSearchSuggestion();
|
||||
|
@ -1065,15 +1104,26 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
|||
public void handleNextItems(final ListExtractor.InfoItemsPage<?> result) {
|
||||
showListFooter(false);
|
||||
infoListAdapter.addInfoItemList(result.getItems());
|
||||
nextPage = result.getNextPage();
|
||||
|
||||
if (!result.getErrors().isEmpty()) {
|
||||
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
|
||||
"\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", "
|
||||
+ "pageIds: " + nextPage.getIds() + ", "
|
||||
+ "pageCookies: " + nextPage.getCookies(),
|
||||
serviceId));
|
||||
// nextPage should be non-null at this point, because it refers to the page
|
||||
// whose results are handled here, but let's check it anyway
|
||||
if (nextPage == null) {
|
||||
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
|
||||
"\"" + searchString + "\" → nextPage == null", serviceId,
|
||||
getOpenInBrowserUrlForErrors()));
|
||||
} else {
|
||||
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
|
||||
"\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", "
|
||||
+ "pageIds: " + nextPage.getIds() + ", "
|
||||
+ "pageCookies: " + nextPage.getCookies(),
|
||||
serviceId, getOpenInBrowserUrlForErrors()));
|
||||
}
|
||||
}
|
||||
|
||||
// keep the reassignment of nextPage after the error handling to ensure that nextPage
|
||||
// still holds the correct value during the error handling
|
||||
nextPage = result.getNextPage();
|
||||
super.handleNextItems(result);
|
||||
}
|
||||
|
||||
|
|
|
@ -101,14 +101,16 @@ public class CommentInfoItemHolder extends InfoItemHolder {
|
|||
}
|
||||
itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item));
|
||||
|
||||
|
||||
// setup the top row, with pinned icon, author name and comment date
|
||||
itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
|
||||
itemTitleView.setText(Localization.concatenateStrings(item.getUploaderName(),
|
||||
Localization.relativeTimeOrTextual(itemBuilder.getContext(), item.getUploadDate(),
|
||||
final String uploaderName = Localization.localizeUserName(item.getUploaderName());
|
||||
itemTitleView.setText(Localization.concatenateStrings(
|
||||
uploaderName,
|
||||
Localization.relativeTimeOrTextual(
|
||||
itemBuilder.getContext(),
|
||||
item.getUploadDate(),
|
||||
item.getTextualUploadDate())));
|
||||
|
||||
|
||||
// setup bottom row, with likes, heart and replies button
|
||||
itemLikesCountView.setText(
|
||||
Localization.likeCount(itemBuilder.getContext(), item.getLikeCount()));
|
||||
|
|
|
@ -7,3 +7,16 @@ import androidx.core.os.BundleCompat
|
|||
inline fun <reified T : Parcelable> Bundle.parcelableArrayList(key: String?): ArrayList<T>? {
|
||||
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
|
||||
}
|
||||
|
||||
fun Bundle?.toDebugString(): String {
|
||||
if (this == null) {
|
||||
return "null"
|
||||
}
|
||||
val string = StringBuilder("Bundle{")
|
||||
for (key in this.keySet()) {
|
||||
@Suppress("DEPRECATION") // we want this[key] to return items of any type
|
||||
string.append(" ").append(key).append(" => ").append(this[key]).append(";")
|
||||
}
|
||||
string.append(" }")
|
||||
return string.toString()
|
||||
}
|
||||
|
|
|
@ -17,8 +17,10 @@ import androidx.core.view.isGone
|
|||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||
import org.schabi.newpipe.MainActivity
|
||||
|
||||
// logs in this class are disabled by default since it's usually not useful,
|
||||
// you can enable them by setting this flag to MainActivity.DEBUG
|
||||
private const val DEBUG = false
|
||||
private const val TAG = "ViewUtils"
|
||||
|
||||
/**
|
||||
|
@ -38,7 +40,7 @@ fun View.animate(
|
|||
delay: Long = 0,
|
||||
execOnEnd: Runnable? = null
|
||||
) {
|
||||
if (MainActivity.DEBUG) {
|
||||
if (DEBUG) {
|
||||
val id = try {
|
||||
resources.getResourceEntryName(id)
|
||||
} catch (e: Exception) {
|
||||
|
@ -51,7 +53,7 @@ fun View.animate(
|
|||
Log.d(TAG, "animate(): $msg")
|
||||
}
|
||||
if (isVisible && enterOrExit) {
|
||||
if (MainActivity.DEBUG) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "animate(): view was already visible > view = [$this]")
|
||||
}
|
||||
animate().setListener(null).cancel()
|
||||
|
@ -60,7 +62,7 @@ fun View.animate(
|
|||
execOnEnd?.run()
|
||||
return
|
||||
} else if ((isGone || isInvisible) && !enterOrExit) {
|
||||
if (MainActivity.DEBUG) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "animate(): view was already gone > view = [$this]")
|
||||
}
|
||||
animate().setListener(null).cancel()
|
||||
|
@ -89,7 +91,7 @@ fun View.animate(
|
|||
* @param colorEnd the background color to end with
|
||||
*/
|
||||
fun View.animateBackgroundColor(duration: Long, @ColorInt colorStart: Int, @ColorInt colorEnd: Int) {
|
||||
if (MainActivity.DEBUG) {
|
||||
if (DEBUG) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"animateBackgroundColor() called with: view = [$this], duration = [$duration], " +
|
||||
|
@ -109,7 +111,7 @@ fun View.animateBackgroundColor(duration: Long, @ColorInt colorStart: Int, @Colo
|
|||
}
|
||||
|
||||
fun View.animateHeight(duration: Long, targetHeight: Int): ValueAnimator {
|
||||
if (MainActivity.DEBUG) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "animateHeight: duration = [$duration], from $height to → $targetHeight in: $this")
|
||||
}
|
||||
val animator = ValueAnimator.ofFloat(height.toFloat(), targetHeight.toFloat())
|
||||
|
@ -127,7 +129,7 @@ fun View.animateHeight(duration: Long, targetHeight: Int): ValueAnimator {
|
|||
}
|
||||
|
||||
fun View.animateRotation(duration: Long, targetRotation: Int) {
|
||||
if (MainActivity.DEBUG) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "animateRotation: duration = [$duration], from $rotation to → $targetRotation in: $this")
|
||||
}
|
||||
animate().setListener(null).cancel()
|
||||
|
|
|
@ -194,9 +194,6 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
|
|||
if (itemsList != null) {
|
||||
animateHideRecyclerViewAllowingScrolling(itemsList);
|
||||
}
|
||||
if (headerRootBinding != null) {
|
||||
animate(headerRootBinding.getRoot(), false, 200);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -205,9 +202,6 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
|
|||
if (itemsList != null) {
|
||||
animate(itemsList, true, 200);
|
||||
}
|
||||
if (headerRootBinding != null) {
|
||||
animate(headerRootBinding.getRoot(), true, 200);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -253,9 +247,6 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
|
|||
if (itemsList != null) {
|
||||
animateHideRecyclerViewAllowingScrolling(itemsList);
|
||||
}
|
||||
if (headerRootBinding != null) {
|
||||
animate(headerRootBinding.getRoot(), false, 200);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -269,7 +269,12 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||
|
||||
override fun onDestroyOptionsMenu() {
|
||||
super.onDestroyOptionsMenu()
|
||||
activity?.supportActionBar?.subtitle = null
|
||||
if (
|
||||
(groupName != "") &&
|
||||
(activity?.supportActionBar?.subtitle == groupName)
|
||||
) {
|
||||
activity?.supportActionBar?.subtitle = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
@ -281,7 +286,13 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||
}
|
||||
|
||||
super.onDestroy()
|
||||
activity?.supportActionBar?.subtitle = null
|
||||
|
||||
if (
|
||||
(groupName != "") &&
|
||||
(activity?.supportActionBar?.subtitle == groupName)
|
||||
) {
|
||||
activity?.supportActionBar?.subtitle = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
|
|
|
@ -88,7 +88,7 @@ class NotificationHelper(val context: Context) {
|
|||
|
||||
// Show individual stream notifications, set channel icon only if there is actually
|
||||
// one
|
||||
showStreamNotifications(newStreams, data.serviceId, bitmap)
|
||||
showStreamNotifications(newStreams, data.serviceId, data.url, bitmap)
|
||||
// Show summary notification
|
||||
manager.notify(data.pseudoId, summaryBuilder.build())
|
||||
|
||||
|
@ -97,7 +97,7 @@ class NotificationHelper(val context: Context) {
|
|||
|
||||
override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) {
|
||||
// Show individual stream notifications
|
||||
showStreamNotifications(newStreams, data.serviceId, null)
|
||||
showStreamNotifications(newStreams, data.serviceId, data.url, null)
|
||||
// Show summary notification
|
||||
manager.notify(data.pseudoId, summaryBuilder.build())
|
||||
iconLoadingTargets.remove(this) // allow it to be garbage-collected
|
||||
|
@ -118,10 +118,11 @@ class NotificationHelper(val context: Context) {
|
|||
private fun showStreamNotifications(
|
||||
newStreams: List<StreamInfoItem>,
|
||||
serviceId: Int,
|
||||
channelUrl: String,
|
||||
channelIcon: Bitmap?
|
||||
) {
|
||||
for (stream in newStreams) {
|
||||
val notification = createStreamNotification(stream, serviceId, channelIcon)
|
||||
val notification = createStreamNotification(stream, serviceId, channelUrl, channelIcon)
|
||||
manager.notify(stream.url.hashCode(), notification)
|
||||
}
|
||||
}
|
||||
|
@ -129,6 +130,7 @@ class NotificationHelper(val context: Context) {
|
|||
private fun createStreamNotification(
|
||||
item: StreamInfoItem,
|
||||
serviceId: Int,
|
||||
channelUrl: String,
|
||||
channelIcon: Bitmap?
|
||||
): Notification {
|
||||
return NotificationCompat.Builder(
|
||||
|
@ -139,7 +141,7 @@ class NotificationHelper(val context: Context) {
|
|||
.setLargeIcon(channelIcon)
|
||||
.setContentTitle(item.name)
|
||||
.setContentText(item.uploaderName)
|
||||
.setGroup(item.uploaderUrl)
|
||||
.setGroup(channelUrl)
|
||||
.setColor(ContextCompat.getColor(context, R.color.ic_launcher_background))
|
||||
.setColorized(true)
|
||||
.setAutoCancel(true)
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
package org.schabi.newpipe.local.feed.notifications
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.Constraints
|
||||
|
@ -83,7 +85,9 @@ class NotificationWorker(
|
|||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setContentTitle(applicationContext.getString(R.string.feed_notification_loading))
|
||||
.build()
|
||||
setForegroundAsync(ForegroundInfo(FeedLoadService.NOTIFICATION_ID, notification))
|
||||
// ServiceInfo constants are not used below Android Q, so 0 is set here
|
||||
val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0
|
||||
setForegroundAsync(ForegroundInfo(FeedLoadService.NOTIFICATION_ID, notification, serviceType))
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
@ -31,6 +31,7 @@ import androidx.core.app.NotificationCompat
|
|||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
|
@ -200,7 +201,7 @@ class FeedLoadService : Service() {
|
|||
}
|
||||
}
|
||||
}
|
||||
registerReceiver(broadcastReceiver, IntentFilter(ACTION_CANCEL))
|
||||
ContextCompat.registerReceiver(this, broadcastReceiver, IntentFilter(ACTION_CANCEL), ContextCompat.RECEIVER_NOT_EXPORTED)
|
||||
}
|
||||
|
||||
// /////////////////////////////////////////////////////////////////////////
|
||||
|
|
|
@ -0,0 +1,72 @@
|
|||
package org.schabi.newpipe.local.playlist
|
||||
|
||||
import android.content.Context
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry
|
||||
import org.schabi.newpipe.extractor.exceptions.ParsingException
|
||||
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeStreamLinkHandlerFactory
|
||||
import org.schabi.newpipe.local.playlist.PlayListShareMode.JUST_URLS
|
||||
import org.schabi.newpipe.local.playlist.PlayListShareMode.WITH_TITLES
|
||||
import org.schabi.newpipe.local.playlist.PlayListShareMode.YOUTUBE_TEMP_PLAYLIST
|
||||
|
||||
fun export(
|
||||
shareMode: PlayListShareMode,
|
||||
playlist: List<PlaylistStreamEntry>,
|
||||
context: Context
|
||||
): String {
|
||||
return when (shareMode) {
|
||||
WITH_TITLES -> exportWithTitles(playlist, context)
|
||||
JUST_URLS -> exportJustUrls(playlist)
|
||||
YOUTUBE_TEMP_PLAYLIST -> exportAsYoutubeTempPlaylist(playlist)
|
||||
}
|
||||
}
|
||||
|
||||
fun exportWithTitles(
|
||||
playlist: List<PlaylistStreamEntry>,
|
||||
context: Context
|
||||
): String {
|
||||
|
||||
return playlist.asSequence()
|
||||
.map { it.streamEntity }
|
||||
.map { entity ->
|
||||
context.getString(
|
||||
R.string.video_details_list_item,
|
||||
entity.title,
|
||||
entity.url
|
||||
)
|
||||
}
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
fun exportJustUrls(playlist: List<PlaylistStreamEntry>): String {
|
||||
|
||||
return playlist.asSequence()
|
||||
.map { it.streamEntity.url }
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
fun exportAsYoutubeTempPlaylist(playlist: List<PlaylistStreamEntry>): String {
|
||||
|
||||
val videoIDs = playlist.asReversed().asSequence()
|
||||
.map { it.streamEntity.url }
|
||||
.mapNotNull(::getYouTubeId)
|
||||
.take(50) // YouTube limitation: temp playlists can't have more than 50 items
|
||||
.toList()
|
||||
.asReversed()
|
||||
.joinToString(separator = ",")
|
||||
|
||||
return "https://www.youtube.com/watch_videos?video_ids=$videoIDs"
|
||||
}
|
||||
|
||||
val linkHandler: YoutubeStreamLinkHandlerFactory = YoutubeStreamLinkHandlerFactory.getInstance()
|
||||
|
||||
/**
|
||||
* Gets the video id from a YouTube URL.
|
||||
*
|
||||
* @param url YouTube URL
|
||||
* @return the video id
|
||||
*/
|
||||
fun getYouTubeId(url: String): String? {
|
||||
|
||||
return try { linkHandler.getId(url) } catch (e: ParsingException) { null }
|
||||
}
|
|
@ -2,8 +2,13 @@ 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.local.playlist.ExportPlaylistKt.export;
|
||||
import static org.schabi.newpipe.local.playlist.PlayListShareMode.JUST_URLS;
|
||||
import static org.schabi.newpipe.local.playlist.PlayListShareMode.WITH_TITLES;
|
||||
import static org.schabi.newpipe.local.playlist.PlayListShareMode.YOUTUBE_TEMP_PLAYLIST;
|
||||
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.os.Parcelable;
|
||||
|
@ -27,7 +32,6 @@ import androidx.recyclerview.widget.RecyclerView;
|
|||
import androidx.viewbinding.ViewBinding;
|
||||
|
||||
import com.evernote.android.state.State;
|
||||
|
||||
import org.reactivestreams.Subscriber;
|
||||
import org.reactivestreams.Subscription;
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
|
@ -385,34 +389,41 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||
}
|
||||
|
||||
/**
|
||||
* Shares the playlist as a list of stream URLs if {@code shouldSharePlaylistDetails} is
|
||||
* set to {@code false}. Shares the playlist name along with a list of video titles and URLs
|
||||
* if {@code shouldSharePlaylistDetails} is set to {@code true}.
|
||||
* Shares the playlist in one of 3 ways, depending on the value of {@code shareMode}:
|
||||
* <ul>
|
||||
* <li>{@code JUST_URLS}: shares the URLs only.</li>
|
||||
* <li>{@code WITH_TITLES}: each entry in the list is accompanied by its title.</li>
|
||||
* <li>{@code YOUTUBE_TEMP_PLAYLIST}: shares as a YouTube temporary playlist.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param shouldSharePlaylistDetails Whether the playlist details should be included in the
|
||||
* shared content.
|
||||
* @param shareMode The way the playlist should be shared.
|
||||
*/
|
||||
private void sharePlaylist(final boolean shouldSharePlaylistDetails) {
|
||||
private void sharePlaylist(final PlayListShareMode shareMode) {
|
||||
final Context context = requireContext();
|
||||
|
||||
disposables.add(playlistManager.getPlaylistStreams(playlistId)
|
||||
.flatMapSingle(playlist -> Single.just(playlist.stream()
|
||||
.map(PlaylistStreamEntry::getStreamEntity)
|
||||
.map(streamEntity -> {
|
||||
if (shouldSharePlaylistDetails) {
|
||||
return context.getString(R.string.video_details_list_item,
|
||||
streamEntity.getTitle(), streamEntity.getUrl());
|
||||
} else {
|
||||
return streamEntity.getUrl();
|
||||
}
|
||||
})
|
||||
.collect(Collectors.joining("\n"))))
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(urlsText -> ShareUtils.shareText(
|
||||
context, name, shouldSharePlaylistDetails
|
||||
? context.getString(R.string.share_playlist_content_details,
|
||||
name, urlsText) : urlsText),
|
||||
throwable -> showUiErrorSnackbar(this, "Sharing playlist", throwable)));
|
||||
.flatMapSingle(playlist -> Single.just(export(
|
||||
|
||||
shareMode,
|
||||
playlist,
|
||||
context
|
||||
)))
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
urlsText -> {
|
||||
|
||||
final String content = shareMode == WITH_TITLES
|
||||
? context.getString(R.string.share_playlist_content_details,
|
||||
name,
|
||||
urlsText
|
||||
)
|
||||
: urlsText;
|
||||
|
||||
ShareUtils.shareText(context, name, content);
|
||||
},
|
||||
throwable -> showUiErrorSnackbar(this, "Sharing playlist", throwable)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public void removeWatchedStreams(final boolean removePartiallyWatched) {
|
||||
|
@ -872,13 +883,15 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||
private void createShareConfirmationDialog() {
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.share_playlist)
|
||||
.setMessage(R.string.share_playlist_with_titles_message)
|
||||
.setCancelable(true)
|
||||
.setPositiveButton(R.string.share_playlist_with_titles, (dialog, which) ->
|
||||
sharePlaylist(/* shouldSharePlaylistDetails= */ true)
|
||||
sharePlaylist(WITH_TITLES)
|
||||
)
|
||||
.setNeutralButton(R.string.share_playlist_as_youtube_temporary_playlist,
|
||||
(dialog, which) -> sharePlaylist(YOUTUBE_TEMP_PLAYLIST)
|
||||
)
|
||||
.setNegativeButton(R.string.share_playlist_with_list, (dialog, which) ->
|
||||
sharePlaylist(/* shouldSharePlaylistDetails= */ false)
|
||||
sharePlaylist(JUST_URLS)
|
||||
)
|
||||
.show();
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
package org.schabi.newpipe.local.playlist;
|
||||
|
||||
public enum PlayListShareMode {
|
||||
|
||||
JUST_URLS,
|
||||
WITH_TITLES,
|
||||
YOUTUBE_TEMP_PLAYLIST
|
||||
}
|
|
@ -26,6 +26,10 @@ public class RemotePlaylistManager {
|
|||
return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Flowable<PlaylistRemoteEntity> getPlaylist(final long playlistId) {
|
||||
return playlistRemoteTable.getPlaylist(playlistId).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Flowable<List<PlaylistRemoteEntity>> getPlaylist(final PlaylistInfo info) {
|
||||
return playlistRemoteTable.getPlaylist(info.getServiceId(), info.getUrl())
|
||||
.subscribeOn(Schedulers.io());
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
package org.schabi.newpipe.local.subscription;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
|
@ -35,7 +33,6 @@ public class ImportConfirmationDialog extends DialogFragment {
|
|||
@NonNull
|
||||
@Override
|
||||
public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
|
||||
assureCorrectAppLanguage(getContext());
|
||||
return new AlertDialog.Builder(requireContext())
|
||||
.setMessage(R.string.import_network_expensive_warning)
|
||||
.setCancelable(true)
|
||||
|
|
|
@ -13,6 +13,7 @@ import android.view.MenuItem
|
|||
import android.view.SubMenu
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
||||
|
@ -460,6 +461,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
|||
}
|
||||
|
||||
companion object {
|
||||
const val JSON_MIME_TYPE = "application/json"
|
||||
val JSON_MIME_TYPE = MimeTypeMap.getSingleton()
|
||||
.getMimeTypeFromExtension("json") ?: "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -89,8 +89,8 @@ public class SubscriptionsImportFragment extends BaseFragment {
|
|||
if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) {
|
||||
ErrorUtil.showSnackbar(activity,
|
||||
new ErrorInfo(new String[]{}, UserAction.SUBSCRIPTION_IMPORT_EXPORT,
|
||||
ServiceHelper.getNameOfServiceById(currentServiceId),
|
||||
"Service does not support importing subscriptions",
|
||||
currentServiceId,
|
||||
R.string.general_error));
|
||||
activity.finish();
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@ package org.schabi.newpipe.player;
|
|||
|
||||
import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.content.Intent;
|
||||
|
@ -84,7 +83,6 @@ public final class PlayQueueActivity extends AppCompatActivity
|
|||
|
||||
@Override
|
||||
protected void onCreate(final Bundle savedInstanceState) {
|
||||
assureCorrectAppLanguage(this);
|
||||
super.onCreate(savedInstanceState);
|
||||
ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this));
|
||||
|
||||
|
@ -183,7 +181,10 @@ public final class PlayQueueActivity extends AppCompatActivity
|
|||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private void bind() {
|
||||
// Note: this code should not really exist, and PlayerHolder should be used instead, but
|
||||
// it will be rewritten when NewPlayer will replace the current player.
|
||||
final Intent bindIntent = new Intent(this, PlayerService.class);
|
||||
bindIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION);
|
||||
final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE);
|
||||
if (!success) {
|
||||
unbindService(serviceConnection);
|
||||
|
@ -221,7 +222,7 @@ public final class PlayQueueActivity extends AppCompatActivity
|
|||
Log.d(TAG, "Player service is connected");
|
||||
|
||||
if (service instanceof PlayerService.LocalBinder) {
|
||||
player = ((PlayerService.LocalBinder) service).getPlayer();
|
||||
player = ((PlayerService.LocalBinder) service).getService().getPlayer();
|
||||
}
|
||||
|
||||
if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) {
|
||||
|
|
|
@ -24,12 +24,12 @@ import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SEEK_ADJ
|
|||
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SKIP;
|
||||
import static com.google.android.exoplayer2.Player.DiscontinuityReason;
|
||||
import static com.google.android.exoplayer2.Player.Listener;
|
||||
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
|
||||
import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF;
|
||||
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
|
||||
import static com.google.android.exoplayer2.Player.RepeatMode;
|
||||
import static org.schabi.newpipe.extractor.ServiceList.YouTube;
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.nextRepeatMode;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.retrievePlaybackParametersFromPrefs;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.savePlaybackParametersToPrefs;
|
||||
|
@ -44,7 +44,6 @@ import static org.schabi.newpipe.player.notification.NotificationConstants.ACTIO
|
|||
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE;
|
||||
import static org.schabi.newpipe.util.ListHelper.getPopupResolutionIndex;
|
||||
import static org.schabi.newpipe.util.ListHelper.getResolutionIndex;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
import static java.util.concurrent.TimeUnit.MILLISECONDS;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
|
@ -55,11 +54,14 @@ import android.content.SharedPreferences;
|
|||
import android.graphics.Bitmap;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.media.AudioManager;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
import androidx.core.content.IntentCompat;
|
||||
import androidx.core.math.MathUtils;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
|
@ -71,6 +73,7 @@ import com.google.android.exoplayer2.PlaybackParameters;
|
|||
import com.google.android.exoplayer2.Player.PositionInfo;
|
||||
import com.google.android.exoplayer2.Timeline;
|
||||
import com.google.android.exoplayer2.Tracks;
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||
import com.google.android.exoplayer2.source.MediaSource;
|
||||
import com.google.android.exoplayer2.text.CueGroup;
|
||||
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
|
||||
|
@ -86,8 +89,8 @@ import org.schabi.newpipe.databinding.PlayerBinding;
|
|||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.stream.AudioStream;
|
||||
import org.schabi.newpipe.extractor.Image;
|
||||
import org.schabi.newpipe.extractor.stream.AudioStream;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.extractor.stream.VideoStream;
|
||||
|
@ -107,6 +110,7 @@ import org.schabi.newpipe.player.playback.MediaSourceManager;
|
|||
import org.schabi.newpipe.player.playback.PlaybackListener;
|
||||
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.resolver.AudioPlaybackResolver;
|
||||
import org.schabi.newpipe.player.resolver.VideoPlaybackResolver;
|
||||
import org.schabi.newpipe.player.resolver.VideoPlaybackResolver.SourceType;
|
||||
|
@ -116,21 +120,25 @@ import org.schabi.newpipe.player.ui.PlayerUiList;
|
|||
import org.schabi.newpipe.player.ui.PopupPlayerUi;
|
||||
import org.schabi.newpipe.player.ui.VideoPlayerUi;
|
||||
import org.schabi.newpipe.util.DependentPreferenceHelper;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.ListHelper;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.SerializedCache;
|
||||
import org.schabi.newpipe.util.StreamTypeUtil;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Observable;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.disposables.SerialDisposable;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public final class Player implements PlaybackListener, Listener {
|
||||
public static final boolean DEBUG = MainActivity.DEBUG;
|
||||
|
@ -152,15 +160,13 @@ public final class Player implements PlaybackListener, Listener {
|
|||
// Intent
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
public static final String REPEAT_MODE = "repeat_mode";
|
||||
public static final String PLAYBACK_QUALITY = "playback_quality";
|
||||
public static final String PLAY_QUEUE_KEY = "play_queue_key";
|
||||
public static final String ENQUEUE = "enqueue";
|
||||
public static final String ENQUEUE_NEXT = "enqueue_next";
|
||||
public static final String RESUME_PLAYBACK = "resume_playback";
|
||||
public static final String PLAY_WHEN_READY = "play_when_ready";
|
||||
public static final String PLAYER_TYPE = "player_type";
|
||||
public static final String IS_MUTED = "is_muted";
|
||||
public static final String PLAYER_INTENT_TYPE = "player_intent_type";
|
||||
public static final String PLAYER_INTENT_DATA = "player_intent_data";
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Time constants
|
||||
|
@ -245,6 +251,8 @@ public final class Player implements PlaybackListener, Listener {
|
|||
private final SerialDisposable progressUpdateDisposable = new SerialDisposable();
|
||||
@NonNull
|
||||
private final CompositeDisposable databaseUpdateDisposable = new CompositeDisposable();
|
||||
@NonNull
|
||||
private final CompositeDisposable streamItemDisposable = new CompositeDisposable();
|
||||
|
||||
// This is the only listener we need for thumbnail loading, since there is always at most only
|
||||
// one thumbnail being loaded at a time. This field is also here to maintain a strong reference,
|
||||
|
@ -269,7 +277,16 @@ public final class Player implements PlaybackListener, Listener {
|
|||
//////////////////////////////////////////////////////////////////////////*/
|
||||
//region Constructor
|
||||
|
||||
public Player(@NonNull final PlayerService service) {
|
||||
/**
|
||||
* @param service the service this player resides in
|
||||
* @param mediaSession used to build the {@link MediaSessionPlayerUi}, lives in the service and
|
||||
* could possibly be reused with multiple player instances
|
||||
* @param sessionConnector used to build the {@link MediaSessionPlayerUi}, lives in the service
|
||||
* and could possibly be reused with multiple player instances
|
||||
*/
|
||||
public Player(@NonNull final PlayerService service,
|
||||
@NonNull final MediaSessionCompat mediaSession,
|
||||
@NonNull final MediaSessionConnector sessionConnector) {
|
||||
this.service = service;
|
||||
context = service;
|
||||
prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
|
@ -302,7 +319,7 @@ public final class Player implements PlaybackListener, Listener {
|
|||
// notification ui in the UIs list, since the notification depends on the media session in
|
||||
// PlayerUi#initPlayer(), and UIs.call() guarantees UI order is preserved.
|
||||
UIs = new PlayerUiList(
|
||||
new MediaSessionPlayerUi(this),
|
||||
new MediaSessionPlayerUi(this, mediaSession, sessionConnector),
|
||||
new NotificationPlayerUi(this)
|
||||
);
|
||||
}
|
||||
|
@ -336,49 +353,121 @@ public final class Player implements PlaybackListener, Listener {
|
|||
|
||||
@SuppressWarnings("MethodLength")
|
||||
public void handleIntent(@NonNull final Intent intent) {
|
||||
// fail fast if no play queue was provided
|
||||
final String queueCache = intent.getStringExtra(PLAY_QUEUE_KEY);
|
||||
if (queueCache == null) {
|
||||
final var playerIntentType = IntentCompat.getSerializableExtra(intent, PLAYER_INTENT_TYPE,
|
||||
PlayerIntentType.class);
|
||||
if (playerIntentType == null) {
|
||||
return;
|
||||
}
|
||||
final PlayQueue newQueue = SerializedCache.getInstance().take(queueCache, PlayQueue.class);
|
||||
if (newQueue == null) {
|
||||
return;
|
||||
// TODO: this should be in the second switch below, but I’m not sure whether I
|
||||
// can move the initUIs stuff without breaking the setup for edge cases somehow.
|
||||
// when playing from a timestamp, keep the current player as-is.
|
||||
if (playerIntentType != PlayerIntentType.TimestampChange) {
|
||||
playerType = IntentCompat.getSerializableExtra(intent, PLAYER_TYPE, PlayerType.class);
|
||||
}
|
||||
|
||||
final PlayerType oldPlayerType = playerType;
|
||||
playerType = PlayerType.retrieveFromIntent(intent);
|
||||
initUIsForCurrentPlayerType();
|
||||
// We need to setup audioOnly before super(), see "sourceOf"
|
||||
isAudioOnly = audioPlayerSelected();
|
||||
|
||||
if (intent.hasExtra(PLAYBACK_QUALITY)) {
|
||||
videoResolver.setPlaybackQuality(intent.getStringExtra(PLAYBACK_QUALITY));
|
||||
}
|
||||
|
||||
// Resolve enqueue intents
|
||||
if (intent.getBooleanExtra(ENQUEUE, false) && playQueue != null) {
|
||||
playQueue.append(newQueue.getStreams());
|
||||
return;
|
||||
final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true);
|
||||
|
||||
// Resolve enqueue next intents
|
||||
} else if (intent.getBooleanExtra(ENQUEUE_NEXT, false) && playQueue != null) {
|
||||
final int currentIndex = playQueue.getIndex();
|
||||
playQueue.append(newQueue.getStreams());
|
||||
playQueue.move(playQueue.size() - 1, currentIndex + 1);
|
||||
switch (playerIntentType) {
|
||||
case Enqueue -> {
|
||||
if (playQueue != null) {
|
||||
final PlayQueue newQueue = getPlayQueueFromCache(intent);
|
||||
if (newQueue == null) {
|
||||
return;
|
||||
}
|
||||
playQueue.append(newQueue.getStreams());
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: This falls through to the old logic, there was no playQueue
|
||||
// yet so we should start the player and add the new video
|
||||
break;
|
||||
}
|
||||
case EnqueueNext -> {
|
||||
if (playQueue != null) {
|
||||
final PlayQueue newQueue = getPlayQueueFromCache(intent);
|
||||
if (newQueue == null) {
|
||||
return;
|
||||
}
|
||||
final PlayQueueItem newItem = newQueue.getStreams().get(0);
|
||||
newQueue.enqueueNext(newItem, false);
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: This falls through to the old logic, there was no playQueue
|
||||
// yet so we should start the player and add the new video
|
||||
break;
|
||||
}
|
||||
case TimestampChange -> {
|
||||
final var data = Objects.requireNonNull(IntentCompat.getParcelableExtra(intent,
|
||||
PLAYER_INTENT_DATA, TimestampChangeData.class));
|
||||
final Single<StreamInfo> single =
|
||||
ExtractorHelper.getStreamInfo(data.getServiceId(), data.getUrl(), false);
|
||||
streamItemDisposable.add(single.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(info -> {
|
||||
final @Nullable PlayQueue oldPlayQueue = playQueue;
|
||||
info.setStartPosition(data.getSeconds());
|
||||
final PlayQueueItem playQueueItem = new PlayQueueItem(info);
|
||||
|
||||
// If the stream is already playing,
|
||||
// we can just seek to the appropriate timestamp
|
||||
if (oldPlayQueue != null
|
||||
&& playQueueItem.isSameItem(oldPlayQueue.getItem())) {
|
||||
// Player can have state = IDLE when playback is stopped or failed
|
||||
// and we should retry in this case
|
||||
if (simpleExoPlayer.getPlaybackState()
|
||||
== com.google.android.exoplayer2.Player.STATE_IDLE) {
|
||||
simpleExoPlayer.prepare();
|
||||
}
|
||||
simpleExoPlayer.seekTo(oldPlayQueue.getIndex(),
|
||||
data.getSeconds() * 1000L);
|
||||
simpleExoPlayer.setPlayWhenReady(playWhenReady);
|
||||
|
||||
} else {
|
||||
final PlayQueue newPlayQueue;
|
||||
|
||||
// If there is no queue yet, just add our item
|
||||
if (oldPlayQueue == null) {
|
||||
newPlayQueue = new SinglePlayQueue(playQueueItem);
|
||||
|
||||
// else we add the timestamped stream behind the current video
|
||||
// and start playing it.
|
||||
} else {
|
||||
oldPlayQueue.enqueueNext(playQueueItem, true);
|
||||
oldPlayQueue.offsetIndex(1);
|
||||
newPlayQueue = oldPlayQueue;
|
||||
}
|
||||
initPlayback(newPlayQueue, playWhenReady);
|
||||
}
|
||||
|
||||
}, throwable -> {
|
||||
// This will only show a snackbar if the passed context has a root view:
|
||||
// otherwise it will resort to showing a notification, so we are safe
|
||||
// here.
|
||||
final var info = new ErrorInfo(throwable, UserAction.PLAY_ON_POPUP,
|
||||
data.getUrl(), null, data.getUrl());
|
||||
ErrorUtil.createNotification(context, info);
|
||||
}));
|
||||
return;
|
||||
}
|
||||
case AllOthers -> {
|
||||
// fallthrough; TODO: put other intent data in separate cases
|
||||
}
|
||||
}
|
||||
|
||||
final PlayQueue newQueue = getPlayQueueFromCache(intent);
|
||||
if (newQueue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final PlaybackParameters savedParameters = retrievePlaybackParametersFromPrefs(this);
|
||||
final float playbackSpeed = savedParameters.speed;
|
||||
final float playbackPitch = savedParameters.pitch;
|
||||
final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString(
|
||||
R.string.playback_skip_silence_key), getPlaybackSkipSilence());
|
||||
|
||||
// branching parameters for below
|
||||
final boolean samePlayQueue = playQueue != null && playQueue.equalStreamsAndIndex(newQueue);
|
||||
final int repeatMode = intent.getIntExtra(REPEAT_MODE, getRepeatMode());
|
||||
final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true);
|
||||
final boolean isMuted = intent.getBooleanExtra(IS_MUTED, isMuted());
|
||||
|
||||
/*
|
||||
* TODO As seen in #7427 this does not work:
|
||||
|
@ -393,7 +482,7 @@ public final class Player implements PlaybackListener, Listener {
|
|||
if (!exoPlayerIsNull()
|
||||
&& newQueue.size() == 1 && newQueue.getItem() != null
|
||||
&& playQueue != null && playQueue.size() == 1 && playQueue.getItem() != null
|
||||
&& newQueue.getItem().getUrl().equals(playQueue.getItem().getUrl())
|
||||
&& newQueue.getItem().isSameItem(playQueue.getItem())
|
||||
&& newQueue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) {
|
||||
// Player can have state = IDLE when playback is stopped or failed
|
||||
// and we should retry in this case
|
||||
|
@ -419,7 +508,8 @@ public final class Player implements PlaybackListener, Listener {
|
|||
|
||||
} else if (intent.getBooleanExtra(RESUME_PLAYBACK, false)
|
||||
&& DependentPreferenceHelper.getResumePlaybackEnabled(context)
|
||||
&& !samePlayQueue
|
||||
// !samePlayQueue
|
||||
&& (playQueue == null || !playQueue.equalStreamsAndIndex(newQueue))
|
||||
&& !newQueue.isEmpty()
|
||||
&& newQueue.getItem() != null
|
||||
&& newQueue.getItem().getRecoveryPosition() == PlayQueueItem.RECOVERY_UNSET) {
|
||||
|
@ -435,30 +525,30 @@ public final class Player implements PlaybackListener, Listener {
|
|||
newQueue.setRecovery(newQueue.getIndex(),
|
||||
state.getProgressMillis());
|
||||
}
|
||||
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
|
||||
playbackSkipSilence, playWhenReady, isMuted);
|
||||
initPlayback(newQueue, playWhenReady);
|
||||
},
|
||||
error -> {
|
||||
if (DEBUG) {
|
||||
Log.w(TAG, "Failed to start playback", error);
|
||||
}
|
||||
// In case any error we can start playback without history
|
||||
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
|
||||
playbackSkipSilence, playWhenReady, isMuted);
|
||||
initPlayback(newQueue, playWhenReady);
|
||||
},
|
||||
() -> {
|
||||
// Completed but not found in history
|
||||
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
|
||||
playbackSkipSilence, playWhenReady, isMuted);
|
||||
initPlayback(newQueue, playWhenReady);
|
||||
}
|
||||
));
|
||||
} else {
|
||||
// Good to go...
|
||||
// In a case of equal PlayQueues we can re-init old one but only when it is disposed
|
||||
initPlayback(samePlayQueue ? playQueue : newQueue, repeatMode, playbackSpeed,
|
||||
playbackPitch, playbackSkipSilence, playWhenReady, isMuted);
|
||||
initPlayback(samePlayQueue ? playQueue : newQueue, playWhenReady);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
public void handleIntentPost(final PlayerType oldPlayerType) {
|
||||
if (oldPlayerType != playerType && playQueue != null) {
|
||||
// If playerType changes from one to another we should reload the player
|
||||
// (to disable/enable video stream or to set quality)
|
||||
|
@ -469,6 +559,19 @@ public final class Player implements PlaybackListener, Listener {
|
|||
NavigationHelper.sendPlayerStartedEvent(context);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private static PlayQueue getPlayQueueFromCache(@NonNull final Intent intent) {
|
||||
final String queueCache = intent.getStringExtra(PLAY_QUEUE_KEY);
|
||||
if (queueCache == null) {
|
||||
return null;
|
||||
}
|
||||
final PlayQueue newQueue = SerializedCache.getInstance().take(queueCache, PlayQueue.class);
|
||||
if (newQueue == null) {
|
||||
return null;
|
||||
}
|
||||
return newQueue;
|
||||
}
|
||||
|
||||
private void initUIsForCurrentPlayerType() {
|
||||
if ((UIs.get(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN)
|
||||
|| (UIs.get(PopupPlayerUi.class).isPresent() && playerType == PlayerType.POPUP)) {
|
||||
|
@ -502,16 +605,13 @@ public final class Player implements PlaybackListener, Listener {
|
|||
}
|
||||
|
||||
private void initPlayback(@NonNull final PlayQueue queue,
|
||||
@RepeatMode final int repeatMode,
|
||||
final float playbackSpeed,
|
||||
final float playbackPitch,
|
||||
final boolean playbackSkipSilence,
|
||||
final boolean playOnReady,
|
||||
final boolean isMuted) {
|
||||
final boolean playOnReady) {
|
||||
destroyPlayer();
|
||||
initPlayer(playOnReady);
|
||||
setRepeatMode(repeatMode);
|
||||
setPlaybackParameters(playbackSpeed, playbackPitch, playbackSkipSilence);
|
||||
final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString(
|
||||
R.string.playback_skip_silence_key), getPlaybackSkipSilence());
|
||||
final PlaybackParameters savedParameters = retrievePlaybackParametersFromPrefs(this);
|
||||
setPlaybackParameters(savedParameters.speed, savedParameters.pitch, playbackSkipSilence);
|
||||
|
||||
playQueue = queue;
|
||||
playQueue.init();
|
||||
|
@ -519,7 +619,7 @@ public final class Player implements PlaybackListener, Listener {
|
|||
|
||||
UIs.call(PlayerUi::initPlayback);
|
||||
|
||||
simpleExoPlayer.setVolume(isMuted ? 0 : 1);
|
||||
simpleExoPlayer.setVolume(isMuted() ? 0 : 1);
|
||||
notifyQueueUpdateToListeners();
|
||||
}
|
||||
|
||||
|
@ -601,6 +701,7 @@ public final class Player implements PlaybackListener, Listener {
|
|||
|
||||
databaseUpdateDisposable.clear();
|
||||
progressUpdateDisposable.set(null);
|
||||
streamItemDisposable.clear();
|
||||
cancelLoadingCurrentThumbnail();
|
||||
|
||||
UIs.destroyAll(Object.class); // destroy every UI: obviously every UI extends Object
|
||||
|
@ -646,7 +747,7 @@ public final class Player implements PlaybackListener, Listener {
|
|||
Log.d(TAG, "onPlaybackShutdown() called");
|
||||
}
|
||||
// destroys the service, which in turn will destroy the player
|
||||
service.stopService();
|
||||
service.destroyPlayerAndStopService();
|
||||
}
|
||||
|
||||
public void smoothStopForImmediateReusing() {
|
||||
|
@ -718,7 +819,7 @@ public final class Player implements PlaybackListener, Listener {
|
|||
pause();
|
||||
break;
|
||||
case ACTION_CLOSE:
|
||||
service.stopService();
|
||||
service.destroyPlayerAndStopService();
|
||||
break;
|
||||
case ACTION_PLAY_PAUSE:
|
||||
playPause();
|
||||
|
@ -742,7 +843,6 @@ public final class Player implements PlaybackListener, Listener {
|
|||
toggleShuffleModeEnabled();
|
||||
break;
|
||||
case Intent.ACTION_CONFIGURATION_CHANGED:
|
||||
assureCorrectAppLanguage(service);
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "ACTION_CONFIGURATION_CHANGED received");
|
||||
}
|
||||
|
@ -755,7 +855,8 @@ public final class Player implements PlaybackListener, Listener {
|
|||
private void registerBroadcastReceiver() {
|
||||
// Try to unregister current first
|
||||
unregisterBroadcastReceiver();
|
||||
context.registerReceiver(broadcastReceiver, intentFilter);
|
||||
ContextCompat.registerReceiver(context, broadcastReceiver, intentFilter,
|
||||
ContextCompat.RECEIVER_EXPORTED);
|
||||
}
|
||||
|
||||
private void unregisterBroadcastReceiver() {
|
||||
|
@ -1167,16 +1268,25 @@ public final class Player implements PlaybackListener, Listener {
|
|||
return exoPlayerIsNull() ? REPEAT_MODE_OFF : simpleExoPlayer.getRepeatMode();
|
||||
}
|
||||
|
||||
public void setRepeatMode(@RepeatMode final int repeatMode) {
|
||||
public void cycleNextRepeatMode() {
|
||||
if (!exoPlayerIsNull()) {
|
||||
@RepeatMode final int repeatMode;
|
||||
switch (simpleExoPlayer.getRepeatMode()) {
|
||||
case REPEAT_MODE_OFF:
|
||||
repeatMode = REPEAT_MODE_ONE;
|
||||
break;
|
||||
case REPEAT_MODE_ONE:
|
||||
repeatMode = REPEAT_MODE_ALL;
|
||||
break;
|
||||
case REPEAT_MODE_ALL:
|
||||
default:
|
||||
repeatMode = REPEAT_MODE_OFF;
|
||||
break;
|
||||
}
|
||||
simpleExoPlayer.setRepeatMode(repeatMode);
|
||||
}
|
||||
}
|
||||
|
||||
public void cycleNextRepeatMode() {
|
||||
setRepeatMode(nextRepeatMode(getRepeatMode()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRepeatModeChanged(@RepeatMode final int repeatMode) {
|
||||
if (DEBUG) {
|
||||
|
@ -1279,7 +1389,8 @@ public final class Player implements PlaybackListener, Listener {
|
|||
UserAction.PLAY_STREAM,
|
||||
"Loading failed for [" + currentMetadata.getTitle()
|
||||
+ "]: " + currentMetadata.getStreamUrl(),
|
||||
currentMetadata.getServiceId());
|
||||
currentMetadata.getServiceId(),
|
||||
currentMetadata.getStreamUrl());
|
||||
ErrorUtil.createNotification(context, errorInfo);
|
||||
}
|
||||
|
||||
|
@ -1375,6 +1486,19 @@ public final class Player implements PlaybackListener, Listener {
|
|||
public void onCues(@NonNull final CueGroup cueGroup) {
|
||||
UIs.call(playerUi -> playerUi.onCues(cueGroup.cues));
|
||||
}
|
||||
|
||||
/**
|
||||
* To be called when the {@code PlaybackPreparer} set in the {@link MediaSessionConnector}
|
||||
* receives an {@code onPrepare()} call. This function allows restoring the default behavior
|
||||
* that would happen if there was no playback preparer set, i.e. to just call
|
||||
* {@code player.prepare()}. You can find the default behavior in `onPlay()` inside the
|
||||
* {@link MediaSessionConnector} file.
|
||||
*/
|
||||
public void onPrepare() {
|
||||
if (!exoPlayerIsNull()) {
|
||||
simpleExoPlayer.prepare();
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
|
||||
|
@ -1482,7 +1606,7 @@ public final class Player implements PlaybackListener, Listener {
|
|||
errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM,
|
||||
"Player error[type=" + error.getErrorCodeName()
|
||||
+ "] occurred while playing " + currentMetadata.getStreamUrl(),
|
||||
currentMetadata.getServiceId());
|
||||
currentMetadata.getServiceId(), currentMetadata.getStreamUrl());
|
||||
}
|
||||
ErrorUtil.createNotification(context, errorInfo);
|
||||
}
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
package org.schabi.newpipe.player
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
// We model this as an enum class plus one struct for each enum value
|
||||
// so we can consume it from Java properly. After converting to Kotlin,
|
||||
// we could switch to a sealed enum class & a proper Kotlin `when` match.
|
||||
enum class PlayerIntentType {
|
||||
Enqueue,
|
||||
EnqueueNext,
|
||||
TimestampChange,
|
||||
AllOthers
|
||||
}
|
||||
|
||||
/**
|
||||
* A timestamp on the given was clicked and we should switch the playing stream to it.
|
||||
*/
|
||||
@Parcelize
|
||||
data class TimestampChangeData(
|
||||
val serviceId: Int,
|
||||
val url: String,
|
||||
val seconds: Int
|
||||
) : Parcelable
|
|
@ -19,96 +19,162 @@
|
|||
|
||||
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.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.support.v4.media.MediaBrowserCompat;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.ServiceCompat;
|
||||
import androidx.media.MediaBrowserServiceCompat;
|
||||
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||
|
||||
import org.schabi.newpipe.ktx.BundleKt;
|
||||
import org.schabi.newpipe.player.mediabrowser.MediaBrowserImpl;
|
||||
import org.schabi.newpipe.player.mediabrowser.MediaBrowserPlaybackPreparer;
|
||||
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
|
||||
import org.schabi.newpipe.player.notification.NotificationPlayerUi;
|
||||
import org.schabi.newpipe.player.notification.NotificationUtil;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
|
||||
/**
|
||||
* One service for all players.
|
||||
*/
|
||||
public final class PlayerService extends Service {
|
||||
public final class PlayerService extends MediaBrowserServiceCompat {
|
||||
private static final String TAG = PlayerService.class.getSimpleName();
|
||||
private static final boolean DEBUG = Player.DEBUG;
|
||||
|
||||
public static final String SHOULD_START_FOREGROUND_EXTRA = "should_start_foreground_extra";
|
||||
public static final String BIND_PLAYER_HOLDER_ACTION = "bind_player_holder_action";
|
||||
|
||||
// These objects are used to cleanly separate the Service implementation (in this file) and the
|
||||
// media browser and playback preparer implementations. At the moment the playback preparer is
|
||||
// only used in conjunction with the media browser.
|
||||
private MediaBrowserImpl mediaBrowserImpl;
|
||||
private MediaBrowserPlaybackPreparer mediaBrowserPlaybackPreparer;
|
||||
|
||||
// these are instantiated in onCreate() as per
|
||||
// https://developer.android.com/training/cars/media#browser_workflow
|
||||
private MediaSessionCompat mediaSession;
|
||||
private MediaSessionConnector sessionConnector;
|
||||
|
||||
@Nullable
|
||||
private Player player;
|
||||
|
||||
private final IBinder mBinder = new PlayerService.LocalBinder(this);
|
||||
|
||||
/**
|
||||
* The parameter taken by this {@link Consumer} can be null to indicate the player is being
|
||||
* stopped.
|
||||
*/
|
||||
@Nullable
|
||||
private Consumer<Player> onPlayerStartedOrStopped = null;
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Service's LifeCycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
//region Service lifecycle
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreate() called");
|
||||
}
|
||||
assureCorrectAppLanguage(this);
|
||||
ThemeHelper.setTheme(this);
|
||||
|
||||
player = new Player(this);
|
||||
/*
|
||||
Create the player notification and start immediately the service in foreground,
|
||||
otherwise if nothing is played or initializing the player and its components (especially
|
||||
loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the
|
||||
service would never be put in the foreground while we said to the system we would do so
|
||||
*/
|
||||
player.UIs().get(NotificationPlayerUi.class)
|
||||
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
|
||||
mediaBrowserImpl = new MediaBrowserImpl(this, this::notifyChildrenChanged);
|
||||
|
||||
// see https://developer.android.com/training/cars/media#browser_workflow
|
||||
mediaSession = new MediaSessionCompat(this, "MediaSessionPlayerServ");
|
||||
setSessionToken(mediaSession.getSessionToken());
|
||||
sessionConnector = new MediaSessionConnector(mediaSession);
|
||||
sessionConnector.setMetadataDeduplicationEnabled(true);
|
||||
|
||||
mediaBrowserPlaybackPreparer = new MediaBrowserPlaybackPreparer(
|
||||
this,
|
||||
sessionConnector::setCustomErrorMessage,
|
||||
() -> sessionConnector.setCustomErrorMessage(null),
|
||||
(playWhenReady) -> {
|
||||
if (player != null) {
|
||||
player.onPrepare();
|
||||
}
|
||||
}
|
||||
);
|
||||
sessionConnector.setPlaybackPreparer(mediaBrowserPlaybackPreparer);
|
||||
|
||||
// Note: you might be tempted to create the player instance and call startForeground here,
|
||||
// but be aware that the Android system might start the service just to perform media
|
||||
// queries. In those cases creating a player instance is a waste of resources, and calling
|
||||
// startForeground means creating a useless empty notification. In case it's really needed
|
||||
// the player instance can be created here, but startForeground() should definitely not be
|
||||
// called here unless the service is actually starting in the foreground, to avoid the
|
||||
// useless notification.
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(final Intent intent, final int flags, final int startId) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onStartCommand() called with: intent = [" + intent
|
||||
+ "], extras = [" + BundleKt.toDebugString(intent.getExtras())
|
||||
+ "], flags = [" + flags + "], startId = [" + startId + "]");
|
||||
}
|
||||
|
||||
/*
|
||||
Be sure that the player notification is set and the service is started in foreground,
|
||||
otherwise, the app may crash on Android 8+ as the service would never be put in the
|
||||
foreground while we said to the system we would do so
|
||||
The service is always requested to be started in foreground, so always creating a
|
||||
notification if there is no one already and starting the service in foreground should
|
||||
not create any issues
|
||||
If the service is already started in foreground, requesting it to be started shouldn't
|
||||
do anything
|
||||
*/
|
||||
if (player != null) {
|
||||
// All internal NewPipe intents used to interact with the player, that are sent to the
|
||||
// PlayerService using startForegroundService(), will have SHOULD_START_FOREGROUND_EXTRA,
|
||||
// to ensure startForeground() is called (otherwise Android will force-crash the app).
|
||||
if (intent.getBooleanExtra(SHOULD_START_FOREGROUND_EXTRA, false)) {
|
||||
final boolean playerWasNull = (player == null);
|
||||
if (playerWasNull) {
|
||||
// make sure the player exists, in case the service was resumed
|
||||
player = new Player(this, mediaSession, sessionConnector);
|
||||
}
|
||||
|
||||
// Be sure that the player notification is set and the service is started in foreground,
|
||||
// otherwise, the app may crash on Android 8+ as the service would never be put in the
|
||||
// foreground while we said to the system we would do so. The service is always
|
||||
// requested to be started in foreground, so always creating a notification if there is
|
||||
// no one already and starting the service in foreground should not create any issues.
|
||||
// If the service is already started in foreground, requesting it to be started
|
||||
// shouldn't do anything.
|
||||
player.UIs().get(NotificationPlayerUi.class)
|
||||
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
|
||||
|
||||
if (playerWasNull && onPlayerStartedOrStopped != null) {
|
||||
// notify that a new player was created (but do it after creating the foreground
|
||||
// notification just to make sure we don't incur, due to slowness, in
|
||||
// "Context.startForegroundService() did not then call Service.startForeground()")
|
||||
onPlayerStartedOrStopped.accept(player);
|
||||
}
|
||||
}
|
||||
|
||||
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
|
||||
&& (player == null || player.getPlayQueue() == null)) {
|
||||
/*
|
||||
No need to process media button's actions if the player is not working, otherwise
|
||||
the player service would strangely start with nothing to play
|
||||
Stop the service in this case, which will be removed from the foreground and its
|
||||
notification cancelled in its destruction
|
||||
*/
|
||||
stopSelf();
|
||||
if (player == null) {
|
||||
// No need to process media button's actions or other system intents if the player is
|
||||
// not running. However, since the current intent might have been issued by the system
|
||||
// with `startForegroundService()` (for unknown reasons), we need to ensure that we post
|
||||
// a (dummy) foreground notification, otherwise we'd incur in
|
||||
// "Context.startForegroundService() did not then call Service.startForeground()". Then
|
||||
// we stop the service again.
|
||||
Log.d(TAG, "onStartCommand() got a useless intent, closing the service");
|
||||
NotificationUtil.startForegroundWithDummyNotification(this);
|
||||
destroyPlayerAndStopService();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
if (player != null) {
|
||||
player.handleIntent(intent);
|
||||
player.UIs().get(MediaSessionPlayerUi.class)
|
||||
.ifPresent(ui -> ui.handleMediaButtonIntent(intent));
|
||||
}
|
||||
final PlayerType oldPlayerType = player.getPlayerType();
|
||||
player.handleIntent(intent);
|
||||
player.handleIntentPost(oldPlayerType);
|
||||
player.UIs().get(MediaSessionPlayerUi.class)
|
||||
.ifPresent(ui -> ui.handleMediaButtonIntent(intent));
|
||||
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
@ -142,29 +208,84 @@ public final class PlayerService extends Service {
|
|||
if (DEBUG) {
|
||||
Log.d(TAG, "destroy() called");
|
||||
}
|
||||
super.onDestroy();
|
||||
|
||||
cleanup();
|
||||
|
||||
mediaBrowserPlaybackPreparer.dispose();
|
||||
mediaSession.release();
|
||||
mediaBrowserImpl.dispose();
|
||||
}
|
||||
|
||||
private void cleanup() {
|
||||
if (player != null) {
|
||||
if (onPlayerStartedOrStopped != null) {
|
||||
// notify that the player is being destroyed
|
||||
onPlayerStartedOrStopped.accept(null);
|
||||
}
|
||||
player.destroy();
|
||||
player = null;
|
||||
}
|
||||
|
||||
// Should already be handled by MediaSessionPlayerUi, but just to be sure.
|
||||
mediaSession.setActive(false);
|
||||
|
||||
// Should already be handled by NotificationUtil.cancelNotificationAndStopForeground() in
|
||||
// NotificationPlayerUi, but let's make sure that the foreground service is stopped.
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE);
|
||||
}
|
||||
|
||||
public void stopService() {
|
||||
/**
|
||||
* Destroys the player and allows the player instance to be garbage collected. Sets the media
|
||||
* session to inactive. Stops the foreground service and removes the player notification
|
||||
* associated with it. Tries to stop the {@link PlayerService} completely, but this step will
|
||||
* have no effect in case some service connection still uses the service (e.g. the Android Auto
|
||||
* system accesses the media browser even when no player is running).
|
||||
*/
|
||||
public void destroyPlayerAndStopService() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "destroyPlayerAndStopService() called");
|
||||
}
|
||||
|
||||
cleanup();
|
||||
stopSelf();
|
||||
|
||||
// This only really stops the service if there are no other service connections (see docs):
|
||||
// for example the (Android Auto) media browser binder will block stopService().
|
||||
// This is why we also stopForeground() above, to make sure the notification is removed.
|
||||
// If we were to call stopSelf(), then the service would be surely stopped (regardless of
|
||||
// other service connections), but this would be a waste of resources since the service
|
||||
// would be immediately restarted by those same connections to perform the queries.
|
||||
stopService(new Intent(this, PlayerService.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(final Context base) {
|
||||
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base));
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Bind
|
||||
@Override
|
||||
public IBinder onBind(final Intent intent) {
|
||||
return mBinder;
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onBind() called with: intent = [" + intent
|
||||
+ "], extras = [" + BundleKt.toDebugString(intent.getExtras()) + "]");
|
||||
}
|
||||
|
||||
if (BIND_PLAYER_HOLDER_ACTION.equals(intent.getAction())) {
|
||||
// Note that this binder might be reused multiple times while the service is alive, even
|
||||
// after unbind() has been called: https://stackoverflow.com/a/8794930 .
|
||||
return mBinder;
|
||||
|
||||
} else if (MediaBrowserServiceCompat.SERVICE_INTERFACE.equals(intent.getAction())) {
|
||||
// MediaBrowserService also uses its own binder, so for actions related to the media
|
||||
// browser service, pass the onBind to the superclass.
|
||||
return super.onBind(intent);
|
||||
|
||||
} else {
|
||||
// This is an unknown request, avoid returning any binder to not leak objects.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static class LocalBinder extends Binder {
|
||||
|
@ -177,9 +298,51 @@ public final class PlayerService extends Service {
|
|||
public PlayerService getService() {
|
||||
return playerService.get();
|
||||
}
|
||||
}
|
||||
|
||||
public Player getPlayer() {
|
||||
return playerService.get().player;
|
||||
/**
|
||||
* @return the current active player instance. May be null, since the player service can outlive
|
||||
* the player e.g. to respond to Android Auto media browser queries.
|
||||
*/
|
||||
@Nullable
|
||||
public Player getPlayer() {
|
||||
return player;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the listener that will be called when the player is started or stopped. If a
|
||||
* {@code null} listener is passed, then the current listener will be unset. The parameter taken
|
||||
* by the {@link Consumer} can be null to indicate that the player is stopping.
|
||||
* @param listener the listener to set or unset
|
||||
*/
|
||||
public void setPlayerListener(@Nullable final Consumer<Player> listener) {
|
||||
this.onPlayerStartedOrStopped = listener;
|
||||
if (listener != null) {
|
||||
// if there is no player, then `null` will be sent here, to ensure the state is synced
|
||||
listener.accept(player);
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Media browser
|
||||
@Override
|
||||
public BrowserRoot onGetRoot(@NonNull final String clientPackageName,
|
||||
final int clientUid,
|
||||
@Nullable final Bundle rootHints) {
|
||||
return mediaBrowserImpl.onGetRoot(clientPackageName, clientUid, rootHints);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadChildren(@NonNull final String parentId,
|
||||
@NonNull final Result<List<MediaBrowserCompat.MediaItem>> result) {
|
||||
mediaBrowserImpl.onLoadChildren(parentId, result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSearch(@NonNull final String query,
|
||||
final Bundle extras,
|
||||
@NonNull final Result<List<MediaBrowserCompat.MediaItem>> result) {
|
||||
mediaBrowserImpl.onSearch(query, result);
|
||||
}
|
||||
//endregion
|
||||
}
|
||||
|
|
|
@ -1,32 +1,7 @@
|
|||
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())];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,10 +14,12 @@ import static com.google.android.exoplayer2.util.Assertions.checkNotNull;
|
|||
import static com.google.android.exoplayer2.util.Util.castNonNull;
|
||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getAndroidUserAgent;
|
||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getIosUserAgent;
|
||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.getTvHtml5UserAgent;
|
||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isAndroidStreamingUrl;
|
||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isIosStreamingUrl;
|
||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isTvHtml5StreamingUrl;
|
||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebStreamingUrl;
|
||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isTvHtml5SimplyEmbeddedPlayerStreamingUrl;
|
||||
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isWebEmbeddedPlayerStreamingUrl;
|
||||
import static java.lang.Math.min;
|
||||
|
||||
import android.net.Uri;
|
||||
|
@ -270,6 +272,7 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD
|
|||
|
||||
private static final String RN_PARAMETER = "&rn=";
|
||||
private static final String YOUTUBE_BASE_URL = "https://www.youtube.com";
|
||||
private static final byte[] POST_BODY = new byte[] {0x78, 0};
|
||||
|
||||
private final boolean allowCrossProtocolRedirects;
|
||||
private final boolean rangeParameterEnabled;
|
||||
|
@ -658,8 +661,11 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD
|
|||
}
|
||||
}
|
||||
|
||||
final boolean isTvHtml5StreamingUrl = isTvHtml5StreamingUrl(requestUrl);
|
||||
|
||||
if (isWebStreamingUrl(requestUrl)
|
||||
|| isTvHtml5SimplyEmbeddedPlayerStreamingUrl(requestUrl)) {
|
||||
|| isTvHtml5StreamingUrl
|
||||
|| isWebEmbeddedPlayerStreamingUrl(requestUrl)) {
|
||||
httpURLConnection.setRequestProperty(HttpHeaders.ORIGIN, YOUTUBE_BASE_URL);
|
||||
httpURLConnection.setRequestProperty(HttpHeaders.REFERER, YOUTUBE_BASE_URL);
|
||||
httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_DEST, "empty");
|
||||
|
@ -679,6 +685,9 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD
|
|||
} else if (isIosStreamingUrl) {
|
||||
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT,
|
||||
getIosUserAgent(null));
|
||||
} else if (isTvHtml5StreamingUrl) {
|
||||
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT,
|
||||
getTvHtml5UserAgent());
|
||||
} else {
|
||||
// non-mobile user agent
|
||||
httpURLConnection.setRequestProperty(HttpHeaders.USER_AGENT, DownloaderImpl.USER_AGENT);
|
||||
|
@ -687,22 +696,16 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD
|
|||
httpURLConnection.setRequestProperty(HttpHeaders.ACCEPT_ENCODING,
|
||||
allowGzip ? "gzip" : "identity");
|
||||
httpURLConnection.setInstanceFollowRedirects(followRedirects);
|
||||
httpURLConnection.setDoOutput(httpBody != null);
|
||||
// Most clients use POST requests to fetch contents
|
||||
httpURLConnection.setRequestMethod("POST");
|
||||
httpURLConnection.setDoOutput(true);
|
||||
httpURLConnection.setFixedLengthStreamingMode(POST_BODY.length);
|
||||
httpURLConnection.connect();
|
||||
|
||||
// Mobile clients uses POST requests to fetch contents
|
||||
httpURLConnection.setRequestMethod(isAndroidStreamingUrl || isIosStreamingUrl
|
||||
? "POST"
|
||||
: DataSpec.getStringForHttpMethod(httpMethod));
|
||||
final OutputStream os = httpURLConnection.getOutputStream();
|
||||
os.write(POST_BODY);
|
||||
os.close();
|
||||
|
||||
if (httpBody != null) {
|
||||
httpURLConnection.setFixedLengthStreamingMode(httpBody.length);
|
||||
httpURLConnection.connect();
|
||||
final OutputStream os = httpURLConnection.getOutputStream();
|
||||
os.write(httpBody);
|
||||
os.close();
|
||||
} else {
|
||||
httpURLConnection.connect();
|
||||
}
|
||||
return httpURLConnection;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,11 +1,48 @@
|
|||
package org.schabi.newpipe.player.event;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.schabi.newpipe.player.PlayerService;
|
||||
import org.schabi.newpipe.player.Player;
|
||||
|
||||
/**
|
||||
* In addition to {@link PlayerServiceEventListener}, provides callbacks for service and player
|
||||
* connections and disconnections. "Connected" here means that the service (resp. the
|
||||
* player) is running and is bound to {@link org.schabi.newpipe.player.helper.PlayerHolder}.
|
||||
* "Disconnected" means that either the service (resp. the player) was stopped completely, or that
|
||||
* {@link org.schabi.newpipe.player.helper.PlayerHolder} is not bound.
|
||||
*/
|
||||
public interface PlayerServiceExtendedEventListener extends PlayerServiceEventListener {
|
||||
void onServiceConnected(Player player,
|
||||
PlayerService playerService,
|
||||
boolean playAfterConnect);
|
||||
/**
|
||||
* The player service just connected to {@link org.schabi.newpipe.player.helper.PlayerHolder},
|
||||
* but the player may not be active at this moment, e.g. in case the service is running to
|
||||
* respond to Android Auto media browser queries without playing anything.
|
||||
* {@link #onPlayerConnected(Player, boolean)} will be called right after this function if there
|
||||
* is a player.
|
||||
*
|
||||
* @param playerService the newly connected player service
|
||||
*/
|
||||
void onServiceConnected(@NonNull PlayerService playerService);
|
||||
|
||||
/**
|
||||
* The player service is already connected and the player was just started.
|
||||
*
|
||||
* @param player the newly connected or started player
|
||||
* @param playAfterConnect whether to open the video player in the video details fragment
|
||||
*/
|
||||
void onPlayerConnected(@NonNull Player player, boolean playAfterConnect);
|
||||
|
||||
/**
|
||||
* The player got disconnected, for one of these reasons: the player is getting closed while
|
||||
* leaving the service open for future media browser queries, the service is stopping
|
||||
* completely, or {@link org.schabi.newpipe.player.helper.PlayerHolder} is unbinding.
|
||||
*/
|
||||
void onPlayerDisconnected();
|
||||
|
||||
/**
|
||||
* The service got disconnected from {@link org.schabi.newpipe.player.helper.PlayerHolder},
|
||||
* either because {@link org.schabi.newpipe.player.helper.PlayerHolder} is unbinding or because
|
||||
* the service is stopping completely.
|
||||
*/
|
||||
void onServiceDisconnected();
|
||||
}
|
||||
|
|
|
@ -154,9 +154,6 @@ public class AudioReactor implements AudioManager.OnAudioFocusChangeListener, An
|
|||
notifyAudioSessionUpdate(true, audioSessionId);
|
||||
}
|
||||
private void notifyAudioSessionUpdate(final boolean active, final int audioSessionId) {
|
||||
if (!PlayerHelper.isUsingDSP()) {
|
||||
return;
|
||||
}
|
||||
final Intent intent = new Intent(active
|
||||
? AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION
|
||||
: AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION);
|
||||
|
|
|
@ -2,7 +2,6 @@ package org.schabi.newpipe.player.helper;
|
|||
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateRotation;
|
||||
import static org.schabi.newpipe.player.Player.DEBUG;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
import static org.schabi.newpipe.util.ThemeHelper.resolveDrawable;
|
||||
|
||||
import android.app.Dialog;
|
||||
|
@ -145,7 +144,6 @@ public class PlaybackParameterDialog extends DialogFragment {
|
|||
@NonNull
|
||||
@Override
|
||||
public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
|
||||
assureCorrectAppLanguage(getContext());
|
||||
Bridge.restoreInstanceState(this, savedInstanceState);
|
||||
|
||||
binding = DialogPlaybackParameterBinding.inflate(getLayoutInflater());
|
||||
|
|
|
@ -1,8 +1,5 @@
|
|||
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.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;
|
||||
|
@ -25,7 +22,6 @@ import androidx.core.content.ContextCompat;
|
|||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import com.google.android.exoplayer2.PlaybackParameters;
|
||||
import com.google.android.exoplayer2.Player.RepeatMode;
|
||||
import com.google.android.exoplayer2.SeekParameters;
|
||||
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
|
||||
import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection;
|
||||
|
@ -33,11 +29,9 @@ import com.google.android.exoplayer2.trackselection.ExoTrackSelection;
|
|||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.ResizeMode;
|
||||
import com.google.android.exoplayer2.ui.CaptionStyleCompat;
|
||||
import com.google.android.exoplayer2.util.MimeTypes;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.MediaFormat;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.SubtitlesStream;
|
||||
|
@ -47,13 +41,14 @@ 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.util.ListHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
|
||||
import java.lang.annotation.Retention;
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.DecimalFormatSymbols;
|
||||
import java.text.NumberFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Formatter;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
@ -62,11 +57,7 @@ import java.util.Set;
|
|||
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 NumberFormat SPEED_FORMATTER = new DecimalFormat("0.##x");
|
||||
private static final NumberFormat PITCH_FORMATTER = new DecimalFormat("##%");
|
||||
private static final FormattersProvider FORMATTERS_PROVIDER = new FormattersProvider();
|
||||
|
||||
@Retention(SOURCE)
|
||||
@IntDef({AUTOPLAY_TYPE_ALWAYS, AUTOPLAY_TYPE_WIFI,
|
||||
|
@ -89,9 +80,11 @@ public final class PlayerHelper {
|
|||
private PlayerHelper() {
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Exposed helpers
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// region Exposed helpers
|
||||
|
||||
public static void resetFormat() {
|
||||
FORMATTERS_PROVIDER.reset();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static String getTimeString(final int milliSeconds) {
|
||||
|
@ -100,35 +93,24 @@ public final class PlayerHelper {
|
|||
final int hours = (milliSeconds % 86400000) / 3600000;
|
||||
final int days = (milliSeconds % (86400000 * 7)) / 86400000;
|
||||
|
||||
STRING_BUILDER.setLength(0);
|
||||
return (days > 0
|
||||
? STRING_FORMATTER.format("%d:%02d:%02d:%02d", days, hours, minutes, seconds)
|
||||
: hours > 0
|
||||
? STRING_FORMATTER.format("%d:%02d:%02d", hours, minutes, seconds)
|
||||
: STRING_FORMATTER.format("%02d:%02d", minutes, seconds)
|
||||
).toString();
|
||||
final Formatters formatters = FORMATTERS_PROVIDER.formatters();
|
||||
if (days > 0) {
|
||||
return formatters.stringFormat("%d:%02d:%02d:%02d", days, hours, minutes, seconds);
|
||||
}
|
||||
|
||||
return hours > 0
|
||||
? formatters.stringFormat("%d:%02d:%02d", hours, minutes, seconds)
|
||||
: formatters.stringFormat("%02d:%02d", minutes, seconds);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static String formatSpeed(final double speed) {
|
||||
return SPEED_FORMATTER.format(speed);
|
||||
return FORMATTERS_PROVIDER.formatters().speed().format(speed);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static String formatPitch(final double pitch) {
|
||||
return PITCH_FORMATTER.format(pitch);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static String subtitleMimeTypesOf(@NonNull final MediaFormat format) {
|
||||
switch (format) {
|
||||
case VTT:
|
||||
return MimeTypes.TEXT_VTT;
|
||||
case TTML:
|
||||
return MimeTypes.APPLICATION_TTML;
|
||||
default:
|
||||
throw new IllegalArgumentException("Unrecognized mime type: " + format.name());
|
||||
}
|
||||
return FORMATTERS_PROVIDER.formatters().pitch().format(pitch);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
|
@ -219,9 +201,8 @@ public final class PlayerHelper {
|
|||
? null : getAutoQueuedSinglePlayQueue(autoQueueItems.get(0));
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Settings Resolution
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// endregion
|
||||
// region Resolution
|
||||
|
||||
public static boolean isResumeAfterAudioFocusGain(@NonNull final Context context) {
|
||||
return getPreferences(context)
|
||||
|
@ -315,10 +296,6 @@ public final class PlayerHelper {
|
|||
AdaptiveTrackSelection.DEFAULT_BANDWIDTH_FRACTION);
|
||||
}
|
||||
|
||||
public static boolean isUsingDSP() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static CaptionStyleCompat getCaptionStyle(@NonNull final Context context) {
|
||||
final CaptioningManager captioningManager = ContextCompat.getSystemService(context,
|
||||
|
@ -405,9 +382,8 @@ public final class PlayerHelper {
|
|||
return Integer.parseInt(preferredIntervalBytes) * 1024;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Private helpers
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// endregion
|
||||
// region Private helpers
|
||||
|
||||
@NonNull
|
||||
private static SharedPreferences getPreferences(@NonNull final Context context) {
|
||||
|
@ -426,23 +402,8 @@ public final class PlayerHelper {
|
|||
return singlePlayQueue;
|
||||
}
|
||||
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Utils used by player
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
@RepeatMode
|
||||
public static int nextRepeatMode(@RepeatMode final int repeatMode) {
|
||||
switch (repeatMode) {
|
||||
case REPEAT_MODE_OFF:
|
||||
return REPEAT_MODE_ONE;
|
||||
case REPEAT_MODE_ONE:
|
||||
return REPEAT_MODE_ALL;
|
||||
case REPEAT_MODE_ALL:
|
||||
default:
|
||||
return REPEAT_MODE_OFF;
|
||||
}
|
||||
}
|
||||
// endregion
|
||||
// region Utils used by player
|
||||
|
||||
@ResizeMode
|
||||
public static int retrieveResizeModeFromPrefs(final Player player) {
|
||||
|
@ -503,4 +464,43 @@ public final class PlayerHelper {
|
|||
player.getContext().getString(R.string.seek_duration_key),
|
||||
player.getContext().getString(R.string.seek_duration_default_value))));
|
||||
}
|
||||
|
||||
// endregion
|
||||
// region Format
|
||||
|
||||
static class FormattersProvider {
|
||||
private Formatters formatters;
|
||||
|
||||
public Formatters formatters() {
|
||||
if (formatters == null) {
|
||||
formatters = Formatters.create();
|
||||
}
|
||||
return formatters;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
formatters = null;
|
||||
}
|
||||
}
|
||||
|
||||
record Formatters(
|
||||
Locale locale,
|
||||
NumberFormat speed,
|
||||
NumberFormat pitch) {
|
||||
|
||||
static Formatters create() {
|
||||
final Locale locale = Localization.getAppLocale();
|
||||
final DecimalFormatSymbols dfs = DecimalFormatSymbols.getInstance(locale);
|
||||
return new Formatters(
|
||||
locale,
|
||||
new DecimalFormat("0.##x", dfs),
|
||||
new DecimalFormat("##%", dfs));
|
||||
}
|
||||
|
||||
String stringFormat(final String format, final Object... args) {
|
||||
return String.format(locale, format, args);
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
|
|
@ -22,6 +22,10 @@ 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;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class PlayerHolder {
|
||||
|
||||
|
@ -44,7 +48,16 @@ public final class PlayerHolder {
|
|||
private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection();
|
||||
private boolean bound;
|
||||
@Nullable private PlayerService playerService;
|
||||
@Nullable private Player player;
|
||||
|
||||
private Optional<Player> getPlayer() {
|
||||
return Optional.ofNullable(playerService)
|
||||
.flatMap(s -> Optional.ofNullable(s.getPlayer()));
|
||||
}
|
||||
|
||||
private Optional<PlayQueue> getPlayQueue() {
|
||||
// player play queue might be null e.g. while player is starting
|
||||
return getPlayer().flatMap(p -> Optional.ofNullable(p.getPlayQueue()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current {@link PlayerType} of the {@link PlayerService} service,
|
||||
|
@ -54,21 +67,15 @@ public final class PlayerHolder {
|
|||
*/
|
||||
@Nullable
|
||||
public PlayerType getType() {
|
||||
if (player == null) {
|
||||
return null;
|
||||
}
|
||||
return player.getPlayerType();
|
||||
return getPlayer().map(Player::getPlayerType).orElse(null);
|
||||
}
|
||||
|
||||
public boolean isPlaying() {
|
||||
if (player == null) {
|
||||
return false;
|
||||
}
|
||||
return player.isPlaying();
|
||||
return getPlayer().map(Player::isPlaying).orElse(false);
|
||||
}
|
||||
|
||||
public boolean isPlayerOpen() {
|
||||
return player != null;
|
||||
return getPlayer().isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -77,7 +84,7 @@ public final class PlayerHolder {
|
|||
* @return true only if the player is open and its play queue is ready (i.e. it is not null)
|
||||
*/
|
||||
public boolean isPlayQueueReady() {
|
||||
return player != null && player.getPlayQueue() != null;
|
||||
return getPlayQueue().isPresent();
|
||||
}
|
||||
|
||||
public boolean isBound() {
|
||||
|
@ -85,18 +92,11 @@ public final class PlayerHolder {
|
|||
}
|
||||
|
||||
public int getQueueSize() {
|
||||
if (player == null || player.getPlayQueue() == null) {
|
||||
// player play queue might be null e.g. while player is starting
|
||||
return 0;
|
||||
}
|
||||
return player.getPlayQueue().size();
|
||||
return getPlayQueue().map(PlayQueue::size).orElse(0);
|
||||
}
|
||||
|
||||
public int getQueuePosition() {
|
||||
if (player == null || player.getPlayQueue() == null) {
|
||||
return 0;
|
||||
}
|
||||
return player.getPlayQueue().getIndex();
|
||||
return getPlayQueue().map(PlayQueue::getIndex).orElse(0);
|
||||
}
|
||||
|
||||
public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) {
|
||||
|
@ -107,9 +107,10 @@ public final class PlayerHolder {
|
|||
}
|
||||
|
||||
// Force reload data from service
|
||||
if (player != null) {
|
||||
listener.onServiceConnected(player, playerService, false);
|
||||
if (playerService != null) {
|
||||
listener.onServiceConnected(playerService);
|
||||
startPlayerListener();
|
||||
// ^ will call listener.onPlayerConnected() down the line if there is an active player
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -121,6 +122,9 @@ public final class PlayerHolder {
|
|||
|
||||
public void startService(final boolean playAfterConnect,
|
||||
final PlayerServiceExtendedEventListener newListener) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "startService() called with playAfterConnect=" + playAfterConnect);
|
||||
}
|
||||
final Context context = getCommonContext();
|
||||
setListener(newListener);
|
||||
if (bound) {
|
||||
|
@ -130,14 +134,24 @@ 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, PlayerService.class));
|
||||
final Intent intent = new Intent(context, PlayerService.class);
|
||||
intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true);
|
||||
ContextCompat.startForegroundService(context, intent);
|
||||
serviceConnection.doPlayAfterConnect(playAfterConnect);
|
||||
bind(context);
|
||||
}
|
||||
|
||||
public void stopService() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "stopService() called");
|
||||
}
|
||||
if (playerService != null) {
|
||||
playerService.destroyPlayerAndStopService();
|
||||
}
|
||||
final Context context = getCommonContext();
|
||||
unbind(context);
|
||||
// destroyPlayerAndStopService() already runs the next line of code, but run it again just
|
||||
// to make sure to stop the service even if playerService is null by any chance.
|
||||
context.stopService(new Intent(context, PlayerService.class));
|
||||
}
|
||||
|
||||
|
@ -145,6 +159,11 @@ public final class PlayerHolder {
|
|||
|
||||
private boolean playAfterConnect = false;
|
||||
|
||||
/**
|
||||
* @param playAfterConnection Sets the value of `playAfterConnect` to pass to the {@link
|
||||
* PlayerServiceExtendedEventListener#onPlayerConnected(Player, boolean)} the next time it
|
||||
* is called. The value of `playAfterConnect` will be reset to false after that.
|
||||
*/
|
||||
public void doPlayAfterConnect(final boolean playAfterConnection) {
|
||||
this.playAfterConnect = playAfterConnection;
|
||||
}
|
||||
|
@ -167,11 +186,17 @@ public final class PlayerHolder {
|
|||
final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service;
|
||||
|
||||
playerService = localBinder.getService();
|
||||
player = localBinder.getPlayer();
|
||||
if (listener != null) {
|
||||
listener.onServiceConnected(player, playerService, playAfterConnect);
|
||||
listener.onServiceConnected(playerService);
|
||||
}
|
||||
startPlayerListener();
|
||||
// ^ will call listener.onPlayerConnected() down the line if there is an active player
|
||||
|
||||
if (playerService != null && playerService.getPlayer() != null) {
|
||||
// notify the main activity that binding the service has completed and that there is
|
||||
// a player, so that it can open the bottom mini-player
|
||||
NavigationHelper.sendPlayerStartedEvent(localBinder.getService());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -179,15 +204,28 @@ public final class PlayerHolder {
|
|||
if (DEBUG) {
|
||||
Log.d(TAG, "bind() called");
|
||||
}
|
||||
|
||||
final Intent serviceIntent = new Intent(context, PlayerService.class);
|
||||
bound = context.bindService(serviceIntent, serviceConnection,
|
||||
Context.BIND_AUTO_CREATE);
|
||||
// BIND_AUTO_CREATE starts the service if it's not already running
|
||||
bound = bind(context, Context.BIND_AUTO_CREATE);
|
||||
if (!bound) {
|
||||
context.unbindService(serviceConnection);
|
||||
}
|
||||
}
|
||||
|
||||
public void tryBindIfNeeded(final Context context) {
|
||||
if (!bound) {
|
||||
// flags=0 means the service will not be started if it does not already exist. In this
|
||||
// case the return value is not useful, as a value of "true" does not really indicate
|
||||
// that the service is going to be bound.
|
||||
bind(context, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean bind(final Context context, final int flags) {
|
||||
final Intent serviceIntent = new Intent(context, PlayerService.class);
|
||||
serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION);
|
||||
return context.bindService(serviceIntent, serviceConnection, flags);
|
||||
}
|
||||
|
||||
private void unbind(final Context context) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "unbind() called");
|
||||
|
@ -198,25 +236,32 @@ public final class PlayerHolder {
|
|||
bound = false;
|
||||
stopPlayerListener();
|
||||
playerService = null;
|
||||
player = null;
|
||||
if (listener != null) {
|
||||
listener.onPlayerDisconnected();
|
||||
listener.onServiceDisconnected();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void startPlayerListener() {
|
||||
if (player != null) {
|
||||
player.setFragmentListener(internalListener);
|
||||
if (playerService != null) {
|
||||
// setting the player listener will take care of calling relevant callbacks if the
|
||||
// player in the service is (not) already active, also see playerStateListener below
|
||||
playerService.setPlayerListener(playerStateListener);
|
||||
}
|
||||
getPlayer().ifPresent(p -> p.setFragmentListener(internalListener));
|
||||
}
|
||||
|
||||
private void stopPlayerListener() {
|
||||
if (player != null) {
|
||||
player.removeFragmentListener(internalListener);
|
||||
if (playerService != null) {
|
||||
playerService.setPlayerListener(null);
|
||||
}
|
||||
getPlayer().ifPresent(p -> p.removeFragmentListener(internalListener));
|
||||
}
|
||||
|
||||
/**
|
||||
* This listener will be held by the players created by {@link PlayerService}.
|
||||
*/
|
||||
private final PlayerServiceEventListener internalListener =
|
||||
new PlayerServiceEventListener() {
|
||||
@Override
|
||||
|
@ -303,4 +348,25 @@ public final class PlayerHolder {
|
|||
unbind(getCommonContext());
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This listener will be held by bound {@link PlayerService}s to notify of the player starting
|
||||
* or stopping. This is necessary since the service outlives the player e.g. to answer Android
|
||||
* Auto media browser queries.
|
||||
*/
|
||||
private final Consumer<Player> playerStateListener = (@Nullable final Player player) -> {
|
||||
if (listener != null) {
|
||||
if (player == null) {
|
||||
// player.fragmentListener=null is already done by player.stopActivityBinding(),
|
||||
// which is called by player.destroy(), which is in turn called by PlayerService
|
||||
// before setting its player to null
|
||||
listener.onPlayerDisconnected();
|
||||
} else {
|
||||
listener.onPlayerConnected(player, serviceConnection.playAfterConnect);
|
||||
// reset the value of playAfterConnect: if it was true before, it is now "consumed"
|
||||
serviceConnection.playAfterConnect = false;
|
||||
player.setFragmentListener(internalListener);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
package org.schabi.newpipe.player.mediabrowser
|
||||
|
||||
import org.schabi.newpipe.BuildConfig
|
||||
import org.schabi.newpipe.extractor.InfoItem.InfoType
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
|
||||
internal const val ID_AUTHORITY = BuildConfig.APPLICATION_ID
|
||||
internal const val ID_ROOT = "//$ID_AUTHORITY"
|
||||
internal const val ID_BOOKMARKS = "playlists"
|
||||
internal const val ID_HISTORY = "history"
|
||||
internal const val ID_INFO_ITEM = "item"
|
||||
|
||||
internal const val ID_LOCAL = "local"
|
||||
internal const val ID_REMOTE = "remote"
|
||||
internal const val ID_URL = "url"
|
||||
internal const val ID_STREAM = "stream"
|
||||
internal const val ID_PLAYLIST = "playlist"
|
||||
internal const val ID_CHANNEL = "channel"
|
||||
|
||||
internal fun infoItemTypeToString(type: InfoType): String {
|
||||
return when (type) {
|
||||
InfoType.STREAM -> ID_STREAM
|
||||
InfoType.PLAYLIST -> ID_PLAYLIST
|
||||
InfoType.CHANNEL -> ID_CHANNEL
|
||||
else -> throw IllegalStateException("Unexpected value: $type")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun infoItemTypeFromString(type: String): InfoType {
|
||||
return when (type) {
|
||||
ID_STREAM -> InfoType.STREAM
|
||||
ID_PLAYLIST -> InfoType.PLAYLIST
|
||||
ID_CHANNEL -> InfoType.CHANNEL
|
||||
else -> throw IllegalStateException("Unexpected value: $type")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parseError(mediaId: String): ContentNotAvailableException {
|
||||
return ContentNotAvailableException("Failed to parse media ID $mediaId")
|
||||
}
|
|
@ -0,0 +1,421 @@
|
|||
package org.schabi.newpipe.player.mediabrowser
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.util.Log
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT
|
||||
import androidx.media.MediaBrowserServiceCompat.Result
|
||||
import androidx.media.utils.MediaConstants
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.MainActivity.DEBUG
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntry
|
||||
import org.schabi.newpipe.database.playlist.PlaylistLocalItem
|
||||
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
|
||||
import org.schabi.newpipe.extractor.InfoItem
|
||||
import org.schabi.newpipe.extractor.InfoItem.InfoType
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
|
||||
import org.schabi.newpipe.extractor.search.SearchInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.bookmark.MergedPlaylistManager
|
||||
import org.schabi.newpipe.local.playlist.LocalPlaylistManager
|
||||
import org.schabi.newpipe.local.playlist.RemotePlaylistManager
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
import org.schabi.newpipe.util.ServiceHelper
|
||||
import org.schabi.newpipe.util.image.ImageStrategy
|
||||
import java.util.function.Consumer
|
||||
|
||||
/**
|
||||
* This class is used to cleanly separate the Service implementation (in
|
||||
* [org.schabi.newpipe.player.PlayerService]) and the media browser implementation (in this file).
|
||||
*
|
||||
* @param notifyChildrenChanged takes the parent id of the children that changed
|
||||
*/
|
||||
class MediaBrowserImpl(
|
||||
private val context: Context,
|
||||
notifyChildrenChanged: Consumer<String>, // parentId
|
||||
) {
|
||||
private val packageValidator = PackageValidator(context)
|
||||
private val database = NewPipeDatabase.getInstance(context)
|
||||
private var disposables = CompositeDisposable()
|
||||
|
||||
init {
|
||||
// this will listen to changes in the bookmarks until this MediaBrowserImpl is dispose()d
|
||||
disposables.add(
|
||||
getMergedPlaylists().subscribe { notifyChildrenChanged.accept(ID_BOOKMARKS) }
|
||||
)
|
||||
}
|
||||
|
||||
//region Cleanup
|
||||
fun dispose() {
|
||||
disposables.dispose()
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region onGetRoot
|
||||
fun onGetRoot(
|
||||
clientPackageName: String,
|
||||
clientUid: Int,
|
||||
rootHints: Bundle?
|
||||
): MediaBrowserServiceCompat.BrowserRoot? {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onGetRoot($clientPackageName, $clientUid, $rootHints)")
|
||||
}
|
||||
|
||||
if (!packageValidator.isKnownCaller(clientPackageName, clientUid)) {
|
||||
// this is a caller we can't trust (see PackageValidator's rules taken from uamp)
|
||||
return null
|
||||
}
|
||||
|
||||
if (rootHints?.getBoolean(EXTRA_RECENT, false) == true) {
|
||||
// the system is asking for a root to do media resumption, but we can't handle that yet,
|
||||
// see https://developer.android.com/media/implement/surfaces/mobile#mediabrowserservice_implementation
|
||||
return null
|
||||
}
|
||||
|
||||
val extras = Bundle()
|
||||
extras.putBoolean(
|
||||
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true
|
||||
)
|
||||
return MediaBrowserServiceCompat.BrowserRoot(ID_ROOT, extras)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region onLoadChildren
|
||||
fun onLoadChildren(parentId: String, result: Result<List<MediaBrowserCompat.MediaItem>>) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onLoadChildren($parentId)")
|
||||
}
|
||||
|
||||
result.detach() // allows sendResult() to happen later
|
||||
disposables.add(
|
||||
onLoadChildren(parentId)
|
||||
.subscribe(
|
||||
{ result.sendResult(it) },
|
||||
{ throwable ->
|
||||
// null indicates an error, see the docs of MediaSessionCompat.onSearch()
|
||||
result.sendResult(null)
|
||||
Log.e(TAG, "onLoadChildren error for parentId=$parentId: $throwable")
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun onLoadChildren(parentId: String): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
try {
|
||||
val parentIdUri = parentId.toUri()
|
||||
val path = ArrayList(parentIdUri.pathSegments)
|
||||
|
||||
if (path.isEmpty()) {
|
||||
return Single.just(
|
||||
listOf(
|
||||
createRootMediaItem(
|
||||
ID_BOOKMARKS,
|
||||
context.resources.getString(R.string.tab_bookmarks_short),
|
||||
R.drawable.ic_bookmark_white
|
||||
),
|
||||
createRootMediaItem(
|
||||
ID_HISTORY,
|
||||
context.resources.getString(R.string.action_history),
|
||||
R.drawable.ic_history_white
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
when (/*val uriType = */path.removeAt(0)) {
|
||||
ID_BOOKMARKS -> {
|
||||
if (path.isEmpty()) {
|
||||
return populateBookmarks()
|
||||
}
|
||||
if (path.size == 2) {
|
||||
val localOrRemote = path[0]
|
||||
val playlistId = path[1].toLong()
|
||||
if (localOrRemote == ID_LOCAL) {
|
||||
return populateLocalPlaylist(playlistId)
|
||||
} else if (localOrRemote == ID_REMOTE) {
|
||||
return populateRemotePlaylist(playlistId)
|
||||
}
|
||||
}
|
||||
Log.w(TAG, "Unknown playlist URI: $parentId")
|
||||
throw parseError(parentId)
|
||||
}
|
||||
|
||||
ID_HISTORY -> return populateHistory()
|
||||
|
||||
else -> throw parseError(parentId)
|
||||
}
|
||||
} catch (e: ContentNotAvailableException) {
|
||||
return Single.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRootMediaItem(
|
||||
mediaId: String?,
|
||||
folderName: String?,
|
||||
@DrawableRes iconResId: Int
|
||||
): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder.setMediaId(mediaId)
|
||||
builder.setTitle(folderName)
|
||||
val resources = context.resources
|
||||
builder.setIconUri(
|
||||
Uri.Builder()
|
||||
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
|
||||
.authority(resources.getResourcePackageName(iconResId))
|
||||
.appendPath(resources.getResourceTypeName(iconResId))
|
||||
.appendPath(resources.getResourceEntryName(iconResId))
|
||||
.build()
|
||||
)
|
||||
|
||||
val extras = Bundle()
|
||||
extras.putString(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
|
||||
context.getString(R.string.app_name)
|
||||
)
|
||||
builder.setExtras(extras)
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun createPlaylistMediaItem(playlist: PlaylistLocalItem): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder
|
||||
.setMediaId(createMediaIdForInfoItem(playlist is PlaylistRemoteEntity, playlist.uid))
|
||||
.setTitle(playlist.orderingName)
|
||||
.setIconUri(imageUriOrNullIfDisabled(playlist.thumbnailUrl))
|
||||
|
||||
val extras = Bundle()
|
||||
extras.putString(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
|
||||
context.resources.getString(R.string.tab_bookmarks),
|
||||
)
|
||||
builder.setExtras(extras)
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createInfoItemMediaItem(item: InfoItem): MediaBrowserCompat.MediaItem? {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder.setMediaId(createMediaIdForInfoItem(item))
|
||||
.setTitle(item.name)
|
||||
|
||||
when (item.infoType) {
|
||||
InfoType.STREAM -> builder.setSubtitle((item as StreamInfoItem).uploaderName)
|
||||
InfoType.PLAYLIST -> builder.setSubtitle((item as PlaylistInfoItem).uploaderName)
|
||||
InfoType.CHANNEL -> builder.setSubtitle((item as ChannelInfoItem).description)
|
||||
else -> return null
|
||||
}
|
||||
|
||||
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
|
||||
builder.setIconUri(imageUriOrNullIfDisabled(it))
|
||||
}
|
||||
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildMediaId(): Uri.Builder {
|
||||
return Uri.Builder().authority(ID_AUTHORITY)
|
||||
}
|
||||
|
||||
private fun buildPlaylistMediaId(playlistType: String?): Uri.Builder {
|
||||
return buildMediaId()
|
||||
.appendPath(ID_BOOKMARKS)
|
||||
.appendPath(playlistType)
|
||||
}
|
||||
|
||||
private fun buildLocalPlaylistItemMediaId(isRemote: Boolean, playlistId: Long): Uri.Builder {
|
||||
return buildPlaylistMediaId(if (isRemote) ID_REMOTE else ID_LOCAL)
|
||||
.appendPath(playlistId.toString())
|
||||
}
|
||||
|
||||
private fun buildInfoItemMediaId(item: InfoItem): Uri.Builder {
|
||||
return buildMediaId()
|
||||
.appendPath(ID_INFO_ITEM)
|
||||
.appendPath(infoItemTypeToString(item.infoType))
|
||||
.appendPath(item.serviceId.toString())
|
||||
.appendQueryParameter(ID_URL, item.url)
|
||||
}
|
||||
|
||||
private fun createMediaIdForInfoItem(isRemote: Boolean, playlistId: Long): String {
|
||||
return buildLocalPlaylistItemMediaId(isRemote, playlistId)
|
||||
.build().toString()
|
||||
}
|
||||
|
||||
private fun createLocalPlaylistStreamMediaItem(
|
||||
playlistId: Long,
|
||||
item: PlaylistStreamEntry,
|
||||
index: Int,
|
||||
): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index))
|
||||
.setTitle(item.streamEntity.title)
|
||||
.setSubtitle(item.streamEntity.uploader)
|
||||
.setIconUri(imageUriOrNullIfDisabled(item.streamEntity.thumbnailUrl))
|
||||
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun createRemotePlaylistStreamMediaItem(
|
||||
playlistId: Long,
|
||||
item: StreamInfoItem,
|
||||
index: Int,
|
||||
): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index))
|
||||
.setTitle(item.name)
|
||||
.setSubtitle(item.uploaderName)
|
||||
|
||||
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
|
||||
builder.setIconUri(imageUriOrNullIfDisabled(it))
|
||||
}
|
||||
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun createMediaIdForPlaylistIndex(
|
||||
isRemote: Boolean,
|
||||
playlistId: Long,
|
||||
index: Int,
|
||||
): String {
|
||||
return buildLocalPlaylistItemMediaId(isRemote, playlistId)
|
||||
.appendPath(index.toString())
|
||||
.build().toString()
|
||||
}
|
||||
|
||||
private fun createMediaIdForInfoItem(item: InfoItem): String {
|
||||
return buildInfoItemMediaId(item).build().toString()
|
||||
}
|
||||
|
||||
private fun populateHistory(): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
val history = database.streamHistoryDAO().getHistory().firstOrError()
|
||||
return history.map { items ->
|
||||
items.map { this.createHistoryMediaItem(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun createHistoryMediaItem(streamHistoryEntry: StreamHistoryEntry): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
val mediaId = buildMediaId()
|
||||
.appendPath(ID_HISTORY)
|
||||
.appendPath(streamHistoryEntry.streamId.toString())
|
||||
.build().toString()
|
||||
builder.setMediaId(mediaId)
|
||||
.setTitle(streamHistoryEntry.streamEntity.title)
|
||||
.setSubtitle(streamHistoryEntry.streamEntity.uploader)
|
||||
.setIconUri(imageUriOrNullIfDisabled(streamHistoryEntry.streamEntity.thumbnailUrl))
|
||||
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun getMergedPlaylists(): Flowable<MutableList<PlaylistLocalItem>> {
|
||||
return MergedPlaylistManager.getMergedOrderedPlaylists(
|
||||
LocalPlaylistManager(database),
|
||||
RemotePlaylistManager(database)
|
||||
)
|
||||
}
|
||||
|
||||
private fun populateBookmarks(): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
val playlists = getMergedPlaylists().firstOrError()
|
||||
return playlists.map { playlist ->
|
||||
playlist.map { this.createPlaylistMediaItem(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun populateLocalPlaylist(playlistId: Long): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
val playlist = LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError()
|
||||
return playlist.map { items ->
|
||||
items.mapIndexed { index, item ->
|
||||
createLocalPlaylistStreamMediaItem(playlistId, item, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun populateRemotePlaylist(playlistId: Long): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
return RemotePlaylistManager(database).getPlaylist(playlistId).firstOrError()
|
||||
.flatMap { ExtractorHelper.getPlaylistInfo(it.serviceId, it.url, false) }
|
||||
.map {
|
||||
// ignore it.errors, i.e. ignore errors about specific items, since there would
|
||||
// be no way to show the error properly in Android Auto anyway
|
||||
it.relatedItems.mapIndexed { index, item ->
|
||||
createRemotePlaylistStreamMediaItem(playlistId, item, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Search
|
||||
fun onSearch(
|
||||
query: String,
|
||||
result: Result<List<MediaBrowserCompat.MediaItem>>
|
||||
) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onSearch($query)")
|
||||
}
|
||||
|
||||
result.detach() // allows sendResult() to happen later
|
||||
disposables.add(
|
||||
searchMusicBySongTitle(query)
|
||||
// ignore it.errors, i.e. ignore errors about specific items, since there would
|
||||
// be no way to show the error properly in Android Auto anyway
|
||||
.map { it.relatedItems.mapNotNull(this::createInfoItemMediaItem) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe(
|
||||
{ result.sendResult(it) },
|
||||
{ throwable ->
|
||||
// null indicates an error, see the docs of MediaSessionCompat.onSearch()
|
||||
result.sendResult(null)
|
||||
Log.e(TAG, "Search error for query=\"$query\": $throwable")
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun searchMusicBySongTitle(query: String?): Single<SearchInfo> {
|
||||
val serviceId = ServiceHelper.getSelectedServiceId(context)
|
||||
return ExtractorHelper.searchFor(serviceId, query, listOf(), "")
|
||||
}
|
||||
//endregion
|
||||
|
||||
companion object {
|
||||
private val TAG: String = MediaBrowserImpl::class.java.getSimpleName()
|
||||
|
||||
fun imageUriOrNullIfDisabled(url: String?): Uri? {
|
||||
return if (ImageStrategy.shouldLoadImages()) {
|
||||
url?.toUri()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,262 @@
|
|||
package org.schabi.newpipe.player.mediabrowser
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.ResultReceiver
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.PlaybackPreparer
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.MainActivity
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.error.ErrorInfo
|
||||
import org.schabi.newpipe.extractor.InfoItem.InfoType
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler
|
||||
import org.schabi.newpipe.local.playlist.LocalPlaylistManager
|
||||
import org.schabi.newpipe.local.playlist.RemotePlaylistManager
|
||||
import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue
|
||||
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue
|
||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue
|
||||
import org.schabi.newpipe.util.ChannelTabHelper
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
import org.schabi.newpipe.util.NavigationHelper
|
||||
import java.util.function.BiConsumer
|
||||
import java.util.function.Consumer
|
||||
|
||||
/**
|
||||
* This class is used to cleanly separate the Service implementation (in
|
||||
* [org.schabi.newpipe.player.PlayerService]) and the playback preparer implementation (in this
|
||||
* file). We currently use the playback preparer only in conjunction with the media browser: the
|
||||
* playback preparer will receive the media URLs generated by [MediaBrowserImpl] and will start
|
||||
* playback of the corresponding streams or playlists.
|
||||
*
|
||||
* @param setMediaSessionError takes an error String and an error code from [PlaybackStateCompat],
|
||||
* calls `sessionConnector.setCustomErrorMessage(errorString, errorCode)`
|
||||
* @param clearMediaSessionError calls `sessionConnector.setCustomErrorMessage(null)`
|
||||
* @param onPrepare takes playWhenReady, calls `player.prepare()`; this is needed because
|
||||
* `MediaSessionConnector`'s `onPlay()` method calls this class' [onPrepare] instead of
|
||||
* `player.prepare()` if the playback preparer is not null, but we want the original behavior
|
||||
*/
|
||||
class MediaBrowserPlaybackPreparer(
|
||||
private val context: Context,
|
||||
private val setMediaSessionError: BiConsumer<String, Int>, // error string, error code
|
||||
private val clearMediaSessionError: Runnable,
|
||||
private val onPrepare: Consumer<Boolean>,
|
||||
) : PlaybackPreparer {
|
||||
private val database = NewPipeDatabase.getInstance(context)
|
||||
private var disposable: Disposable? = null
|
||||
|
||||
fun dispose() {
|
||||
disposable?.dispose()
|
||||
}
|
||||
|
||||
//region Overrides
|
||||
override fun getSupportedPrepareActions(): Long {
|
||||
return PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID
|
||||
}
|
||||
|
||||
override fun onPrepare(playWhenReady: Boolean) {
|
||||
onPrepare.accept(playWhenReady)
|
||||
}
|
||||
|
||||
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
|
||||
if (MainActivity.DEBUG) {
|
||||
Log.d(TAG, "onPrepareFromMediaId($mediaId, $playWhenReady, $extras)")
|
||||
}
|
||||
|
||||
disposable?.dispose()
|
||||
disposable = extractPlayQueueFromMediaId(mediaId)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ playQueue ->
|
||||
clearMediaSessionError.run()
|
||||
NavigationHelper.playOnBackgroundPlayer(context, playQueue, playWhenReady)
|
||||
},
|
||||
{ throwable ->
|
||||
Log.e(TAG, "Failed to start playback of media ID [$mediaId]", throwable)
|
||||
onPrepareError(throwable)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
|
||||
onUnsupportedError()
|
||||
}
|
||||
|
||||
override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) {
|
||||
onUnsupportedError()
|
||||
}
|
||||
|
||||
override fun onCommand(
|
||||
player: Player,
|
||||
command: String,
|
||||
extras: Bundle?,
|
||||
cb: ResultReceiver?
|
||||
): Boolean {
|
||||
return false
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Errors
|
||||
private fun onUnsupportedError() {
|
||||
setMediaSessionError.accept(
|
||||
ContextCompat.getString(context, R.string.content_not_supported),
|
||||
PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED
|
||||
)
|
||||
}
|
||||
|
||||
private fun onPrepareError(throwable: Throwable) {
|
||||
setMediaSessionError.accept(
|
||||
ErrorInfo.getMessage(throwable, null, null).getString(context),
|
||||
PlaybackStateCompat.ERROR_CODE_APP_ERROR
|
||||
)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Building play queues from playlists and history
|
||||
private fun extractLocalPlayQueue(playlistId: Long, index: Int): Single<PlayQueue> {
|
||||
return LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError()
|
||||
.map { items -> SinglePlayQueue(items.map { it.toStreamInfoItem() }, index) }
|
||||
}
|
||||
|
||||
private fun extractRemotePlayQueue(playlistId: Long, index: Int): Single<PlayQueue> {
|
||||
return RemotePlaylistManager(database).getPlaylist(playlistId).firstOrError()
|
||||
.flatMap { ExtractorHelper.getPlaylistInfo(it.serviceId, it.url, false) }
|
||||
// ignore info.errors, i.e. ignore errors about specific items, since there would
|
||||
// be no way to show the error properly in Android Auto anyway
|
||||
.map { info -> PlaylistPlayQueue(info, index) }
|
||||
}
|
||||
|
||||
private fun extractPlayQueueFromMediaId(mediaId: String): Single<PlayQueue> {
|
||||
try {
|
||||
val mediaIdUri = mediaId.toUri()
|
||||
val path = ArrayList(mediaIdUri.pathSegments)
|
||||
if (path.isEmpty()) {
|
||||
throw parseError(mediaId)
|
||||
}
|
||||
|
||||
return when (/*val uriType = */path.removeAt(0)) {
|
||||
ID_BOOKMARKS -> extractPlayQueueFromPlaylistMediaId(
|
||||
mediaId,
|
||||
path,
|
||||
mediaIdUri.getQueryParameter(ID_URL)
|
||||
)
|
||||
|
||||
ID_HISTORY -> extractPlayQueueFromHistoryMediaId(mediaId, path)
|
||||
|
||||
ID_INFO_ITEM -> extractPlayQueueFromInfoItemMediaId(
|
||||
mediaId,
|
||||
path,
|
||||
mediaIdUri.getQueryParameter(ID_URL) ?: throw parseError(mediaId)
|
||||
)
|
||||
|
||||
else -> throw parseError(mediaId)
|
||||
}
|
||||
} catch (e: ContentNotAvailableException) {
|
||||
return Single.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContentNotAvailableException::class)
|
||||
private fun extractPlayQueueFromPlaylistMediaId(
|
||||
mediaId: String,
|
||||
path: MutableList<String>,
|
||||
url: String?,
|
||||
): Single<PlayQueue> {
|
||||
if (path.isEmpty()) {
|
||||
throw parseError(mediaId)
|
||||
}
|
||||
|
||||
when (val playlistType = path.removeAt(0)) {
|
||||
ID_LOCAL, ID_REMOTE -> {
|
||||
if (path.size != 2) {
|
||||
throw parseError(mediaId)
|
||||
}
|
||||
val playlistId = path[0].toLong()
|
||||
val index = path[1].toInt()
|
||||
return if (playlistType == ID_LOCAL)
|
||||
extractLocalPlayQueue(playlistId, index)
|
||||
else
|
||||
extractRemotePlayQueue(playlistId, index)
|
||||
}
|
||||
|
||||
ID_URL -> {
|
||||
if (path.size != 1 || url == null) {
|
||||
throw parseError(mediaId)
|
||||
}
|
||||
|
||||
val serviceId = path[0].toInt()
|
||||
return ExtractorHelper.getPlaylistInfo(serviceId, url, false)
|
||||
.map { PlaylistPlayQueue(it) }
|
||||
}
|
||||
|
||||
else -> throw parseError(mediaId)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContentNotAvailableException::class)
|
||||
private fun extractPlayQueueFromHistoryMediaId(
|
||||
mediaId: String,
|
||||
path: List<String>,
|
||||
): Single<PlayQueue> {
|
||||
if (path.size != 1) {
|
||||
throw parseError(mediaId)
|
||||
}
|
||||
|
||||
val streamId = path[0].toLong()
|
||||
return database.streamHistoryDAO().getHistory()
|
||||
.firstOrError()
|
||||
.map { items ->
|
||||
val infoItems = items
|
||||
.filter { it.streamId == streamId }
|
||||
.map { it.toStreamInfoItem() }
|
||||
SinglePlayQueue(infoItems, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContentNotAvailableException::class)
|
||||
private fun extractPlayQueueFromInfoItemMediaId(
|
||||
mediaId: String,
|
||||
path: List<String>,
|
||||
url: String,
|
||||
): Single<PlayQueue> {
|
||||
if (path.size != 2) {
|
||||
throw parseError(mediaId)
|
||||
}
|
||||
|
||||
val serviceId = path[1].toInt()
|
||||
return when (/*val infoItemType = */infoItemTypeFromString(path[0])) {
|
||||
InfoType.STREAM -> ExtractorHelper.getStreamInfo(serviceId, url, false)
|
||||
.map { SinglePlayQueue(it) }
|
||||
|
||||
InfoType.PLAYLIST -> ExtractorHelper.getPlaylistInfo(serviceId, url, false)
|
||||
.map { PlaylistPlayQueue(it) }
|
||||
|
||||
InfoType.CHANNEL -> ExtractorHelper.getChannelInfo(serviceId, url, false)
|
||||
.map { info ->
|
||||
val playableTab = info.tabs
|
||||
.firstOrNull { ChannelTabHelper.isStreamsTab(it) }
|
||||
?: throw ContentNotAvailableException("No streams tab found")
|
||||
return@map ChannelTabPlayQueue(serviceId, ListLinkHandler(playableTab))
|
||||
}
|
||||
|
||||
else -> throw parseError(mediaId)
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
companion object {
|
||||
private val TAG = MediaBrowserPlaybackPreparer::class.simpleName
|
||||
}
|
||||
}
|
|
@ -0,0 +1,240 @@
|
|||
/*
|
||||
* Copyright 2018 Google Inc. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// THIS FILE WAS TAKEN FROM UAMP, EXCEPT FOR THINGS RELATED TO THE WHITELIST. UPDATE IT WHEN NEEDED.
|
||||
// https://github.com/android/uamp/blob/329a21b63c247e9bd35f6858d4fc0e448fa38603/common/src/main/java/com/example/android/uamp/media/PackageValidator.kt
|
||||
|
||||
package org.schabi.newpipe.player.mediabrowser
|
||||
|
||||
import android.Manifest.permission.MEDIA_CONTENT_CONTROL
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Process
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import org.schabi.newpipe.BuildConfig
|
||||
import java.security.MessageDigest
|
||||
import java.security.NoSuchAlgorithmException
|
||||
|
||||
/**
|
||||
* Validates that the calling package is authorized to browse a [MediaBrowserServiceCompat].
|
||||
*
|
||||
* The list of allowed signing certificates and their corresponding package names is defined in
|
||||
* res/xml/allowed_media_browser_callers.xml.
|
||||
*
|
||||
* If you want to add a new caller to allowed_media_browser_callers.xml and you don't know
|
||||
* its signature, this class will print to logcat (INFO level) a message with the proper
|
||||
* xml tags to add to allow the caller.
|
||||
*
|
||||
* For more information, see res/xml/allowed_media_browser_callers.xml.
|
||||
*/
|
||||
internal class PackageValidator(context: Context) {
|
||||
private val context: Context = context.applicationContext
|
||||
private val packageManager: PackageManager = this.context.packageManager
|
||||
private val platformSignature: String = getSystemSignature()
|
||||
private val callerChecked = mutableMapOf<String, Pair<Int, Boolean>>()
|
||||
|
||||
/**
|
||||
* Checks whether the caller attempting to connect to a [MediaBrowserServiceCompat] is known.
|
||||
* See [MusicService.onGetRoot] for where this is utilized.
|
||||
*
|
||||
* @param callingPackage The package name of the caller.
|
||||
* @param callingUid The user id of the caller.
|
||||
* @return `true` if the caller is known, `false` otherwise.
|
||||
*/
|
||||
fun isKnownCaller(callingPackage: String, callingUid: Int): Boolean {
|
||||
// If the caller has already been checked, return the previous result here.
|
||||
val (checkedUid, checkResult) = callerChecked[callingPackage] ?: Pair(0, false)
|
||||
if (checkedUid == callingUid) {
|
||||
return checkResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Because some of these checks can be slow, we save the results in [callerChecked] after
|
||||
* this code is run.
|
||||
*
|
||||
* In particular, there's little reason to recompute the calling package's certificate
|
||||
* signature (SHA-256) each call.
|
||||
*
|
||||
* This is safe to do as we know the UID matches the package's UID (from the check above),
|
||||
* and app UIDs are set at install time. Additionally, a package name + UID is guaranteed to
|
||||
* be constant until a reboot. (After a reboot then a previously assigned UID could be
|
||||
* reassigned.)
|
||||
*/
|
||||
|
||||
// Build the caller info for the rest of the checks here.
|
||||
val callerPackageInfo = buildCallerInfo(callingPackage)
|
||||
?: throw IllegalStateException("Caller wasn't found in the system?")
|
||||
|
||||
// Verify that things aren't ... broken. (This test should always pass.)
|
||||
if (callerPackageInfo.uid != callingUid) {
|
||||
throw IllegalStateException("Caller's package UID doesn't match caller's actual UID?")
|
||||
}
|
||||
|
||||
val callerSignature = callerPackageInfo.signature
|
||||
|
||||
val isCallerKnown = when {
|
||||
// If it's our own app making the call, allow it.
|
||||
callingUid == Process.myUid() -> true
|
||||
// If the system is making the call, allow it.
|
||||
callingUid == Process.SYSTEM_UID -> true
|
||||
// If the app was signed by the same certificate as the platform itself, also allow it.
|
||||
callerSignature == platformSignature -> true
|
||||
/**
|
||||
* [MEDIA_CONTENT_CONTROL] permission is only available to system applications, and
|
||||
* while it isn't required to allow these apps to connect to a
|
||||
* [MediaBrowserServiceCompat], allowing this ensures optimal compatability with apps
|
||||
* such as Android TV and the Google Assistant.
|
||||
*/
|
||||
callerPackageInfo.permissions.contains(MEDIA_CONTENT_CONTROL) -> true
|
||||
/**
|
||||
* If the calling app has a notification listener it is able to retrieve notifications
|
||||
* and can connect to an active [MediaSessionCompat].
|
||||
*
|
||||
* It's not required to allow apps with a notification listener to
|
||||
* connect to your [MediaBrowserServiceCompat], but it does allow easy compatibility
|
||||
* with apps such as Wear OS.
|
||||
*/
|
||||
NotificationManagerCompat.getEnabledListenerPackages(this.context)
|
||||
.contains(callerPackageInfo.packageName) -> true
|
||||
|
||||
// If none of the previous checks succeeded, then the caller is unrecognized.
|
||||
else -> false
|
||||
}
|
||||
|
||||
if (!isCallerKnown) {
|
||||
logUnknownCaller(callerPackageInfo)
|
||||
}
|
||||
|
||||
// Save our work for next time.
|
||||
callerChecked[callingPackage] = Pair(callingUid, isCallerKnown)
|
||||
return isCallerKnown
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an info level message with details of how to add a caller to the allowed callers list
|
||||
* when the app is debuggable.
|
||||
*/
|
||||
private fun logUnknownCaller(callerPackageInfo: CallerPackageInfo) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.w(TAG, "Unknown caller $callerPackageInfo")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [CallerPackageInfo] for a given package that can be used for all the
|
||||
* various checks that are performed before allowing an app to connect to a
|
||||
* [MediaBrowserServiceCompat].
|
||||
*/
|
||||
private fun buildCallerInfo(callingPackage: String): CallerPackageInfo? {
|
||||
val packageInfo = getPackageInfo(callingPackage) ?: return null
|
||||
|
||||
val appName = packageInfo.applicationInfo?.loadLabel(packageManager).toString()
|
||||
val uid = packageInfo.applicationInfo?.uid ?: -1
|
||||
val signature = getSignature(packageInfo)
|
||||
|
||||
val requestedPermissions = packageInfo.requestedPermissions?.asSequence().orEmpty()
|
||||
val permissionFlags = packageInfo.requestedPermissionsFlags?.asSequence().orEmpty()
|
||||
val activePermissions = (requestedPermissions zip permissionFlags)
|
||||
.filter { (permission, flag) -> flag and REQUESTED_PERMISSION_GRANTED != 0 }
|
||||
.mapTo(mutableSetOf()) { (permission, flag) -> permission }
|
||||
|
||||
return CallerPackageInfo(appName, callingPackage, uid, signature, activePermissions.toSet())
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the [PackageInfo] for a package name.
|
||||
* This requests both the signatures (for checking if an app is on the allow list) and
|
||||
* the app's permissions, which allow for more flexibility in the allow list.
|
||||
*
|
||||
* @return [PackageInfo] for the package name or null if it's not found.
|
||||
*/
|
||||
@Suppress("deprecation")
|
||||
@SuppressLint("PackageManagerGetSignatures")
|
||||
private fun getPackageInfo(callingPackage: String): PackageInfo? =
|
||||
packageManager.getPackageInfo(
|
||||
callingPackage,
|
||||
PackageManager.GET_SIGNATURES or PackageManager.GET_PERMISSIONS
|
||||
)
|
||||
|
||||
/**
|
||||
* Gets the signature of a given package's [PackageInfo].
|
||||
*
|
||||
* The "signature" is a SHA-256 hash of the public key of the signing certificate used by
|
||||
* the app.
|
||||
*
|
||||
* If the app is not found, or if the app does not have exactly one signature, this method
|
||||
* returns `null` as the signature.
|
||||
*/
|
||||
@Suppress("deprecation")
|
||||
private fun getSignature(packageInfo: PackageInfo): String? =
|
||||
if (packageInfo.signatures == null || packageInfo.signatures!!.size != 1) {
|
||||
// Security best practices dictate that an app should be signed with exactly one (1)
|
||||
// signature. Because of this, if there are multiple signatures, reject it.
|
||||
null
|
||||
} else {
|
||||
val certificate = packageInfo.signatures!![0].toByteArray()
|
||||
getSignatureSha256(certificate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the Android platform signing key signature. This key is never null.
|
||||
*/
|
||||
private fun getSystemSignature(): String =
|
||||
getPackageInfo(ANDROID_PLATFORM)?.let { platformInfo ->
|
||||
getSignature(platformInfo)
|
||||
} ?: throw IllegalStateException("Platform signature not found")
|
||||
|
||||
/**
|
||||
* Creates a SHA-256 signature given a certificate byte array.
|
||||
*/
|
||||
private fun getSignatureSha256(certificate: ByteArray): String {
|
||||
val md: MessageDigest
|
||||
try {
|
||||
md = MessageDigest.getInstance("SHA256")
|
||||
} catch (noSuchAlgorithmException: NoSuchAlgorithmException) {
|
||||
Log.e(TAG, "No such algorithm: $noSuchAlgorithmException")
|
||||
throw RuntimeException("Could not find SHA256 hash algorithm", noSuchAlgorithmException)
|
||||
}
|
||||
md.update(certificate)
|
||||
|
||||
// This code takes the byte array generated by `md.digest()` and joins each of the bytes
|
||||
// to a string, applying the string format `%02x` on each digit before it's appended, with
|
||||
// a colon (':') between each of the items.
|
||||
// For example: input=[0,2,4,6,8,10,12], output="00:02:04:06:08:0a:0c"
|
||||
return md.digest().joinToString(":") { String.format("%02x", it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience class to hold all of the information about an app that's being checked
|
||||
* to see if it's a known caller.
|
||||
*/
|
||||
private data class CallerPackageInfo(
|
||||
val name: String,
|
||||
val packageName: String,
|
||||
val uid: Int,
|
||||
val signature: String?,
|
||||
val permissions: Set<String>
|
||||
)
|
||||
}
|
||||
|
||||
private const val TAG = "PackageValidator"
|
||||
private const val ANDROID_PLATFORM = "android"
|
|
@ -38,10 +38,10 @@ public class MediaSessionPlayerUi extends PlayerUi
|
|||
implements SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
private static final String TAG = "MediaSessUi";
|
||||
|
||||
@Nullable
|
||||
private MediaSessionCompat mediaSession;
|
||||
@Nullable
|
||||
private MediaSessionConnector sessionConnector;
|
||||
@NonNull
|
||||
private final MediaSessionCompat mediaSession;
|
||||
@NonNull
|
||||
private final MediaSessionConnector sessionConnector;
|
||||
|
||||
private final String ignoreHardwareMediaButtonsKey;
|
||||
private boolean shouldIgnoreHardwareMediaButtons = false;
|
||||
|
@ -50,9 +50,13 @@ public class MediaSessionPlayerUi extends PlayerUi
|
|||
private List<NotificationActionData> prevNotificationActions = List.of();
|
||||
|
||||
|
||||
public MediaSessionPlayerUi(@NonNull final Player player) {
|
||||
public MediaSessionPlayerUi(@NonNull final Player player,
|
||||
@NonNull final MediaSessionCompat mediaSession,
|
||||
@NonNull final MediaSessionConnector sessionConnector) {
|
||||
super(player);
|
||||
ignoreHardwareMediaButtonsKey =
|
||||
this.mediaSession = mediaSession;
|
||||
this.sessionConnector = sessionConnector;
|
||||
this.ignoreHardwareMediaButtonsKey =
|
||||
context.getString(R.string.ignore_hardware_media_buttons_key);
|
||||
}
|
||||
|
||||
|
@ -61,10 +65,8 @@ public class MediaSessionPlayerUi extends PlayerUi
|
|||
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());
|
||||
|
||||
|
@ -89,27 +91,18 @@ public class MediaSessionPlayerUi extends PlayerUi
|
|||
public void destroyPlayer() {
|
||||
super.destroyPlayer();
|
||||
player.getPrefs().unregisterOnSharedPreferenceChangeListener(this);
|
||||
if (sessionConnector != null) {
|
||||
sessionConnector.setMediaButtonEventHandler(null);
|
||||
sessionConnector.setPlayer(null);
|
||||
sessionConnector.setQueueNavigator(null);
|
||||
sessionConnector = null;
|
||||
}
|
||||
if (mediaSession != null) {
|
||||
mediaSession.setActive(false);
|
||||
mediaSession.release();
|
||||
mediaSession = null;
|
||||
}
|
||||
sessionConnector.setMediaButtonEventHandler(null);
|
||||
sessionConnector.setPlayer(null);
|
||||
sessionConnector.setQueueNavigator(null);
|
||||
mediaSession.setActive(false);
|
||||
prevNotificationActions = List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onThumbnailLoaded(@Nullable final Bitmap bitmap) {
|
||||
super.onThumbnailLoaded(bitmap);
|
||||
if (sessionConnector != null) {
|
||||
// the thumbnail is now loaded: invalidate the metadata to trigger a metadata update
|
||||
sessionConnector.invalidateMediaSessionMetadata();
|
||||
}
|
||||
// the thumbnail is now loaded: invalidate the metadata to trigger a metadata update
|
||||
sessionConnector.invalidateMediaSessionMetadata();
|
||||
}
|
||||
|
||||
|
||||
|
@ -200,8 +193,8 @@ public class MediaSessionPlayerUi extends PlayerUi
|
|||
return;
|
||||
}
|
||||
|
||||
if (sessionConnector == null) {
|
||||
// sessionConnector will be null after destroyPlayer is called
|
||||
if (!mediaSession.isActive()) {
|
||||
// mediaSession will be inactive after destroyPlayer is called
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
|
@ -5,7 +5,9 @@ import static androidx.media.app.NotificationCompat.MediaStyle;
|
|||
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Notification;
|
||||
import android.app.PendingIntent;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.ServiceInfo;
|
||||
import android.graphics.Bitmap;
|
||||
|
@ -23,6 +25,8 @@ 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.PlayerIntentType;
|
||||
import org.schabi.newpipe.player.PlayerService;
|
||||
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
|
||||
|
@ -89,12 +93,9 @@ public final class NotificationUtil {
|
|||
Log.d(TAG, "createNotification()");
|
||||
}
|
||||
notificationManager = NotificationManagerCompat.from(player.getContext());
|
||||
final NotificationCompat.Builder builder =
|
||||
new NotificationCompat.Builder(player.getContext(),
|
||||
player.getContext().getString(R.string.notification_channel_id));
|
||||
final MediaStyle mediaStyle = new MediaStyle();
|
||||
|
||||
// setup media style (compact notification slots and media session)
|
||||
final MediaStyle mediaStyle = new MediaStyle();
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
|
||||
// notification actions are ignored on Android 13+, and are replaced by code in
|
||||
// MediaSessionPlayerUi
|
||||
|
@ -107,18 +108,9 @@ public final class NotificationUtil {
|
|||
.ifPresent(mediaStyle::setMediaSession);
|
||||
|
||||
// setup notification builder
|
||||
builder.setStyle(mediaStyle)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
||||
.setShowWhen(false)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setColor(ContextCompat.getColor(player.getContext(),
|
||||
R.color.dark_background_color))
|
||||
final var builder = setupNotificationBuilder(player.getContext(), mediaStyle)
|
||||
.setColorized(player.getPrefs().getBoolean(
|
||||
player.getContext().getString(R.string.notification_colorize_key), true))
|
||||
.setDeleteIntent(PendingIntentCompat.getBroadcast(player.getContext(),
|
||||
NOTIFICATION_ID, new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT, false));
|
||||
player.getContext().getString(R.string.notification_colorize_key), true));
|
||||
|
||||
// set the initial value for the video thumbnail, updatable with updateNotificationThumbnail
|
||||
setLargeIcon(builder);
|
||||
|
@ -167,19 +159,17 @@ public final class NotificationUtil {
|
|||
&& notificationBuilder.mActions.get(2).actionIntent != null);
|
||||
}
|
||||
|
||||
public static void startForegroundWithDummyNotification(final PlayerService service) {
|
||||
final var builder = setupNotificationBuilder(service, new MediaStyle());
|
||||
startForeground(service, builder.build());
|
||||
}
|
||||
|
||||
public void createNotificationAndStartForeground() {
|
||||
if (notificationBuilder == null) {
|
||||
notificationBuilder = createNotification();
|
||||
}
|
||||
updateNotification();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build(),
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
|
||||
} else {
|
||||
player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build());
|
||||
}
|
||||
startForeground(player.getService(), notificationBuilder.build());
|
||||
}
|
||||
|
||||
public void cancelNotificationAndStopForeground() {
|
||||
|
@ -193,6 +183,34 @@ public final class NotificationUtil {
|
|||
}
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////
|
||||
// STATIC FUNCTIONS IN COMMON BETWEEN DUMMY AND REAL NOTIFICATION
|
||||
/////////////////////////////////////////////////////
|
||||
|
||||
private static NotificationCompat.Builder setupNotificationBuilder(final Context context,
|
||||
final MediaStyle style) {
|
||||
return new NotificationCompat.Builder(context,
|
||||
context.getString(R.string.notification_channel_id))
|
||||
.setStyle(style)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
|
||||
.setShowWhen(false)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setColor(ContextCompat.getColor(context, R.color.dark_background_color))
|
||||
.setDeleteIntent(PendingIntentCompat.getBroadcast(context,
|
||||
NOTIFICATION_ID, new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT, false));
|
||||
}
|
||||
|
||||
private static void startForeground(final PlayerService service,
|
||||
final Notification notification) {
|
||||
// ServiceInfo constants are not used below Android Q, so 0 is set here
|
||||
final int serviceType = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
? ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK : 0;
|
||||
ServiceCompat.startForeground(service, NOTIFICATION_ID, notification, serviceType);
|
||||
}
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////
|
||||
// ACTIONS
|
||||
/////////////////////////////////////////////////////
|
||||
|
@ -256,7 +274,9 @@ public final class NotificationUtil {
|
|||
} else {
|
||||
// We are playing in fragment. Don't open another activity just show fragment. That's it
|
||||
final Intent intent = NavigationHelper.getPlayerIntent(
|
||||
player.getContext(), MainActivity.class, null, true);
|
||||
player.getContext(), MainActivity.class, null,
|
||||
PlayerIntentType.AllOthers);
|
||||
intent.putExtra(Player.RESUME_PLAYBACK, true);
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
intent.setAction(Intent.ACTION_MAIN);
|
||||
intent.addCategory(Intent.CATEGORY_LAUNCHER);
|
||||
|
|
|
@ -3,6 +3,7 @@ package org.schabi.newpipe.player.playqueue;
|
|||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
|
@ -23,18 +24,23 @@ abstract class AbstractInfoPlayQueue<T extends ListInfo<? extends InfoItem>>
|
|||
|
||||
final int serviceId;
|
||||
final String baseUrl;
|
||||
@Nullable
|
||||
Page nextPage;
|
||||
|
||||
private transient Disposable fetchReactor;
|
||||
|
||||
protected AbstractInfoPlayQueue(final T info) {
|
||||
this(info, 0);
|
||||
}
|
||||
|
||||
protected AbstractInfoPlayQueue(final T info, final int index) {
|
||||
this(info.getServiceId(), info.getUrl(), info.getNextPage(),
|
||||
info.getRelatedItems()
|
||||
.stream()
|
||||
.filter(StreamInfoItem.class::isInstance)
|
||||
.map(StreamInfoItem.class::cast)
|
||||
.collect(Collectors.toList()),
|
||||
0);
|
||||
index);
|
||||
}
|
||||
|
||||
protected AbstractInfoPlayQueue(final int serviceId,
|
||||
|
|
|
@ -291,6 +291,22 @@ public abstract class PlayQueue implements Serializable {
|
|||
broadcast(new AppendEvent(itemList.size()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the given item after the current stream.
|
||||
*
|
||||
* @param item item to add.
|
||||
* @param skipIfSame if set, skip adding if the next stream is the same stream.
|
||||
*/
|
||||
public void enqueueNext(@NonNull final PlayQueueItem item, final boolean skipIfSame) {
|
||||
final int currentIndex = getIndex();
|
||||
// if the next item is the same item as the one we want to enqueue, skip if flag is true
|
||||
if (skipIfSame && item.isSameItem(getItem(currentIndex + 1))) {
|
||||
return;
|
||||
}
|
||||
append(List.of(item));
|
||||
move(size() - 1, currentIndex + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the item at the given index from the play queue.
|
||||
* <p>
|
||||
|
@ -529,8 +545,7 @@ public abstract class PlayQueue implements Serializable {
|
|||
final PlayQueueItem stream = streams.get(i);
|
||||
final PlayQueueItem otherStream = other.streams.get(i);
|
||||
// Check is based on serviceId and URL
|
||||
if (stream.getServiceId() != otherStream.getServiceId()
|
||||
|| !stream.getUrl().equals(otherStream.getUrl())) {
|
||||
if (!stream.isSameItem(otherStream)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,7 +38,7 @@ public class PlayQueueItem implements Serializable {
|
|||
private long recoveryPosition;
|
||||
private Throwable error;
|
||||
|
||||
PlayQueueItem(@NonNull final StreamInfo info) {
|
||||
public PlayQueueItem(@NonNull final StreamInfo info) {
|
||||
this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(),
|
||||
info.getThumbnails(), info.getUploaderName(),
|
||||
info.getUploaderUrl(), info.getStreamType());
|
||||
|
@ -71,6 +71,22 @@ public class PlayQueueItem implements Serializable {
|
|||
this.recoveryPosition = RECOVERY_UNSET;
|
||||
}
|
||||
|
||||
/** Whether these two items should be treated as the same stream
|
||||
* for the sake of keeping the same player running when e.g. jumping between timestamps.
|
||||
*
|
||||
* @param other the {@link PlayQueueItem} to compare against.
|
||||
* @return whether the two items are the same so the stream can be re-used.
|
||||
*/
|
||||
public boolean isSameItem(@Nullable final PlayQueueItem other) {
|
||||
if (other == null) {
|
||||
return false;
|
||||
}
|
||||
// We assume that the same service & URL uniquely determines
|
||||
// that we can keep the same stream running.
|
||||
return serviceId == other.serviceId
|
||||
&& url.equals(other.url);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public String getTitle() {
|
||||
return title;
|
||||
|
|
|
@ -16,6 +16,10 @@ public final class PlaylistPlayQueue extends AbstractInfoPlayQueue<PlaylistInfo>
|
|||
super(info);
|
||||
}
|
||||
|
||||
public PlaylistPlayQueue(final PlaylistInfo info, final int index) {
|
||||
super(info, index);
|
||||
}
|
||||
|
||||
public PlaylistPlayQueue(final int serviceId,
|
||||
final String url,
|
||||
final Page nextPage,
|
||||
|
|
|
@ -16,7 +16,9 @@ public final class SinglePlayQueue extends PlayQueue {
|
|||
public SinglePlayQueue(final StreamInfo info) {
|
||||
super(0, List.of(new PlayQueueItem(info)));
|
||||
}
|
||||
|
||||
public SinglePlayQueue(final PlayQueueItem item) {
|
||||
super(0, List.of(item));
|
||||
}
|
||||
public SinglePlayQueue(final StreamInfo info, final long startPosition) {
|
||||
super(0, List.of(new PlayQueueItem(info)));
|
||||
getItem().setRecoveryPosition(startPosition);
|
||||
|
|
|
@ -25,9 +25,7 @@ import android.graphics.Color;
|
|||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.provider.Settings;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.util.Log;
|
||||
import android.util.TypedValue;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
|
@ -44,6 +42,7 @@ import androidx.fragment.app.FragmentActivity;
|
|||
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import com.google.android.exoplayer2.ui.SubtitleView;
|
||||
import com.google.android.exoplayer2.video.VideoSize;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
|
@ -522,11 +521,8 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
|||
|
||||
@Override
|
||||
protected void setupSubtitleView(final float captionScale) {
|
||||
final DisplayMetrics metrics = context.getResources().getDisplayMetrics();
|
||||
final int minimumLength = Math.min(metrics.heightPixels, metrics.widthPixels);
|
||||
final float captionRatioInverse = 20f + 4f * (1.0f - captionScale);
|
||||
binding.subtitleView.setFixedTextSize(
|
||||
TypedValue.COMPLEX_UNIT_PX, minimumLength / captionRatioInverse);
|
||||
binding.subtitleView.setFractionalTextSize(
|
||||
SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * captionScale);
|
||||
}
|
||||
//endregion
|
||||
|
||||
|
|
|
@ -382,7 +382,7 @@ public final class PopupPlayerUi extends VideoPlayerUi {
|
|||
private void end() {
|
||||
windowManager.removeView(closeOverlayBinding.getRoot());
|
||||
closeOverlayBinding = null;
|
||||
player.getService().stopService();
|
||||
player.getService().destroyPlayerAndStopService();
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
@ -424,9 +424,8 @@ public final class PopupPlayerUi extends VideoPlayerUi {
|
|||
|
||||
@Override
|
||||
protected void setupSubtitleView(final float captionScale) {
|
||||
final float captionRatio = (captionScale - 1.0f) / 5.0f + 1.0f;
|
||||
binding.subtitleView.setFractionalTextSize(
|
||||
SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * captionRatio);
|
||||
SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * captionScale);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
|
@ -1414,6 +1414,10 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
|
|||
binding.subtitleView.setStyle(captionStyle);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param captionScale Value returned by {@link PlayerHelper#getCaptionScale}.
|
||||
*/
|
||||
protected abstract void setupSubtitleView(float captionScale);
|
||||
//endregion
|
||||
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package org.schabi.newpipe.settings;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
|
@ -126,7 +125,6 @@ public class BackupRestoreSettingsFragment extends BasePreferenceFragment {
|
|||
}
|
||||
|
||||
private void requestExportPathResult(final ActivityResult result) {
|
||||
assureCorrectAppLanguage(requireContext());
|
||||
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
|
||||
// will be saved only on success
|
||||
final Uri lastExportDataUri = result.getData().getData();
|
||||
|
@ -139,7 +137,6 @@ public class BackupRestoreSettingsFragment extends BasePreferenceFragment {
|
|||
}
|
||||
|
||||
private void requestImportPathResult(final ActivityResult result) {
|
||||
assureCorrectAppLanguage(requireContext());
|
||||
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
|
||||
// will be saved only on success
|
||||
final Uri lastImportDataUri = result.getData().getData();
|
||||
|
|
|
@ -1,57 +1,89 @@
|
|||
package org.schabi.newpipe.settings;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.core.os.LocaleListCompat;
|
||||
import androidx.preference.Preference;
|
||||
|
||||
import org.schabi.newpipe.DownloaderImpl;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.localization.ContentCountry;
|
||||
import org.schabi.newpipe.extractor.localization.Localization;
|
||||
import org.schabi.newpipe.player.helper.PlayerHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.image.PreferredImageQuality;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
|
||||
public class ContentSettingsFragment extends BasePreferenceFragment {
|
||||
private String youtubeRestrictedModeEnabledKey;
|
||||
|
||||
private Localization initialSelectedLocalization;
|
||||
private ContentCountry initialSelectedContentCountry;
|
||||
private String initialLanguage;
|
||||
|
||||
@Override
|
||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||
youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled);
|
||||
|
||||
addPreferencesFromResourceRegistry();
|
||||
|
||||
initialSelectedLocalization = org.schabi.newpipe.util.Localization
|
||||
.getPreferredLocalization(requireContext());
|
||||
initialSelectedContentCountry = org.schabi.newpipe.util.Localization
|
||||
.getPreferredContentCountry(requireContext());
|
||||
initialLanguage = defaultPreferences.getString(getString(R.string.app_language_key), "en");
|
||||
setupAppLanguagePreferences();
|
||||
setupImageQualityPref();
|
||||
}
|
||||
|
||||
final Preference imageQualityPreference = requirePreference(R.string.image_quality_key);
|
||||
imageQualityPreference.setOnPreferenceChangeListener(
|
||||
(preference, newValue) -> {
|
||||
ImageStrategy.setPreferredImageQuality(PreferredImageQuality
|
||||
.fromPreferenceKey(requireContext(), (String) newValue));
|
||||
try {
|
||||
PicassoHelper.clearCache(preference.getContext());
|
||||
Toast.makeText(preference.getContext(),
|
||||
R.string.thumbnail_cache_wipe_complete_notice, Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
} catch (final IOException e) {
|
||||
Log.e(TAG, "Unable to clear Picasso cache", e);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
private void setupAppLanguagePreferences() {
|
||||
final Preference appLanguagePref = requirePreference(R.string.app_language_key);
|
||||
// Android 13+ allows to set app specific languages
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
appLanguagePref.setVisible(false);
|
||||
|
||||
final Preference newAppLanguagePref =
|
||||
requirePreference(R.string.app_language_android_13_and_up_key);
|
||||
newAppLanguagePref.setSummaryProvider(preference -> {
|
||||
final Locale loc = AppCompatDelegate.getApplicationLocales().get(0);
|
||||
return loc != null ? loc.getDisplayName() : getString(R.string.systems_language);
|
||||
});
|
||||
newAppLanguagePref.setOnPreferenceClickListener(preference -> {
|
||||
final Intent intent = new Intent(Settings.ACTION_APP_LOCALE_SETTINGS)
|
||||
.setData(Uri.fromParts("package", requireContext().getPackageName(), null));
|
||||
startActivity(intent);
|
||||
return true;
|
||||
});
|
||||
newAppLanguagePref.setVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
appLanguagePref.setOnPreferenceChangeListener((preference, newValue) -> {
|
||||
final String language = (String) newValue;
|
||||
final String systemLang = getString(R.string.default_localization_key);
|
||||
final String tag = systemLang.equals(language) ? null : language;
|
||||
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(tag));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private void setupImageQualityPref() {
|
||||
requirePreference(R.string.image_quality_key).setOnPreferenceChangeListener(
|
||||
(preference, newValue) -> {
|
||||
ImageStrategy.setPreferredImageQuality(PreferredImageQuality
|
||||
.fromPreferenceKey(requireContext(), (String) newValue));
|
||||
try {
|
||||
PicassoHelper.clearCache(preference.getContext());
|
||||
Toast.makeText(preference.getContext(),
|
||||
R.string.thumbnail_cache_wipe_complete_notice, Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
} catch (final IOException e) {
|
||||
Log.e(TAG, "Unable to clear Picasso cache", e);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -72,20 +104,10 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
|
|||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
|
||||
final Localization selectedLocalization = org.schabi.newpipe.util.Localization
|
||||
.getPreferredLocalization(requireContext());
|
||||
final ContentCountry selectedContentCountry = org.schabi.newpipe.util.Localization
|
||||
.getPreferredContentCountry(requireContext());
|
||||
final String selectedLanguage =
|
||||
defaultPreferences.getString(getString(R.string.app_language_key), "en");
|
||||
|
||||
if (!selectedLocalization.equals(initialSelectedLocalization)
|
||||
|| !selectedContentCountry.equals(initialSelectedContentCountry)
|
||||
|| !selectedLanguage.equals(initialLanguage)) {
|
||||
Toast.makeText(requireContext(), R.string.localization_changes_requires_app_restart,
|
||||
Toast.LENGTH_LONG).show();
|
||||
|
||||
NewPipe.setupLocalization(selectedLocalization, selectedContentCountry);
|
||||
}
|
||||
final Context context = requireContext();
|
||||
NewPipe.setupLocalization(
|
||||
Localization.getPreferredLocalization(context),
|
||||
Localization.getPreferredContentCountry(context));
|
||||
PlayerHelper.resetFormat();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
package org.schabi.newpipe.settings;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
|
@ -209,8 +207,6 @@ public class DownloadSettingsFragment extends BasePreferenceFragment {
|
|||
}
|
||||
|
||||
private void requestDownloadPathResult(final ActivityResult result, final String key) {
|
||||
assureCorrectAppLanguage(getContext());
|
||||
|
||||
if (result.getResultCode() != Activity.RESULT_OK) {
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import androidx.preference.PreferenceManager;
|
|||
|
||||
import org.schabi.newpipe.App;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.settings.migration.MigrationManager;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
|
||||
import java.io.File;
|
||||
|
@ -46,7 +47,7 @@ public final class NewPipeSettings {
|
|||
|
||||
public static void initSettings(final Context context) {
|
||||
// first run migrations, then setDefaultValues, since the latter requires the correct types
|
||||
SettingMigrations.runMigrationsIfNeeded(context);
|
||||
MigrationManager.runMigrationsIfNeeded(context);
|
||||
|
||||
// readAgain is true so that if new settings are added their default value is set
|
||||
PreferenceManager.setDefaultValues(context, R.xml.main_settings, true);
|
||||
|
@ -102,12 +103,12 @@ public final class NewPipeSettings {
|
|||
}
|
||||
|
||||
public static boolean useStorageAccessFramework(final Context context) {
|
||||
// There's a FireOS bug which prevents SAF open/close dialogs from being confirmed with a
|
||||
// remote (see #6455).
|
||||
if (DeviceUtils.isFireTv()) {
|
||||
return false;
|
||||
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
return true;
|
||||
} else if (DeviceUtils.isFireTv()) {
|
||||
// There's a FireOS bug which prevents SAF open/close dialogs from being confirmed with
|
||||
// a remote (see #6455).
|
||||
return false;
|
||||
}
|
||||
|
||||
final String key = context.getString(R.string.storage_use_saf);
|
||||
|
|
|
@ -0,0 +1,214 @@
|
|||
package org.schabi.newpipe.settings;
|
||||
|
||||
import android.content.DialogInterface;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.AppDatabase;
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Vector;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Observer;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
/**
|
||||
* Created by Christian Schabesberger on 26.09.17.
|
||||
* SelectChannelFragment.java is part of NewPipe.
|
||||
* <p>
|
||||
* NewPipe is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
* </p>
|
||||
* <p>
|
||||
* NewPipe is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
* </p>
|
||||
* <p>
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||
* </p>
|
||||
*/
|
||||
|
||||
public class SelectFeedGroupFragment extends DialogFragment {
|
||||
|
||||
private OnSelectedListener onSelectedListener = null;
|
||||
private OnCancelListener onCancelListener = null;
|
||||
|
||||
private ProgressBar progressBar;
|
||||
private TextView emptyView;
|
||||
private RecyclerView recyclerView;
|
||||
|
||||
private List<FeedGroupEntity> feedGroups = new Vector<>();
|
||||
|
||||
public void setOnSelectedListener(final OnSelectedListener listener) {
|
||||
onSelectedListener = listener;
|
||||
}
|
||||
|
||||
public void setOnCancelListener(final OnCancelListener listener) {
|
||||
onCancelListener = listener;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Init
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable final Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container,
|
||||
final Bundle savedInstanceState) {
|
||||
final View v = inflater.inflate(R.layout.select_feed_group_fragment, container, false);
|
||||
recyclerView = v.findViewById(R.id.items_list);
|
||||
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
|
||||
final SelectFeedGroupAdapter feedGroupAdapter = new SelectFeedGroupAdapter();
|
||||
recyclerView.setAdapter(feedGroupAdapter);
|
||||
|
||||
progressBar = v.findViewById(R.id.progressBar);
|
||||
emptyView = v.findViewById(R.id.empty_state_view);
|
||||
progressBar.setVisibility(View.VISIBLE);
|
||||
recyclerView.setVisibility(View.GONE);
|
||||
emptyView.setVisibility(View.GONE);
|
||||
|
||||
|
||||
final AppDatabase database = NewPipeDatabase.getInstance(requireContext());
|
||||
database.feedGroupDAO().getAll().toObservable()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(getFeedGroupObserver());
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Handle actions
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCancel(@NonNull final DialogInterface dialogInterface) {
|
||||
super.onCancel(dialogInterface);
|
||||
if (onCancelListener != null) {
|
||||
onCancelListener.onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
private void clickedItem(final int position) {
|
||||
if (onSelectedListener != null) {
|
||||
final FeedGroupEntity entry = feedGroups.get(position);
|
||||
onSelectedListener
|
||||
.onFeedGroupSelected(entry.getUid(), entry.getName(),
|
||||
entry.getIcon().getDrawableResource());
|
||||
}
|
||||
dismiss();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Item handling
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private void displayFeedGroups(final List<FeedGroupEntity> newFeedGroups) {
|
||||
this.feedGroups = newFeedGroups;
|
||||
progressBar.setVisibility(View.GONE);
|
||||
if (newFeedGroups.isEmpty()) {
|
||||
emptyView.setVisibility(View.VISIBLE);
|
||||
return;
|
||||
}
|
||||
recyclerView.setVisibility(View.VISIBLE);
|
||||
|
||||
}
|
||||
|
||||
private Observer<List<FeedGroupEntity>> getFeedGroupObserver() {
|
||||
return new Observer<List<FeedGroupEntity>>() {
|
||||
@Override
|
||||
public void onSubscribe(@NonNull final Disposable disposable) { }
|
||||
|
||||
@Override
|
||||
public void onNext(@NonNull final List<FeedGroupEntity> newGroups) {
|
||||
displayFeedGroups(newGroups);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@NonNull final Throwable exception) {
|
||||
ErrorUtil.showUiErrorSnackbar(SelectFeedGroupFragment.this,
|
||||
"Loading Feed Groups", exception);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() { }
|
||||
};
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Interfaces
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
public interface OnSelectedListener {
|
||||
void onFeedGroupSelected(Long groupId, String name, int icon);
|
||||
}
|
||||
|
||||
public interface OnCancelListener {
|
||||
void onCancel();
|
||||
}
|
||||
|
||||
private class SelectFeedGroupAdapter
|
||||
extends RecyclerView.Adapter<SelectFeedGroupAdapter.SelectFeedGroupItemHolder> {
|
||||
@NonNull
|
||||
@Override
|
||||
public SelectFeedGroupItemHolder onCreateViewHolder(final ViewGroup parent,
|
||||
final int viewType) {
|
||||
final View item = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.select_feed_group_item, parent, false);
|
||||
return new SelectFeedGroupItemHolder(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(final SelectFeedGroupItemHolder holder, final int position) {
|
||||
final FeedGroupEntity entry = feedGroups.get(position);
|
||||
holder.titleView.setText(entry.getName());
|
||||
holder.view.setOnClickListener(view -> clickedItem(position));
|
||||
holder.thumbnailView.setImageResource(entry.getIcon().getDrawableResource());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return feedGroups.size();
|
||||
}
|
||||
|
||||
public class SelectFeedGroupItemHolder extends RecyclerView.ViewHolder {
|
||||
public final View view;
|
||||
final ImageView thumbnailView;
|
||||
final TextView titleView;
|
||||
SelectFeedGroupItemHolder(final View v) {
|
||||
super(v);
|
||||
this.view = v;
|
||||
thumbnailView = v.findViewById(R.id.itemThumbnailView);
|
||||
titleView = v.findViewById(R.id.itemTitleView);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,7 +1,5 @@
|
|||
package org.schabi.newpipe.settings;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
|
@ -89,7 +87,6 @@ public class SettingsActivity extends AppCompatActivity implements
|
|||
@Override
|
||||
protected void onCreate(final Bundle savedInstanceBundle) {
|
||||
setTheme(ThemeHelper.getSettingsThemeStyle(this));
|
||||
assureCorrectAppLanguage(this);
|
||||
|
||||
super.onCreate(savedInstanceBundle);
|
||||
Bridge.restoreInstanceState(this, savedInstanceBundle);
|
||||
|
@ -228,7 +225,6 @@ public class SettingsActivity extends AppCompatActivity implements
|
|||
|
||||
// Build search items
|
||||
final Context searchContext = getApplicationContext();
|
||||
assureCorrectAppLanguage(searchContext);
|
||||
final PreferenceParser parser = new PreferenceParser(searchContext, config);
|
||||
final PreferenceSearcher searcher = new PreferenceSearcher(config);
|
||||
|
||||
|
|
|
@ -0,0 +1,103 @@
|
|||
package org.schabi.newpipe.settings.migration;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.util.Consumer;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MigrationManager is responsible for running migrations and showing the user information about
|
||||
* the migrations that were applied.
|
||||
*/
|
||||
public final class MigrationManager {
|
||||
|
||||
private static final String TAG = MigrationManager.class.getSimpleName();
|
||||
/**
|
||||
* List of UI actions that are performed after the UI is initialized (e.g. showing alert
|
||||
* dialogs) to inform the user about changes that were applied by migrations.
|
||||
*/
|
||||
private static final List<Consumer<Context>> MIGRATION_INFO = new ArrayList<>();
|
||||
|
||||
private MigrationManager() {
|
||||
// MigrationManager is a utility class that is completely static
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all migrations that are needed for the current version of NewPipe.
|
||||
* This method should be called at the start of the application, before any other operations
|
||||
* that depend on the settings.
|
||||
*
|
||||
* @param context Context that can be used to run migrations
|
||||
*/
|
||||
public static void runMigrationsIfNeeded(@NonNull final Context context) {
|
||||
SettingMigrations.runMigrationsIfNeeded(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform UI actions informing about migrations that took place if they are present.
|
||||
* @param context Context that can be used to show dialogs/snackbars/toasts
|
||||
*/
|
||||
public static void showUserInfoIfPresent(@NonNull final Context context) {
|
||||
if (MIGRATION_INFO.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
MIGRATION_INFO.get(0).accept(context);
|
||||
} catch (final Exception e) {
|
||||
ErrorUtil.showUiErrorSnackbar(context, "Showing migration info to the user", e);
|
||||
// Remove the migration that caused the error and continue with the next one
|
||||
MIGRATION_INFO.remove(0);
|
||||
showUserInfoIfPresent(context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a migration info action that will be executed after the UI is initialized.
|
||||
* This can be used to show dialogs/snackbars/toasts to inform the user about changes that
|
||||
* were applied by migrations.
|
||||
*
|
||||
* @param info the action to be executed
|
||||
*/
|
||||
public static void addMigrationInfo(final Consumer<Context> info) {
|
||||
MIGRATION_INFO.add(info);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method should be called when the user dismisses the migration info
|
||||
* to check if there are any more migration info actions to be shown.
|
||||
* @param context Context that can be used to show dialogs/snackbars/toasts
|
||||
*/
|
||||
public static void onMigrationInfoDismissed(@NonNull final Context context) {
|
||||
MIGRATION_INFO.remove(0);
|
||||
showUserInfoIfPresent(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dialog to inform the user about the migration.
|
||||
* @param uiContext Context that can be used to show dialogs/snackbars/toasts
|
||||
* @param title the title of the dialog
|
||||
* @param message the message of the dialog
|
||||
* @return the dialog that can be shown to the user with a custom dismiss listener
|
||||
*/
|
||||
static AlertDialog createMigrationInfoDialog(@NonNull final Context uiContext,
|
||||
@NonNull final String title,
|
||||
@NonNull final String message) {
|
||||
return new AlertDialog.Builder(uiContext)
|
||||
.setTitle(title)
|
||||
.setMessage(message)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.setOnDismissListener(dialog ->
|
||||
MigrationManager.onMigrationInfoDismissed(uiContext))
|
||||
.setCancelable(false) // prevents the dialog from being dismissed accidentally
|
||||
.create();
|
||||
}
|
||||
|
||||
}
|
|
@ -1,10 +1,15 @@
|
|||
package org.schabi.newpipe.settings;
|
||||
package org.schabi.newpipe.settings.migration;
|
||||
|
||||
import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||
import static org.schabi.newpipe.extractor.ServiceList.SoundCloud;
|
||||
import static org.schabi.newpipe.extractor.ServiceList.YouTube;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.util.Consumer;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.schabi.newpipe.App;
|
||||
|
@ -12,20 +17,32 @@ import org.schabi.newpipe.R;
|
|||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.settings.tabs.Tab;
|
||||
import org.schabi.newpipe.settings.tabs.TabsManager;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* In order to add a migration, follow these steps, given P is the previous version:<br>
|
||||
* - in the class body add a new {@code MIGRATION_P_P+1 = new Migration(P, P+1) { ... }} and put in
|
||||
* the {@code migrate()} method the code that need to be run when migrating from P to P+1<br>
|
||||
* - add {@code MIGRATION_P_P+1} at the end of {@link SettingMigrations#SETTING_MIGRATIONS}<br>
|
||||
* - increment {@link SettingMigrations#VERSION}'s value by 1 (so it should become P+1)
|
||||
* This class contains the code to migrate the settings from one version to another.
|
||||
* Migrations are run automatically when the app is started and the settings version changed.
|
||||
* <br>
|
||||
* In order to add a migration, follow these steps, given {@code P} is the previous version:
|
||||
* <ul>
|
||||
* <li>in the class body add a new {@code MIGRATION_P_P+1 = new Migration(P, P+1) { ... }} and put
|
||||
* in the {@code migrate()} method the code that need to be run
|
||||
* when migrating from {@code P} to {@code P+1}</li>
|
||||
* <li>add {@code MIGRATION_P_P+1} at the end of {@link SettingMigrations#SETTING_MIGRATIONS}</li>
|
||||
* <li>increment {@link SettingMigrations#VERSION}'s value by 1
|
||||
* (so it becomes {@code P+1})</li>
|
||||
* </ul>
|
||||
* Migrations can register UI actions using {@link MigrationManager#addMigrationInfo(Consumer)}
|
||||
* that will be performed after the UI is initialized to inform the user about changes
|
||||
* that were applied by migrations.
|
||||
*/
|
||||
public final class SettingMigrations {
|
||||
|
||||
|
@ -129,7 +146,7 @@ public final class SettingMigrations {
|
|||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_5_6 = new Migration(5, 6) {
|
||||
private static final Migration MIGRATION_5_6 = new Migration(5, 6) {
|
||||
@Override
|
||||
protected void migrate(@NonNull final Context context) {
|
||||
final boolean loadImages = sp.getBoolean("download_thumbnail_key", true);
|
||||
|
@ -143,6 +160,67 @@ public final class SettingMigrations {
|
|||
}
|
||||
};
|
||||
|
||||
private static final Migration MIGRATION_6_7 = new Migration(6, 7) {
|
||||
@Override
|
||||
protected void migrate(@NonNull final Context context) {
|
||||
// The SoundCloud Top 50 Kiosk was removed in the extractor,
|
||||
// so we remove the corresponding tab if it exists.
|
||||
final TabsManager tabsManager = TabsManager.getManager(context);
|
||||
final List<Tab> tabs = tabsManager.getTabs();
|
||||
final List<Tab> cleanedTabs = tabs.stream()
|
||||
.filter(tab -> !(tab instanceof Tab.KioskTab kioskTab
|
||||
&& kioskTab.getKioskServiceId() == SoundCloud.getServiceId()
|
||||
&& kioskTab.getKioskId().equals("Top 50")))
|
||||
.collect(Collectors.toUnmodifiableList());
|
||||
if (tabs.size() != cleanedTabs.size()) {
|
||||
tabsManager.saveTabs(cleanedTabs);
|
||||
// create an AlertDialog to inform the user about the change
|
||||
MigrationManager.addMigrationInfo(uiContext ->
|
||||
MigrationManager.createMigrationInfoDialog(
|
||||
uiContext,
|
||||
uiContext.getString(R.string.migration_info_6_7_title),
|
||||
uiContext.getString(R.string.migration_info_6_7_message))
|
||||
.show());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private static final Migration MIGRATION_7_8 = new Migration(7, 8) {
|
||||
@Override
|
||||
protected void migrate(@NonNull final Context context) {
|
||||
// YouTube remove the combined Trending kiosk, see
|
||||
// https://github.com/TeamNewPipe/NewPipe/discussions/12445 for more information.
|
||||
// If the user has a dedicated YouTube/Trending kiosk tab,
|
||||
// it is removed and replaced with the new live kiosk tab.
|
||||
// The default trending kiosk tab is not touched
|
||||
// because it uses the default kiosk provided by the extractor
|
||||
// and is thus updated automatically.
|
||||
final TabsManager tabsManager = TabsManager.getManager(context);
|
||||
final List<Tab> tabs = tabsManager.getTabs();
|
||||
final List<Tab> cleanedTabs = tabs.stream()
|
||||
.filter(tab -> !(tab instanceof Tab.KioskTab kioskTab
|
||||
&& kioskTab.getKioskServiceId() == YouTube.getServiceId()
|
||||
&& kioskTab.getKioskId().equals("Trending")))
|
||||
.collect(Collectors.toUnmodifiableList());
|
||||
if (tabs.size() != cleanedTabs.size()) {
|
||||
tabsManager.saveTabs(cleanedTabs);
|
||||
}
|
||||
|
||||
final boolean hasDefaultTrendingTab = tabs.stream()
|
||||
.anyMatch(tab -> tab instanceof Tab.DefaultKioskTab);
|
||||
|
||||
if (tabs.size() != cleanedTabs.size() || hasDefaultTrendingTab) {
|
||||
// User is informed about the change
|
||||
MigrationManager.addMigrationInfo(uiContext ->
|
||||
MigrationManager.createMigrationInfoDialog(
|
||||
uiContext,
|
||||
uiContext.getString(R.string.migration_info_7_8_title),
|
||||
uiContext.getString(R.string.migration_info_7_8_message))
|
||||
.show());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* List of all implemented migrations.
|
||||
* <p>
|
||||
|
@ -156,15 +234,17 @@ public final class SettingMigrations {
|
|||
MIGRATION_3_4,
|
||||
MIGRATION_4_5,
|
||||
MIGRATION_5_6,
|
||||
MIGRATION_6_7,
|
||||
MIGRATION_7_8,
|
||||
};
|
||||
|
||||
/**
|
||||
* Version number for preferences. Must be incremented every time a migration is necessary.
|
||||
*/
|
||||
private static final int VERSION = 6;
|
||||
private static final int VERSION = 8;
|
||||
|
||||
|
||||
public static void runMigrationsIfNeeded(@NonNull final Context context) {
|
||||
static void runMigrationsIfNeeded(@NonNull final Context context) {
|
||||
// setup migrations and check if there is something to do
|
||||
sp = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
final String lastPrefVersionKey = context.getString(R.string.last_used_preferences_version);
|
|
@ -34,6 +34,7 @@ import org.schabi.newpipe.error.UserAction;
|
|||
import org.schabi.newpipe.settings.SelectChannelFragment;
|
||||
import org.schabi.newpipe.settings.SelectKioskFragment;
|
||||
import org.schabi.newpipe.settings.SelectPlaylistFragment;
|
||||
import org.schabi.newpipe.settings.SelectFeedGroupFragment;
|
||||
import org.schabi.newpipe.settings.tabs.AddTabDialog.ChooseTabListItem;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
|
@ -203,6 +204,14 @@ public class ChooseTabsFragment extends Fragment {
|
|||
});
|
||||
selectPlaylistFragment.show(getParentFragmentManager(), "select_playlist");
|
||||
return;
|
||||
case FEEDGROUP:
|
||||
final SelectFeedGroupFragment selectFeedGroupFragment =
|
||||
new SelectFeedGroupFragment();
|
||||
selectFeedGroupFragment.setOnSelectedListener(
|
||||
(groupId, name, iconId) ->
|
||||
addTab(new Tab.FeedGroupTab(groupId, name, iconId)));
|
||||
selectFeedGroupFragment.show(getParentFragmentManager(), "select_feed_group");
|
||||
return;
|
||||
default:
|
||||
addTab(type.getTab());
|
||||
break;
|
||||
|
@ -244,6 +253,11 @@ public class ChooseTabsFragment extends Fragment {
|
|||
getString(R.string.playlist_page_summary),
|
||||
tab.getTabIconRes(context)));
|
||||
break;
|
||||
case FEEDGROUP:
|
||||
returnList.add(new ChooseTabListItem(tab.getTabId(),
|
||||
getString(R.string.feed_group_page_summary),
|
||||
tab.getTabIconRes(context)));
|
||||
break;
|
||||
default:
|
||||
if (!tabList.contains(tab)) {
|
||||
returnList.add(new ChooseTabListItem(context, tab));
|
||||
|
@ -396,6 +410,9 @@ public class ChooseTabsFragment extends Fragment {
|
|||
? getString(R.string.local)
|
||||
: getNameOfServiceById(serviceId);
|
||||
return serviceName + "/" + tab.getTabName(requireContext());
|
||||
case FEEDGROUP:
|
||||
return getString(R.string.feed_groups_header_title)
|
||||
+ "/" + ((Tab.FeedGroupTab) tab).getFeedGroupName();
|
||||
default:
|
||||
return tab.getTabName(requireContext());
|
||||
}
|
||||
|
|
|
@ -93,6 +93,8 @@ public abstract class Tab {
|
|||
return new ChannelTab(jsonObject);
|
||||
case PLAYLIST:
|
||||
return new PlaylistTab(jsonObject);
|
||||
case FEEDGROUP:
|
||||
return new FeedGroupTab(jsonObject);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -162,7 +164,8 @@ public abstract class Tab {
|
|||
HISTORY(new HistoryTab()),
|
||||
KIOSK(new KioskTab()),
|
||||
CHANNEL(new ChannelTab()),
|
||||
PLAYLIST(new PlaylistTab());
|
||||
PLAYLIST(new PlaylistTab()),
|
||||
FEEDGROUP(new FeedGroupTab());
|
||||
|
||||
private final Tab tab;
|
||||
|
||||
|
@ -458,7 +461,7 @@ public abstract class Tab {
|
|||
final ChannelTab other = (ChannelTab) obj;
|
||||
return super.equals(obj)
|
||||
&& channelServiceId == other.channelServiceId
|
||||
&& channelUrl.equals(other.channelName)
|
||||
&& channelUrl.equals(other.channelUrl)
|
||||
&& channelName.equals(other.channelName);
|
||||
}
|
||||
|
||||
|
@ -652,4 +655,93 @@ public abstract class Tab {
|
|||
return playlistType;
|
||||
}
|
||||
}
|
||||
public static class FeedGroupTab extends Tab {
|
||||
public static final int ID = 9;
|
||||
private static final String JSON_FEED_GROUP_ID_KEY = "feed_group_id";
|
||||
private static final String JSON_FEED_GROUP_NAME_KEY = "feed_group_name";
|
||||
private static final String JSON_FEED_GROUP_ICON_KEY = "feed_group_icon";
|
||||
private Long feedGroupId;
|
||||
private String feedGroupName;
|
||||
private int iconId;
|
||||
|
||||
private FeedGroupTab() {
|
||||
this((long) -1, NO_NAME, R.drawable.ic_asterisk);
|
||||
}
|
||||
|
||||
public FeedGroupTab(final Long feedGroupId, final String feedGroupName,
|
||||
final int iconId) {
|
||||
this.feedGroupId = feedGroupId;
|
||||
this.feedGroupName = feedGroupName;
|
||||
this.iconId = iconId;
|
||||
|
||||
}
|
||||
|
||||
public FeedGroupTab(final JsonObject jsonObject) {
|
||||
super(jsonObject);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTabId() {
|
||||
return ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTabName(final Context context) {
|
||||
return context.getString(R.string.fragment_feed_title);
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
@Override
|
||||
public int getTabIconRes(final Context context) {
|
||||
return this.iconId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FeedFragment getFragment(final Context context) {
|
||||
return FeedFragment.newInstance(feedGroupId, feedGroupName);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeDataToJson(final JsonStringWriter writerSink) {
|
||||
writerSink.value(JSON_FEED_GROUP_ID_KEY, feedGroupId)
|
||||
.value(JSON_FEED_GROUP_NAME_KEY, feedGroupName)
|
||||
.value(JSON_FEED_GROUP_ICON_KEY, iconId);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void readDataFromJson(final JsonObject jsonObject) {
|
||||
feedGroupId = jsonObject.getLong(JSON_FEED_GROUP_ID_KEY, -1);
|
||||
feedGroupName = jsonObject.getString(JSON_FEED_GROUP_NAME_KEY, NO_NAME);
|
||||
iconId = jsonObject.getInt(JSON_FEED_GROUP_ICON_KEY, R.drawable.ic_asterisk);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object obj) {
|
||||
if (!(obj instanceof FeedGroupTab)) {
|
||||
return false;
|
||||
}
|
||||
final FeedGroupTab other = (FeedGroupTab) obj;
|
||||
return super.equals(obj)
|
||||
&& feedGroupId.equals(other.feedGroupId)
|
||||
&& feedGroupName.equals(other.feedGroupName)
|
||||
&& iconId == other.iconId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(getTabId(), feedGroupId, feedGroupName, iconId);
|
||||
}
|
||||
|
||||
public Long getFeedGroupId() {
|
||||
return feedGroupId;
|
||||
}
|
||||
|
||||
public String getFeedGroupName() {
|
||||
return feedGroupName;
|
||||
}
|
||||
|
||||
public int getIconId() {
|
||||
return iconId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -24,7 +24,11 @@ public class SrtFromTtmlWriter {
|
|||
private final boolean ignoreEmptyFrames;
|
||||
private final Charset charset = StandardCharsets.UTF_8;
|
||||
|
||||
private int frameIndex = 0;
|
||||
// According to the SubRip (.srt) specification, subtitle
|
||||
// numbering must start from 1.
|
||||
// Some players accept 0 or even negative indices,
|
||||
// but to ensure compliance we start at 1.
|
||||
private int frameIndex = 1;
|
||||
|
||||
public SrtFromTtmlWriter(final SharpStream out, final boolean ignoreEmptyFrames) {
|
||||
this.out = out;
|
||||
|
@ -39,7 +43,8 @@ public class SrtFromTtmlWriter {
|
|||
|
||||
private void writeFrame(final String begin, final String end, final StringBuilder text)
|
||||
throws IOException {
|
||||
writeString(String.valueOf(frameIndex++));
|
||||
writeString(String.valueOf(frameIndex));
|
||||
frameIndex += 1;
|
||||
writeString(NEW_LINE);
|
||||
writeString(begin);
|
||||
writeString(" --> ");
|
||||
|
|
|
@ -24,6 +24,7 @@ public final class ChannelTabHelper {
|
|||
switch (tab) {
|
||||
case ChannelTabs.VIDEOS:
|
||||
case ChannelTabs.TRACKS:
|
||||
case ChannelTabs.LIKES:
|
||||
case ChannelTabs.SHORTS:
|
||||
case ChannelTabs.LIVESTREAMS:
|
||||
return true;
|
||||
|
@ -62,6 +63,8 @@ public final class ChannelTabHelper {
|
|||
return R.string.show_channel_tabs_playlists;
|
||||
case ChannelTabs.ALBUMS:
|
||||
return R.string.show_channel_tabs_albums;
|
||||
case ChannelTabs.LIKES:
|
||||
return R.string.show_channel_tabs_likes;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
|
@ -78,6 +81,8 @@ public final class ChannelTabHelper {
|
|||
return R.string.fetch_channel_tabs_shorts;
|
||||
case ChannelTabs.LIVESTREAMS:
|
||||
return R.string.fetch_channel_tabs_livestreams;
|
||||
case ChannelTabs.LIKES:
|
||||
return R.string.fetch_channel_tabs_likes;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
|
@ -100,6 +105,8 @@ public final class ChannelTabHelper {
|
|||
return R.string.channel_tab_playlists;
|
||||
case ChannelTabs.ALBUMS:
|
||||
return R.string.channel_tab_albums;
|
||||
case ChannelTabs.LIKES:
|
||||
return R.string.channel_tab_likes;
|
||||
default:
|
||||
return R.string.unknown_content;
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ import android.view.InputDevice;
|
|||
import android.view.KeyEvent;
|
||||
import android.view.WindowInsets;
|
||||
import android.view.WindowManager;
|
||||
import android.webkit.CookieManager;
|
||||
|
||||
import androidx.annotation.Dimension;
|
||||
import androidx.annotation.NonNull;
|
||||
|
@ -335,4 +336,17 @@ public final class DeviceUtils {
|
|||
&& !TX_50JXW834
|
||||
&& !HMB9213NW;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return whether the device has support for WebView, see
|
||||
* <a href="https://stackoverflow.com/a/69626735">https://stackoverflow.com/a/69626735</a>
|
||||
*/
|
||||
public static boolean supportsWebView() {
|
||||
try {
|
||||
CookieManager.getInstance();
|
||||
return true;
|
||||
} catch (final Throwable ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -52,6 +52,14 @@ public final class KioskTranslator {
|
|||
return c.getString(R.string.featured);
|
||||
case "Radio":
|
||||
return c.getString(R.string.radio);
|
||||
case "trending_gaming":
|
||||
return c.getString(R.string.trending_gaming);
|
||||
case "trending_music":
|
||||
return c.getString(R.string.trending_music);
|
||||
case "trending_movies_and_shows":
|
||||
return c.getString(R.string.trending_movies);
|
||||
case "trending_podcasts_episodes":
|
||||
return c.getString(R.string.trending_podcasts);
|
||||
default:
|
||||
return kioskId;
|
||||
}
|
||||
|
@ -77,6 +85,14 @@ public final class KioskTranslator {
|
|||
return R.drawable.ic_stars;
|
||||
case "Radio":
|
||||
return R.drawable.ic_radio;
|
||||
case "trending_gaming":
|
||||
return R.drawable.ic_videogame_asset;
|
||||
case "trending_music":
|
||||
return R.drawable.ic_music_note;
|
||||
case "trending_movies_and_shows":
|
||||
return R.drawable.ic_movie;
|
||||
case "trending_podcasts_episodes":
|
||||
return R.drawable.ic_podcasts;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
|
|
@ -322,7 +322,7 @@ public final class ListHelper {
|
|||
}
|
||||
|
||||
// Sort collected streams by name
|
||||
return collectedStreams.values().stream().sorted(getAudioTrackNameComparator(context))
|
||||
return collectedStreams.values().stream().sorted(getAudioTrackNameComparator())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
|
@ -359,7 +359,7 @@ public final class ListHelper {
|
|||
}
|
||||
|
||||
// Sort tracks alphabetically, sort track streams by quality
|
||||
final Comparator<AudioStream> nameCmp = getAudioTrackNameComparator(context);
|
||||
final Comparator<AudioStream> nameCmp = getAudioTrackNameComparator();
|
||||
final Comparator<AudioStream> formatCmp = getAudioFormatComparator(context);
|
||||
|
||||
return collectedStreams.values().stream()
|
||||
|
@ -867,12 +867,10 @@ public final class ListHelper {
|
|||
* Get a {@link Comparator} to compare {@link AudioStream}s by their languages and track types
|
||||
* for alphabetical sorting.
|
||||
*
|
||||
* @param context app context for localization
|
||||
* @return Comparator
|
||||
*/
|
||||
private static Comparator<AudioStream> getAudioTrackNameComparator(
|
||||
@NonNull final Context context) {
|
||||
final Locale appLoc = Localization.getAppLocale(context);
|
||||
private static Comparator<AudioStream> getAudioTrackNameComparator() {
|
||||
final Locale appLoc = Localization.getAppLocale();
|
||||
|
||||
return Comparator.comparing(AudioStream::getAudioLocale, Comparator.nullsLast(
|
||||
Comparator.comparing(locale -> locale.getDisplayName(appLoc))))
|
||||
|
|
|
@ -5,19 +5,21 @@ import static org.schabi.newpipe.MainActivity.DEBUG;
|
|||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.res.Configuration;
|
||||
import android.content.res.Resources;
|
||||
import android.icu.text.CompactDecimalFormat;
|
||||
import android.os.Build;
|
||||
import android.text.BidiFormatter;
|
||||
import android.text.TextUtils;
|
||||
import android.text.format.DateUtils;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.PluralsRes;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.core.math.MathUtils;
|
||||
import androidx.core.os.LocaleListCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.ocpsoft.prettytime.PrettyTime;
|
||||
|
@ -63,6 +65,7 @@ import java.util.stream.Collectors;
|
|||
*/
|
||||
|
||||
public final class Localization {
|
||||
private static final String TAG = Localization.class.toString();
|
||||
public static final String DOT_SEPARATOR = " • ";
|
||||
private static PrettyTime prettyTime;
|
||||
|
||||
|
@ -80,6 +83,20 @@ public final class Localization {
|
|||
.collect(Collectors.joining(delimiter));
|
||||
}
|
||||
|
||||
/**
|
||||
* Localize a user name like <code>@foobar</code>.
|
||||
*
|
||||
* Will correctly handle right-to-left usernames by using a {@link BidiFormatter}.
|
||||
* For right-to-left usernames, it will put the @ on the right side to read more naturally.
|
||||
*
|
||||
* @param plainName username, with an optional leading <code>@</code>
|
||||
* @return a usernames that can include RTL-characters
|
||||
*/
|
||||
@NonNull
|
||||
public static String localizeUserName(final String plainName) {
|
||||
return BidiFormatter.getInstance().unicodeWrap(plainName);
|
||||
}
|
||||
|
||||
public static org.schabi.newpipe.extractor.localization.Localization getPreferredLocalization(
|
||||
final Context context) {
|
||||
return org.schabi.newpipe.extractor.localization.Localization
|
||||
|
@ -100,35 +117,34 @@ public final class Localization {
|
|||
return getLocaleFromPrefs(context, R.string.content_language_key);
|
||||
}
|
||||
|
||||
public static Locale getAppLocale(@NonNull final Context context) {
|
||||
return getLocaleFromPrefs(context, R.string.app_language_key);
|
||||
public static Locale getAppLocale() {
|
||||
final Locale customLocale = AppCompatDelegate.getApplicationLocales().get(0);
|
||||
return customLocale != null ? customLocale : Locale.getDefault();
|
||||
}
|
||||
|
||||
public static String localizeNumber(@NonNull final Context context, final long number) {
|
||||
return localizeNumber(context, (double) number);
|
||||
public static String localizeNumber(final long number) {
|
||||
return localizeNumber((double) number);
|
||||
}
|
||||
|
||||
public static String localizeNumber(@NonNull final Context context, final double number) {
|
||||
final NumberFormat nf = NumberFormat.getInstance(getAppLocale(context));
|
||||
return nf.format(number);
|
||||
public static String localizeNumber(final double number) {
|
||||
return NumberFormat.getInstance(getAppLocale()).format(number);
|
||||
}
|
||||
|
||||
public static String formatDate(@NonNull final Context context,
|
||||
@NonNull final OffsetDateTime offsetDateTime) {
|
||||
public static String formatDate(@NonNull final OffsetDateTime offsetDateTime) {
|
||||
return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
.withLocale(getAppLocale(context)).format(offsetDateTime
|
||||
.atZoneSameInstant(ZoneId.systemDefault()));
|
||||
.withLocale(getAppLocale())
|
||||
.format(offsetDateTime.atZoneSameInstant(ZoneId.systemDefault()));
|
||||
}
|
||||
|
||||
@SuppressLint("StringFormatInvalid")
|
||||
public static String localizeUploadDate(@NonNull final Context context,
|
||||
@NonNull final OffsetDateTime offsetDateTime) {
|
||||
return context.getString(R.string.upload_date_text, formatDate(context, offsetDateTime));
|
||||
return context.getString(R.string.upload_date_text, formatDate(offsetDateTime));
|
||||
}
|
||||
|
||||
public static String localizeViewCount(@NonNull final Context context, final long viewCount) {
|
||||
return getQuantity(context, R.plurals.views, R.string.no_views, viewCount,
|
||||
localizeNumber(context, viewCount));
|
||||
localizeNumber(viewCount));
|
||||
}
|
||||
|
||||
public static String localizeStreamCount(@NonNull final Context context,
|
||||
|
@ -142,7 +158,7 @@ public final class Localization {
|
|||
return context.getResources().getString(R.string.more_than_100_videos);
|
||||
default:
|
||||
return getQuantity(context, R.plurals.videos, R.string.no_videos, streamCount,
|
||||
localizeNumber(context, streamCount));
|
||||
localizeNumber(streamCount));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -163,27 +179,33 @@ public final class Localization {
|
|||
public static String localizeWatchingCount(@NonNull final Context context,
|
||||
final long watchingCount) {
|
||||
return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount,
|
||||
localizeNumber(context, watchingCount));
|
||||
localizeNumber(watchingCount));
|
||||
}
|
||||
|
||||
public static String shortCount(@NonNull final Context context, final long count) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
return CompactDecimalFormat.getInstance(getAppLocale(context),
|
||||
return CompactDecimalFormat.getInstance(getAppLocale(),
|
||||
CompactDecimalFormat.CompactStyle.SHORT).format(count);
|
||||
}
|
||||
|
||||
final double value = (double) count;
|
||||
if (count >= 1000000000) {
|
||||
return localizeNumber(context, round(value / 1000000000))
|
||||
+ context.getString(R.string.short_billion);
|
||||
final double shortenedValue = value / 1000000000;
|
||||
final int scale = shortenedValue >= 100 ? 0 : 1;
|
||||
return context.getString(R.string.short_billion,
|
||||
localizeNumber(round(shortenedValue, scale)));
|
||||
} else if (count >= 1000000) {
|
||||
return localizeNumber(context, round(value / 1000000))
|
||||
+ context.getString(R.string.short_million);
|
||||
final double shortenedValue = value / 1000000;
|
||||
final int scale = shortenedValue >= 100 ? 0 : 1;
|
||||
return context.getString(R.string.short_million,
|
||||
localizeNumber(round(shortenedValue, scale)));
|
||||
} else if (count >= 1000) {
|
||||
return localizeNumber(context, round(value / 1000))
|
||||
+ context.getString(R.string.short_thousand);
|
||||
final double shortenedValue = value / 1000;
|
||||
final int scale = shortenedValue >= 100 ? 0 : 1;
|
||||
return context.getString(R.string.short_thousand,
|
||||
localizeNumber(round(shortenedValue, scale)));
|
||||
} else {
|
||||
return localizeNumber(context, value);
|
||||
return localizeNumber(value);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -308,7 +330,7 @@ public final class Localization {
|
|||
* <ul>
|
||||
* <li>English (original)</li>
|
||||
* <li>English (descriptive)</li>
|
||||
* <li>Spanish (dubbed)</li>
|
||||
* <li>Spanish (Spain) (dubbed)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param context the context used to get the app language
|
||||
|
@ -318,7 +340,7 @@ public final class Localization {
|
|||
public static String audioTrackName(@NonNull final Context context, final AudioStream track) {
|
||||
final String name;
|
||||
if (track.getAudioLocale() != null) {
|
||||
name = track.getAudioLocale().getDisplayLanguage(getAppLocale(context));
|
||||
name = track.getAudioLocale().getDisplayName();
|
||||
} else if (track.getAudioTrackName() != null) {
|
||||
name = track.getAudioTrackName();
|
||||
} else {
|
||||
|
@ -353,8 +375,8 @@ public final class Localization {
|
|||
prettyTime.removeUnit(Decade.class);
|
||||
}
|
||||
|
||||
public static PrettyTime resolvePrettyTime(@NonNull final Context context) {
|
||||
return new PrettyTime(getAppLocale(context));
|
||||
public static PrettyTime resolvePrettyTime() {
|
||||
return new PrettyTime(getAppLocale());
|
||||
}
|
||||
|
||||
public static String relativeTime(@NonNull final OffsetDateTime offsetDateTime) {
|
||||
|
@ -372,9 +394,10 @@ public final class Localization {
|
|||
* {@code parsed != null} and the relevant setting is enabled, {@code textual} will
|
||||
* be appended to the returned string for debugging purposes.
|
||||
*/
|
||||
@Nullable
|
||||
public static String relativeTimeOrTextual(@Nullable final Context context,
|
||||
@Nullable final DateWrapper parsed,
|
||||
final String textual) {
|
||||
@Nullable final String textual) {
|
||||
if (parsed == null) {
|
||||
return textual;
|
||||
} else if (DEBUG && context != null && PreferenceManager
|
||||
|
@ -386,14 +409,6 @@ public final class Localization {
|
|||
}
|
||||
}
|
||||
|
||||
public static void assureCorrectAppLanguage(final Context c) {
|
||||
final Resources res = c.getResources();
|
||||
final DisplayMetrics dm = res.getDisplayMetrics();
|
||||
final Configuration conf = res.getConfiguration();
|
||||
conf.setLocale(getAppLocale(c));
|
||||
res.updateConfiguration(conf, dm);
|
||||
}
|
||||
|
||||
private static Locale getLocaleFromPrefs(@NonNull final Context context,
|
||||
@StringRes final int prefKey) {
|
||||
final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
|
@ -407,8 +422,8 @@ public final class Localization {
|
|||
}
|
||||
}
|
||||
|
||||
private static double round(final double value) {
|
||||
return new BigDecimal(value).setScale(1, RoundingMode.HALF_UP).doubleValue();
|
||||
private static double round(final double value, final int scale) {
|
||||
return new BigDecimal(value).setScale(scale, RoundingMode.HALF_UP).doubleValue();
|
||||
}
|
||||
|
||||
private static String getQuantity(@NonNull final Context context,
|
||||
|
@ -427,4 +442,35 @@ public final class Localization {
|
|||
final int safeCount = (int) MathUtils.clamp(count, Integer.MIN_VALUE, Integer.MAX_VALUE);
|
||||
return context.getResources().getQuantityString(pluralId, safeCount, formattedCount);
|
||||
}
|
||||
|
||||
// Starting with pull request #12093, NewPipe exclusively uses Android's
|
||||
// public per-app language APIs to read and set the UI language for NewPipe.
|
||||
// The following code will migrate any existing custom app language in SharedPreferences to
|
||||
// use the public per-app language APIs instead.
|
||||
// For reference, see
|
||||
// https://android-developers.googleblog.com/2022/11/per-app-language-preferences-part-1.html
|
||||
public static void migrateAppLanguageSettingIfNecessary(@NonNull final Context context) {
|
||||
final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
final String appLanguageKey = context.getString(R.string.app_language_key);
|
||||
final String appLanguageValue = sp.getString(appLanguageKey, null);
|
||||
if (appLanguageValue != null) {
|
||||
// The app language key is used on Android versions < 33
|
||||
// for more info, see ContentSettingsFragment
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
sp.edit().remove(appLanguageKey).apply();
|
||||
}
|
||||
final String appLanguageDefaultValue =
|
||||
context.getString(R.string.default_localization_key);
|
||||
if (!appLanguageValue.equals(appLanguageDefaultValue)) {
|
||||
try {
|
||||
AppCompatDelegate.setApplicationLocales(
|
||||
LocaleListCompat.forLanguageTags(appLanguageValue));
|
||||
} catch (final RuntimeException e) {
|
||||
Log.e(TAG, "Failed to migrate previous custom app language "
|
||||
+ "setting to public per-app language APIs"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -57,8 +57,10 @@ import org.schabi.newpipe.local.subscription.SubscriptionFragment;
|
|||
import org.schabi.newpipe.local.subscription.SubscriptionsImportFragment;
|
||||
import org.schabi.newpipe.player.PlayQueueActivity;
|
||||
import org.schabi.newpipe.player.Player;
|
||||
import org.schabi.newpipe.player.PlayerIntentType;
|
||||
import org.schabi.newpipe.player.PlayerService;
|
||||
import org.schabi.newpipe.player.PlayerType;
|
||||
import org.schabi.newpipe.player.TimestampChangeData;
|
||||
import org.schabi.newpipe.player.helper.PlayerHelper;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
|
@ -67,6 +69,7 @@ import org.schabi.newpipe.settings.SettingsActivity;
|
|||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public final class NavigationHelper {
|
||||
public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag";
|
||||
|
@ -85,53 +88,32 @@ public final class NavigationHelper {
|
|||
public static <T> Intent getPlayerIntent(@NonNull final Context context,
|
||||
@NonNull final Class<T> targetClazz,
|
||||
@Nullable final PlayQueue playQueue,
|
||||
final boolean resumePlayback) {
|
||||
final Intent intent = new Intent(context, targetClazz);
|
||||
|
||||
if (playQueue != null) {
|
||||
final String cacheKey = SerializedCache.getInstance().put(playQueue, PlayQueue.class);
|
||||
if (cacheKey != null) {
|
||||
intent.putExtra(Player.PLAY_QUEUE_KEY, cacheKey);
|
||||
}
|
||||
}
|
||||
intent.putExtra(Player.PLAYER_TYPE, PlayerType.MAIN.valueForIntent());
|
||||
intent.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
|
||||
|
||||
return intent;
|
||||
@NonNull final PlayerIntentType playerIntentType) {
|
||||
final String cacheKey = Optional.ofNullable(playQueue)
|
||||
.map(queue -> SerializedCache.getInstance().put(queue, PlayQueue.class))
|
||||
.orElse(null);
|
||||
return new Intent(context, targetClazz)
|
||||
.putExtra(Player.PLAY_QUEUE_KEY, cacheKey)
|
||||
.putExtra(Player.PLAYER_TYPE, PlayerType.MAIN)
|
||||
.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true)
|
||||
.putExtra(Player.PLAYER_INTENT_TYPE, playerIntentType);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static <T> Intent getPlayerIntent(@NonNull final Context context,
|
||||
@NonNull final Class<T> targetClazz,
|
||||
@Nullable final PlayQueue playQueue,
|
||||
final boolean resumePlayback,
|
||||
final boolean playWhenReady) {
|
||||
return getPlayerIntent(context, targetClazz, playQueue, resumePlayback)
|
||||
.putExtra(Player.PLAY_WHEN_READY, playWhenReady);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static <T> Intent getPlayerEnqueueIntent(@NonNull final Context context,
|
||||
@NonNull final Class<T> targetClazz,
|
||||
@Nullable final PlayQueue playQueue) {
|
||||
// when enqueueing `resumePlayback` is always `false` since:
|
||||
// - if there is a video already playing, the value of `resumePlayback` just doesn't make
|
||||
// any difference.
|
||||
// - if there is nothing already playing, it is useful for the enqueue action to have a
|
||||
// slightly different behaviour than the normal play action: the latter resumes playback,
|
||||
// the former doesn't. (note that enqueue can be triggered when nothing is playing only
|
||||
// by long pressing the video detail fragment, playlist or channel controls
|
||||
return getPlayerIntent(context, targetClazz, playQueue, false)
|
||||
.putExtra(Player.ENQUEUE, true);
|
||||
public static Intent getPlayerTimestampIntent(@NonNull final Context context,
|
||||
@NonNull final TimestampChangeData data) {
|
||||
return new Intent(context, PlayerService.class)
|
||||
.putExtra(Player.PLAYER_INTENT_TYPE, PlayerIntentType.TimestampChange)
|
||||
.putExtra(Player.PLAYER_INTENT_DATA, data);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static <T> Intent getPlayerEnqueueNextIntent(@NonNull final Context context,
|
||||
@NonNull final Class<T> targetClazz,
|
||||
@Nullable final PlayQueue playQueue) {
|
||||
// see comment in `getPlayerEnqueueIntent` as to why `resumePlayback` is false
|
||||
return getPlayerIntent(context, targetClazz, playQueue, false)
|
||||
.putExtra(Player.ENQUEUE_NEXT, true);
|
||||
return getPlayerIntent(context, targetClazz, playQueue, PlayerIntentType.EnqueueNext)
|
||||
// see comment in `getPlayerEnqueueIntent` as to why `resumePlayback` is false
|
||||
.putExtra(Player.RESUME_PLAYBACK, false);
|
||||
}
|
||||
|
||||
/* PLAY */
|
||||
|
@ -165,8 +147,10 @@ public final class NavigationHelper {
|
|||
|
||||
Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show();
|
||||
|
||||
final Intent intent = getPlayerIntent(context, PlayerService.class, queue, resumePlayback);
|
||||
intent.putExtra(Player.PLAYER_TYPE, PlayerType.POPUP.valueForIntent());
|
||||
final var intent = getPlayerIntent(context, PlayerService.class, queue,
|
||||
PlayerIntentType.AllOthers)
|
||||
.putExtra(Player.PLAYER_TYPE, PlayerType.POPUP)
|
||||
.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
|
||||
ContextCompat.startForegroundService(context, intent);
|
||||
}
|
||||
|
||||
|
@ -176,8 +160,10 @@ public final class NavigationHelper {
|
|||
Toast.makeText(context, R.string.background_player_playing_toast, Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
|
||||
final Intent intent = getPlayerIntent(context, PlayerService.class, queue, resumePlayback);
|
||||
intent.putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO.valueForIntent());
|
||||
final Intent intent = getPlayerIntent(context, PlayerService.class, queue,
|
||||
PlayerIntentType.AllOthers)
|
||||
.putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO)
|
||||
.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
|
||||
ContextCompat.startForegroundService(context, intent);
|
||||
}
|
||||
|
||||
|
@ -190,9 +176,18 @@ public final class NavigationHelper {
|
|||
}
|
||||
|
||||
Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show();
|
||||
final Intent intent = getPlayerEnqueueIntent(context, PlayerService.class, queue);
|
||||
|
||||
intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent());
|
||||
// when enqueueing `resumePlayback` is always `false` since:
|
||||
// - if there is a video already playing, the value of `resumePlayback` just doesn't make
|
||||
// any difference.
|
||||
// - if there is nothing already playing, it is useful for the enqueue action to have a
|
||||
// slightly different behaviour than the normal play action: the latter resumes playback,
|
||||
// the former doesn't. (note that enqueue can be triggered when nothing is playing only
|
||||
// by long pressing the video detail fragment, playlist or channel controls
|
||||
final Intent intent = getPlayerIntent(context, PlayerService.class, queue,
|
||||
PlayerIntentType.Enqueue)
|
||||
.putExtra(Player.RESUME_PLAYBACK, false)
|
||||
.putExtra(Player.PLAYER_TYPE, playerType);
|
||||
ContextCompat.startForegroundService(context, intent);
|
||||
}
|
||||
|
||||
|
@ -214,9 +209,8 @@ public final class NavigationHelper {
|
|||
playerType = PlayerType.AUDIO;
|
||||
}
|
||||
Toast.makeText(context, R.string.enqueued_next, Toast.LENGTH_SHORT).show();
|
||||
final Intent intent = getPlayerEnqueueNextIntent(context, PlayerService.class, queue);
|
||||
|
||||
intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent());
|
||||
final Intent intent = getPlayerEnqueueNextIntent(context, PlayerService.class, queue)
|
||||
.putExtra(Player.PLAYER_TYPE, playerType);
|
||||
ContextCompat.startForegroundService(context, intent);
|
||||
}
|
||||
|
||||
|
@ -452,8 +446,12 @@ public final class NavigationHelper {
|
|||
if (fragment instanceof VideoDetailFragment && fragment.isVisible()) {
|
||||
onVideoDetailFragmentReady.run((VideoDetailFragment) fragment);
|
||||
} else {
|
||||
// Specify no url here, otherwise the VideoDetailFragment will start loading the
|
||||
// stream automatically if it's the first time it is being opened, but then
|
||||
// onVideoDetailFragmentReady will kick in and start another loading process.
|
||||
// See VideoDetailFragment.wasCleared() and its usage in doInitialLoadLogic().
|
||||
final VideoDetailFragment instance = VideoDetailFragment
|
||||
.getInstance(serviceId, url, title, playQueue);
|
||||
.getInstance(serviceId, null, title, playQueue);
|
||||
instance.setAutoPlay(autoPlay);
|
||||
|
||||
defaultTransaction(fragmentManager)
|
||||
|
|
|
@ -9,9 +9,11 @@ import android.content.pm.PackageManager;
|
|||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.provider.Settings;
|
||||
import android.text.Html;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
|
@ -113,14 +115,47 @@ public final class PermissionHelper {
|
|||
@RequiresApi(api = Build.VERSION_CODES.M)
|
||||
public static boolean checkSystemAlertWindowPermission(final Context context) {
|
||||
if (!Settings.canDrawOverlays(context)) {
|
||||
final Intent i = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
Uri.parse("package:" + context.getPackageName()));
|
||||
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
try {
|
||||
context.startActivity(i);
|
||||
} catch (final ActivityNotFoundException ignored) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
|
||||
final Intent i = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
|
||||
Uri.parse("package:" + context.getPackageName()));
|
||||
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
try {
|
||||
context.startActivity(i);
|
||||
} catch (final ActivityNotFoundException ignored) {
|
||||
}
|
||||
return false;
|
||||
// from Android R the ACTION_MANAGE_OVERLAY_PERMISSION will only point to the menu,
|
||||
// so let’s add a dialog that points the user to the right setting.
|
||||
} else {
|
||||
final String appName = context.getApplicationInfo()
|
||||
.loadLabel(context.getPackageManager()).toString();
|
||||
final String title = context.getString(R.string.permission_display_over_apps);
|
||||
final String permissionName =
|
||||
context.getString(R.string.permission_display_over_apps_permission_name);
|
||||
final String appNameItalic = "<i>" + appName + "</i>";
|
||||
final String permissionNameItalic = "<i>" + permissionName + "</i>";
|
||||
final String message =
|
||||
context.getString(R.string.permission_display_over_apps_message,
|
||||
appNameItalic,
|
||||
permissionNameItalic
|
||||
);
|
||||
new AlertDialog.Builder(context)
|
||||
.setTitle(title)
|
||||
.setMessage(Html.fromHtml(message, Html.FROM_HTML_MODE_COMPACT))
|
||||
.setPositiveButton("OK", (dialog, which) -> {
|
||||
// we don’t need the package name here, since it won’t do anything on >R
|
||||
final Intent intent =
|
||||
new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION);
|
||||
try {
|
||||
context.startActivity(intent);
|
||||
} catch (final ActivityNotFoundException ignored) {
|
||||
}
|
||||
})
|
||||
.setCancelable(true)
|
||||
.show();
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
|
|
|
@ -121,7 +121,7 @@ public final class SparseItemUtil {
|
|||
callback.accept(result);
|
||||
}, throwable -> ErrorUtil.createNotification(context,
|
||||
new ErrorInfo(throwable, UserAction.REQUESTED_STREAM,
|
||||
"Loading stream info: " + url, serviceId)
|
||||
"Loading stream info: " + url, serviceId, url)
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,10 +5,9 @@ import static org.schabi.newpipe.MainActivity.DEBUG;
|
|||
import android.content.ActivityNotFoundException;
|
||||
import android.content.ClipData;
|
||||
import android.content.ClipboardManager;
|
||||
import android.content.ComponentName;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.pm.ResolveInfo;
|
||||
import android.graphics.Bitmap;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
|
@ -23,6 +22,7 @@ import androidx.core.content.FileProvider;
|
|||
|
||||
import org.schabi.newpipe.BuildConfig;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.RouterActivity;
|
||||
import org.schabi.newpipe.extractor.Image;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
|
@ -62,8 +62,9 @@ public final class ShareUtils {
|
|||
}
|
||||
|
||||
/**
|
||||
* Open the url with the system default browser. If no browser is set as default, falls back to
|
||||
* {@link #openAppChooser(Context, Intent, boolean)}.
|
||||
* Open the url with the system default browser. If no browser is installed, falls back to
|
||||
* {@link #openAppChooser(Context, Intent, boolean)} (for displaying that no apps are available
|
||||
* to handle the action, or possible OEM-related edge cases).
|
||||
* <p>
|
||||
* This function selects the package to open based on which apps respond to the {@code http://}
|
||||
* schema alone, which should exclude special non-browser apps that are can handle the url (e.g.
|
||||
|
@ -77,44 +78,26 @@ public final class ShareUtils {
|
|||
* @param url the url to browse
|
||||
**/
|
||||
public static void openUrlInBrowser(@NonNull final Context context, final String url) {
|
||||
// Resolve using a generic http://, so we are sure to get a browser and not e.g. the yt app.
|
||||
// Target a generic http://, so we are sure to get a browser and not e.g. the yt app.
|
||||
// Note that this requires the `http` schema to be added to `<queries>` in the manifest.
|
||||
final ResolveInfo defaultBrowserInfo;
|
||||
final Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse("http://"));
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent,
|
||||
PackageManager.ResolveInfoFlags.of(PackageManager.MATCH_DEFAULT_ONLY));
|
||||
} else {
|
||||
defaultBrowserInfo = context.getPackageManager().resolveActivity(browserIntent,
|
||||
PackageManager.MATCH_DEFAULT_ONLY);
|
||||
}
|
||||
|
||||
final Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url))
|
||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
|
||||
if (defaultBrowserInfo == null) {
|
||||
// No app installed to open a web URL, but it may be handled by other apps so try
|
||||
// opening a system chooser for the link in this case (it could be bypassed by the
|
||||
// system if there is only one app which can open the link or a default app associated
|
||||
// with the link domain on Android 12 and higher)
|
||||
// See https://stackoverflow.com/a/58801285 and `setSelector` documentation
|
||||
intent.setSelector(browserIntent);
|
||||
try {
|
||||
context.startActivity(intent);
|
||||
} catch (final ActivityNotFoundException e) {
|
||||
// No browser is available. This should, in the end, yield a nice AOSP error message
|
||||
// indicating that no app is available to handle this action.
|
||||
//
|
||||
// Note: there are some situations where modified OEM ROMs have apps that appear
|
||||
// to be browsers but are actually app choosers. If starting the Activity fails
|
||||
// related to this, opening the system app chooser is still the correct behavior.
|
||||
intent.setSelector(null);
|
||||
openAppChooser(context, intent, true);
|
||||
return;
|
||||
}
|
||||
|
||||
final String defaultBrowserPackage = defaultBrowserInfo.activityInfo.packageName;
|
||||
|
||||
if (defaultBrowserPackage.equals("android")) {
|
||||
// No browser set as default (doesn't work on some devices)
|
||||
openAppChooser(context, intent, true);
|
||||
} else {
|
||||
try {
|
||||
intent.setPackage(defaultBrowserPackage);
|
||||
context.startActivity(intent);
|
||||
} catch (final ActivityNotFoundException e) {
|
||||
// Not a browser but an app chooser because of OEMs changes
|
||||
intent.setPackage(null);
|
||||
openAppChooser(context, intent, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -190,6 +173,18 @@ public final class ShareUtils {
|
|||
chooserIntent.putExtra(Intent.EXTRA_TITLE, context.getString(R.string.open_with));
|
||||
}
|
||||
|
||||
// Avoid opening in NewPipe
|
||||
// (Implementation note: if the URL is one for which NewPipe itself
|
||||
// is set as handler on Android >= 12, we actually remove the only eligible app
|
||||
// for this link, and browsers will not be offered to the user. For that, use
|
||||
// `openUrlInBrowser`.)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
chooserIntent.putExtra(
|
||||
Intent.EXTRA_EXCLUDE_COMPONENTS,
|
||||
new ComponentName[]{new ComponentName(context, RouterActivity.class)}
|
||||
);
|
||||
}
|
||||
|
||||
// Migrate any clip data and flags from the original intent.
|
||||
final int permFlags = intent.getFlags() & (Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||
|
|
|
@ -0,0 +1,113 @@
|
|||
package org.schabi.newpipe.util.potoken
|
||||
|
||||
import com.grack.nanojson.JsonObject
|
||||
import com.grack.nanojson.JsonParser
|
||||
import com.grack.nanojson.JsonWriter
|
||||
import okio.ByteString.Companion.decodeBase64
|
||||
import okio.ByteString.Companion.toByteString
|
||||
|
||||
/**
|
||||
* Parses the raw challenge data obtained from the Create endpoint and returns an object that can be
|
||||
* embedded in a JavaScript snippet.
|
||||
*/
|
||||
fun parseChallengeData(rawChallengeData: String): String {
|
||||
val scrambled = JsonParser.array().from(rawChallengeData)
|
||||
|
||||
val challengeData = if (scrambled.size > 1 && scrambled.isString(1)) {
|
||||
val descrambled = descramble(scrambled.getString(1))
|
||||
JsonParser.array().from(descrambled)
|
||||
} else {
|
||||
scrambled.getArray(0)
|
||||
}
|
||||
|
||||
val messageId = challengeData.getString(0)
|
||||
val interpreterHash = challengeData.getString(3)
|
||||
val program = challengeData.getString(4)
|
||||
val globalName = challengeData.getString(5)
|
||||
val clientExperimentsStateBlob = challengeData.getString(7)
|
||||
|
||||
val privateDoNotAccessOrElseSafeScriptWrappedValue = challengeData.getArray(1, null)?.find { it is String }
|
||||
val privateDoNotAccessOrElseTrustedResourceUrlWrappedValue = challengeData.getArray(2, null)?.find { it is String }
|
||||
|
||||
return JsonWriter.string(
|
||||
JsonObject.builder()
|
||||
.value("messageId", messageId)
|
||||
.`object`("interpreterJavascript")
|
||||
.value("privateDoNotAccessOrElseSafeScriptWrappedValue", privateDoNotAccessOrElseSafeScriptWrappedValue)
|
||||
.value("privateDoNotAccessOrElseTrustedResourceUrlWrappedValue", privateDoNotAccessOrElseTrustedResourceUrlWrappedValue)
|
||||
.end()
|
||||
.value("interpreterHash", interpreterHash)
|
||||
.value("program", program)
|
||||
.value("globalName", globalName)
|
||||
.value("clientExperimentsStateBlob", clientExperimentsStateBlob)
|
||||
.done()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the raw integrity token data obtained from the GenerateIT endpoint to a JavaScript
|
||||
* `Uint8Array` that can be embedded directly in JavaScript code, and an [Int] representing the
|
||||
* duration of this token in seconds.
|
||||
*/
|
||||
fun parseIntegrityTokenData(rawIntegrityTokenData: String): Pair<String, Long> {
|
||||
val integrityTokenData = JsonParser.array().from(rawIntegrityTokenData)
|
||||
return base64ToU8(integrityTokenData.getString(0)) to integrityTokenData.getLong(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a string (usually the identifier used as input to `obtainPoToken`) to a JavaScript
|
||||
* `Uint8Array` that can be embedded directly in JavaScript code.
|
||||
*/
|
||||
fun stringToU8(identifier: String): String {
|
||||
return newUint8Array(identifier.toByteArray())
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a poToken encoded as a sequence of bytes represented as integers separated by commas
|
||||
* (e.g. "97,98,99" would be "abc"), which is the output of `Uint8Array::toString()` in JavaScript,
|
||||
* and converts it to the specific base64 representation for poTokens.
|
||||
*/
|
||||
fun u8ToBase64(poToken: String): String {
|
||||
return poToken.split(",")
|
||||
.map { it.toUByte().toByte() }
|
||||
.toByteArray()
|
||||
.toByteString()
|
||||
.base64()
|
||||
.replace("+", "-")
|
||||
.replace("/", "_")
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes the scrambled challenge, decodes it from base64, adds 97 to each byte.
|
||||
*/
|
||||
private fun descramble(scrambledChallenge: String): String {
|
||||
return base64ToByteString(scrambledChallenge)
|
||||
.map { (it + 97).toByte() }
|
||||
.toByteArray()
|
||||
.decodeToString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a base64 string encoded in the specific base64 representation used by YouTube, and
|
||||
* returns a JavaScript `Uint8Array` that can be embedded directly in JavaScript code.
|
||||
*/
|
||||
private fun base64ToU8(base64: String): String {
|
||||
return newUint8Array(base64ToByteString(base64))
|
||||
}
|
||||
|
||||
private fun newUint8Array(contents: ByteArray): String {
|
||||
return "new Uint8Array([" + contents.joinToString(separator = ",") { it.toUByte().toString() } + "])"
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes a base64 string encoded in the specific base64 representation used by YouTube.
|
||||
*/
|
||||
private fun base64ToByteString(base64: String): ByteArray {
|
||||
val base64Mod = base64
|
||||
.replace('-', '+')
|
||||
.replace('_', '/')
|
||||
.replace('.', '=')
|
||||
|
||||
return (base64Mod.decodeBase64() ?: throw PoTokenException("Cannot base64 decode"))
|
||||
.toByteArray()
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
package org.schabi.newpipe.util.potoken
|
||||
|
||||
class PoTokenException(message: String) : Exception(message)
|
||||
|
||||
// to be thrown if the WebView provided by the system is broken
|
||||
class BadWebViewException(message: String) : Exception(message)
|
||||
|
||||
fun buildExceptionForJsError(error: String): Exception {
|
||||
return if (error.contains("SyntaxError"))
|
||||
BadWebViewException(error)
|
||||
else
|
||||
PoTokenException(error)
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue