1
0
Fork 0
mirror of https://github.com/TeamNewPipe/NewPipe.git synced 2025-10-04 10:19:18 +02:00

Compare commits

..

No commits in common. "dev" and "v0.27.5" have entirely different histories.
dev ... v0.27.5

552 changed files with 2865 additions and 9858 deletions

View file

@ -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 bug report for GitHub.
This way all the data needed for debugging is included in your bugreport for GitHub.
You'll see *exactly* what is sent, be able to add **your comments**, and then send it.
## Issue reporting/feature requests

View file

@ -14,7 +14,7 @@ jobs:
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
java-version: '17'
cache: 'gradle'
- name: "Build release APK"

View file

@ -72,8 +72,8 @@ jobs:
- api-level: 21
target: default
arch: x86
- api-level: 35
target: default
- api-level: 33
target: google_apis # emulator API 33 only exists with Google APIs
arch: x86_64
permissions:
@ -111,7 +111,6 @@ 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:

View file

@ -32,12 +32,12 @@ module.exports = async ({github, context}) => {
}
// Regex for finding images (simple variant) ![ALT_TEXT](https://*.githubusercontent.com/<number>/<variousHexStringsAnd->.<fileExtension>)
const REGEX_USER_CONTENT_IMAGE_LOOKUP = /\!\[([^\]]*)\]\((https:\/\/[-a-z0-9]+\.githubusercontent\.com\/\d+\/[-0-9a-f]{32,512}\.(jpg|gif|png))\)/gm;
const REGEX_ASSETS_IMAGE_LOOKUP = /\!\[([^\]]*)\]\((https:\/\/github\.com\/(?:user-attachments\/assets|[-\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_LOCKUP = /\!\[(.*)\]\((https:\/\/github\.com\/[-\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_LOOKUP.test(initialBody);
|| REGEX_ASSETS_IMAGE_LOCKUP.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_LOOKUP, minimizeAsync);
newBody = await replaceAsync(newBody, REGEX_ASSETS_IMAGE_LOCKUP, minimizeAsync);
if (!wasMatchModified) {
console.log('Nothing was modified. Skipping update');

View file

@ -1,26 +1,20 @@
<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>
<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>
<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" width=206/></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" height=80/></a></p>
<p align="center">
<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://github.com/TeamNewPipe/NewPipe/releases" alt="GitHub release"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe.svg" ></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/actions/workflows/ci.yml/badge.svg?branch=dev&event=push"></a>
<a href="https://github.com/TeamNewPipe/NewPipe/actions" alt="Build Status"><img src="https://github.com/TeamNewPipe/NewPipe/workflows/CI/badge.svg?branch=dev&event=push"></a>
<a href="https://hosted.weblate.org/engage/newpipe/" alt="Translation Status"><img src="https://hosted.weblate.org/widgets/newpipe/-/svg-badge.svg"></a>
</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> &bull; <a href="#supported-services">Supported Services</a> &bull; <a href="#description">Description</a> &bull; <a href="#features">Features</a> &bull; <a href="#installation-and-updates">Installation and updates</a> &bull; <a href="#contribution">Contribution</a> &bull; <a href="#donate">Donate</a> &bull; <a href="#license">License</a></p>
<p align="center"><a href="https://newpipe.net">Website</a> &bull; <a href="https://newpipe.net/blog/">Blog</a> &bull; <a href="https://newpipe.net/FAQ/">FAQ</a> &bull; <a href="https://newpipe.net/press/">Press</a></p>

View file

@ -12,20 +12,20 @@ plugins {
}
android {
compileSdk 36
compileSdk 34
namespace 'org.schabi.newpipe'
defaultConfig {
applicationId "org.schabi.newpipe"
resValue "string", "app_name", "NewPipe"
minSdk 21
targetSdk 35
targetSdk 33
if (System.properties.containsKey('versionCodeOverride')) {
versionCode System.getProperty('versionCodeOverride') as Integer
} else {
versionCode 1005
versionCode 1002
}
versionName "0.28.0"
versionName "0.27.5"
if (System.properties.containsKey('versionNameSuffix')) {
versionNameSuffix System.getProperty('versionNameSuffix')
}
@ -97,10 +97,6 @@ android {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
androidResources {
generateLocaleConfig = true
}
buildFeatures {
viewBinding true
buildConfig true
@ -209,12 +205,10 @@ 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:e9d656ddb49a412a5a0a5d5ef20ca7ef09549996'
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
// WORKAROUND: if you get errors with the NewPipeExtractor dependency, replace `v0.24.3` with
// the corresponding commit hash, since JitPack sometimes deletes artifacts.
// If theres 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'
// the corresponding commit hash, since JitPack is sometimes buggy
implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.24.4'
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
/** Checkstyle **/
@ -225,7 +219,7 @@ dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib:${kotlin_version}"
/** AndroidX **/
implementation 'androidx.appcompat:appcompat:1.7.1'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.core:core-ktx:1.12.0'
@ -247,7 +241,6 @@ 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
@ -280,7 +273,7 @@ dependencies {
implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}"
// Image loading
//noinspection NewerVersionAvailable,GradleDependency --> 2.8 is the last version, not 2.71828!
//noinspection GradleDependency --> 2.8 is the last version, not 2.71828!
implementation "com.squareup.picasso:picasso:2.8"
// Markdown library for Android

View file

@ -5,17 +5,10 @@
## 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.** { *; }

View file

@ -12,7 +12,6 @@ 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;
@ -24,23 +23,8 @@ 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() throws NoSuchFieldException, IllegalAccessException {
public void errorInfoTestParcelable() {
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:
@ -55,7 +39,7 @@ public class ErrorInfoTest {
assertEquals(ServiceList.YouTube.getServiceInfo().getName(),
infoFromParcel.getServiceName());
assertEquals("request", infoFromParcel.getRequest());
assertEquals(R.string.parsing_error, getMessageFromErrorInfo(infoFromParcel));
assertEquals(R.string.parsing_error, infoFromParcel.getMessageStringId());
parcel.recycle();
}

View file

@ -9,8 +9,6 @@
<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+ -->
@ -59,15 +57,6 @@
</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"
@ -75,9 +64,6 @@
<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
@ -96,22 +82,9 @@
android:exported="false"
android:label="@string/title_activity_about" />
<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" />
<service android:name=".local.subscription.services.SubscriptionsImportService" />
<service android:name=".local.subscription.services.SubscriptionsExportService" />
<service android:name=".local.feed.service.FeedLoadService" />
<activity
android:name=".PanicResponderActivity"
@ -143,8 +116,7 @@
android:label="@string/app_name"
android:launchMode="singleTask" />
<service android:name="us.shandian.giga.service.DownloadManagerService"
android:foregroundServiceType="dataSync" />
<service android:name="us.shandian.giga.service.DownloadManagerService" />
<activity
android:name=".util.FilePickerActivityHelper"
@ -437,7 +409,6 @@
</activity>
<service
android:name=".RouterActivity$FetcherService"
android:foregroundServiceType="dataSync"
android:exported="false" />
<!-- opting out of sending metrics to Google in Android System WebView -->
@ -453,10 +424,5 @@
<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>

View file

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

View file

@ -17,7 +17,6 @@ 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;
@ -27,7 +26,6 @@ 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;
@ -102,7 +100,7 @@ public class App extends Application {
NewPipe.init(getDownloader(),
Localization.getPreferredLocalization(this),
Localization.getPreferredContentCountry(this));
Localization.initPrettyTime(Localization.resolvePrettyTime());
Localization.initPrettyTime(Localization.resolvePrettyTime(getApplicationContext()));
BridgeStateSaverInitializer.init(this);
StateSaver.init(this);
@ -120,8 +118,6 @@ public class App extends Application {
&& prefs.getBoolean(getString(R.string.show_image_indicators_key), false));
configureRxJavaErrorHandler();
YoutubeStreamExtractor.setPoTokenProvider(PoTokenProviderImpl.INSTANCE);
}
@Override

View file

@ -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:140.0) Gecko/20100101 Firefox/140.0";
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.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,8 +137,7 @@ 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);
@ -146,33 +145,38 @@ public final class DownloaderImpl extends Downloader {
requestBuilder.addHeader("Cookie", cookies);
}
headers.forEach((headerName, headerValueList) -> {
requestBuilder.removeHeader(headerName);
headerValueList.forEach(headerValue ->
requestBuilder.addHeader(headerName, headerValue));
});
for (final Map.Entry<String, List<String>> pair : headers.entrySet()) {
final String headerName = pair.getKey();
final List<String> headerValueList = pair.getValue();
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));
}
}
final okhttp3.Response response = client.newCall(requestBuilder.build()).execute();
try (
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;
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);
}
return new Response(response.code(), response.message(), response.headers().toMultimap(),
responseBodyToReturn, latestUrl);
}
}

View file

@ -20,6 +20,8 @@
package org.schabi.newpipe;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
@ -36,7 +38,6 @@ 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;
@ -48,7 +49,6 @@ 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,7 +80,6 @@ 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;
@ -93,7 +92,6 @@ 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;
@ -122,14 +120,10 @@ 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_DONATION = 1;
private static final int ITEM_ID_ABOUT = 2;
private static final int ITEM_ID_ABOUT = 1;
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
//////////////////////////////////////////////////////////////////////////*/
@ -141,26 +135,11 @@ public class MainActivity extends AppCompatActivity {
+ "savedInstanceState = [" + savedInstanceState + "]");
}
Localization.migrateAppLanguageSettingIfNecessary(getApplicationContext());
ThemeHelper.setDayNightMode(this);
ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(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);
}
}
}
assureCorrectAppLanguage(this);
super.onCreate(savedInstanceState);
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
sharedPrefEditor = sharedPreferences.edit();
mainBinding = ActivityMainBinding.inflate(getLayoutInflater());
drawerLayoutBinding = mainBinding.drawerLayout;
@ -195,8 +174,6 @@ public class MainActivity extends AppCompatActivity {
&& ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
UpdateSettingsFragment.askForConsentToUpdateChecks(this);
}
MigrationManager.showUserInfoIfPresent(this);
}
@Override
@ -204,29 +181,16 @@ public class MainActivity extends AppCompatActivity {
super.onPostCreate(savedInstanceState);
final App app = App.getApp();
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
if (sharedPreferences.getBoolean(app.getString(R.string.update_app_key), false)
&& sharedPreferences
.getBoolean(app.getString(R.string.update_check_consent_key), false)) {
if (prefs.getBoolean(app.getString(R.string.update_app_key), false)
&& prefs.getBoolean(app.getString(R.string.update_check_consent_key), false)) {
// Start the worker which is checking all conditions
// and eventually searching for a new version.
NewVersionWorker.enqueueNewVersionCheckingWork(app, 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();
@ -264,6 +228,19 @@ 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)
@ -281,28 +258,10 @@ 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);
@ -314,13 +273,10 @@ public class MainActivity extends AppCompatActivity {
changeService(item);
break;
case R.id.menu_tabs_group:
tabSelected(item);
break;
case R.id.menu_kiosks_group:
try {
kioskSelected(item);
tabSelected(item);
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(this, "Selecting drawer kiosk", e);
ErrorUtil.showUiErrorSnackbar(this, "Selecting main page tab", e);
}
break;
case R.id.menu_options_about_group:
@ -344,7 +300,7 @@ public class MainActivity extends AppCompatActivity {
.setChecked(true);
}
private void tabSelected(final MenuItem item) {
private void tabSelected(final MenuItem item) throws ExtractionException {
switch (item.getItemId()) {
case ITEM_ID_SUBSCRIPTIONS:
NavigationHelper.openSubscriptionFragment(getSupportFragmentManager());
@ -361,10 +317,7 @@ public class MainActivity extends AppCompatActivity {
case ITEM_ID_HISTORY:
NavigationHelper.openStatisticFragment(getSupportFragmentManager());
break;
}
}
private void kioskSelected(final MenuItem item) throws ExtractionException {
default:
final StreamingService currentService = ServiceHelper.getSelectedService(this);
int kioskMenuItemId = 0;
for (final String kioskId : currentService.getKioskList().getAvailableKiosks()) {
@ -375,6 +328,8 @@ public class MainActivity extends AppCompatActivity {
}
kioskMenuItemId++;
}
break;
}
}
private void optionsAboutSelected(final MenuItem item) {
@ -382,9 +337,6 @@ 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;
@ -414,7 +366,6 @@ 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
@ -508,8 +459,9 @@ 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());
Localization.initPrettyTime(Localization.resolvePrettyTime(getApplicationContext()));
super.onResume();
// Close drawer on return, and don't show animation,
@ -531,11 +483,13 @@ 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...");
}
sharedPrefEditor.putBoolean(Constants.KEY_THEME_CHANGE, false).apply();
sharedPreferences.edit().putBoolean(Constants.KEY_THEME_CHANGE, false).apply();
ActivityCompat.recreate(this);
}
@ -543,7 +497,7 @@ public class MainActivity extends AppCompatActivity {
if (DEBUG) {
Log.d(TAG, "main page has changed, recreating main fragment...");
}
sharedPrefEditor.putBoolean(Constants.KEY_MAIN_PAGE_CHANGE, false).apply();
sharedPreferences.edit().putBoolean(Constants.KEY_MAIN_PAGE_CHANGE, false).apply();
NavigationHelper.openMainActivity(this);
}
@ -885,8 +839,7 @@ public class MainActivity extends AppCompatActivity {
@Override
public void onReceive(final Context context, final Intent intent) {
if (Objects.equals(intent.getAction(),
VideoDetailFragment.ACTION_PLAYER_STARTED)
&& PlayerHolder.getInstance().isPlayerOpen()) {
VideoDetailFragment.ACTION_PLAYER_STARTED)) {
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.
@ -897,12 +850,7 @@ public class MainActivity extends AppCompatActivity {
};
final IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(VideoDetailFragment.ACTION_PLAYER_STARTED);
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);
registerReceiver(broadcastReceiver, intentFilter);
}
}
@ -976,5 +924,4 @@ public class MainActivity extends AppCompatActivity {
return sheetState == BottomSheetBehavior.STATE_HIDDEN
|| sheetState == BottomSheetBehavior.STATE_COLLAPSED;
}
}

View file

@ -58,10 +58,20 @@ 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;
@ -74,6 +84,7 @@ 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;
@ -121,6 +132,7 @@ 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
@ -250,8 +262,7 @@ public class RouterActivity extends AppCompatActivity {
showUnsupportedUrlDialog(url);
}
}, throwable -> handleError(this, new ErrorInfo(throwable,
UserAction.SHARE_TO_NEWPIPE, "Getting service from url: " + url,
null, url))));
UserAction.SHARE_TO_NEWPIPE, "Getting service from url: " + url))));
}
/**
@ -260,19 +271,40 @@ public class RouterActivity extends AppCompatActivity {
* @param errorInfo the error information
*/
private static void handleError(final Context context, final ErrorInfo errorInfo) {
if (errorInfo.getRecaptchaUrl() != null) {
if (errorInfo.getThrowable() != null) {
errorInfo.getThrowable().printStackTrace();
}
if (errorInfo.getThrowable() instanceof ReCaptchaException) {
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.isReportable()) {
ErrorUtil.createNotification(context, errorInfo);
} 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 {
// 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();
ErrorUtil.createNotification(context, errorInfo);
}
if (context instanceof RouterActivity) {
@ -635,8 +667,7 @@ public class RouterActivity extends AppCompatActivity {
startActivity(intent);
finish();
}, throwable -> handleError(this, new ErrorInfo(throwable,
UserAction.SHARE_TO_NEWPIPE, "Starting info activity: " + currentUrl,
null, currentUrl)))
UserAction.SHARE_TO_NEWPIPE, "Starting info activity: " + currentUrl)))
);
return;
}
@ -823,10 +854,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(),
currentUrl)
((RouterActivity) ctx).currentService.getServiceId())
))
)
);
@ -966,7 +997,7 @@ public class RouterActivity extends AppCompatActivity {
}
}, throwable -> handleError(this, new ErrorInfo(throwable, finalUserAction,
choice.url + " opened with " + choice.playerChoice,
choice.serviceId, choice.url)));
choice.serviceId)));
}
}

View file

@ -16,12 +16,14 @@ 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)

View file

@ -19,6 +19,7 @@ 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
/**
@ -99,6 +100,7 @@ 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)

View file

@ -3,8 +3,6 @@ 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(
@ -29,17 +27,4 @@ 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)
}
}

View file

@ -1,7 +1,5 @@
package org.schabi.newpipe.database.playlist;
import androidx.annotation.Nullable;
import org.schabi.newpipe.database.LocalItem;
public interface PlaylistLocalItem extends LocalItem {
@ -12,7 +10,4 @@ public interface PlaylistLocalItem extends LocalItem {
long getUid();
void setDisplayIndex(long displayIndex);
@Nullable
String getThumbnailUrl();
}

View file

@ -9,8 +9,6 @@ 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";
@ -73,10 +71,4 @@ public class PlaylistMetadataEntry implements PlaylistLocalItem {
public void setDisplayIndex(final long displayIndex) {
this.displayIndex = displayIndex;
}
@Nullable
@Override
public String getThumbnailUrl() {
return thumbnailUrl;
}
}

View file

@ -34,7 +34,7 @@ public interface PlaylistRemoteDAO extends BasicDAO<PlaylistRemoteEntity> {
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
+ REMOTE_PLAYLIST_ID + " = :playlistId")
Flowable<PlaylistRemoteEntity> getPlaylist(long playlistId);
Flowable<List<PlaylistRemoteEntity>> getPlaylist(long playlistId);
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
+ REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")

View file

@ -2,7 +2,6 @@ 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;
@ -135,8 +134,6 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
this.name = name;
}
@Nullable
@Override
public String getThumbnailUrl() {
return thumbnailUrl;
}

View file

@ -20,6 +20,8 @@ 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";
@ -31,6 +33,7 @@ public class DownloadActivity extends AppCompatActivity {
i.setClass(this, DownloadManagerService.class);
startService(i);
assureCorrectAppLanguage(this);
ThemeHelper.setTheme(this);
super.onCreate(savedInstanceState);

View file

@ -2,6 +2,7 @@ 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;
@ -389,7 +390,8 @@ public class DownloadDialog extends DialogFragment
}
}, throwable -> ErrorUtil.showSnackbar(context,
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
"Downloading video stream size", currentInfo))));
"Downloading video stream size",
currentInfo.getServiceId()))));
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(getWrappedAudioStreams())
.subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
@ -398,7 +400,8 @@ public class DownloadDialog extends DialogFragment
}
}, throwable -> ErrorUtil.showSnackbar(context,
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
"Downloading audio stream size", currentInfo))));
"Downloading audio stream size",
currentInfo.getServiceId()))));
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedSubtitleStreams)
.subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
@ -407,7 +410,8 @@ public class DownloadDialog extends DialogFragment
}
}, throwable -> ErrorUtil.showSnackbar(context,
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
"Downloading subtitle stream size", currentInfo))));
"Downloading subtitle stream size",
currentInfo.getServiceId()))));
}
private void setupAudioTrackSpinner() {
@ -747,6 +751,7 @@ 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)

View file

@ -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));
}
}

View file

@ -1,5 +1,7 @@
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;
@ -24,7 +26,7 @@ import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.time.ZonedDateTime;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.stream.Collectors;
@ -65,6 +67,10 @@ 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;
@ -77,6 +83,7 @@ public class ErrorActivity extends AppCompatActivity {
@Override
protected void onCreate(final Bundle savedInstanceState) {
assureCorrectAppLanguage(this);
super.onCreate(savedInstanceState);
ThemeHelper.setDayNightMode(this);
@ -100,9 +107,7 @@ public class ErrorActivity extends AppCompatActivity {
// important add guru meditation
addGuruMeditation();
// print current time, as zoned ISO8601 timestamp
final ZonedDateTime now = ZonedDateTime.now();
currentTimeStamp = now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME);
currentTimeStamp = CURRENT_TIMESTAMP_FORMATTER.format(LocalDateTime.now());
activityErrorBinding.errorReportEmailButton.setOnClickListener(v ->
openPrivacyPolicyDialog(this, "EMAIL"));
@ -115,7 +120,7 @@ public class ErrorActivity extends AppCompatActivity {
// normal bugreport
buildInfo(errorInfo);
activityErrorBinding.errorMessageView.setText(errorInfo.getMessage(this));
activityErrorBinding.errorMessageView.setText(errorInfo.getMessageStringId());
activityErrorBinding.errorView.setText(formErrorText(errorInfo.getStackTraces()));
// print stack trace once again for debugging:
@ -245,9 +250,6 @@ 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");
@ -303,7 +305,7 @@ public class ErrorActivity extends AppCompatActivity {
}
private String getAppLanguage() {
return Localization.getAppLocale().toString();
return Localization.getAppLocale(getApplicationContext()).toString();
}
private String getOsString() {

View file

@ -1,304 +1,115 @@
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 com.google.android.exoplayer2.upstream.HttpDataSource
import com.google.android.exoplayer2.upstream.Loader
import kotlinx.parcelize.IgnoredOnParcel
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.player.mediasource.FailedMediaSource
import org.schabi.newpipe.player.resolver.PlaybackResolver
import java.net.UnknownHostException
import org.schabi.newpipe.util.ServiceHelper
/**
* 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 private constructor(
class ErrorInfo(
val stackTraces: Array<String>,
val userAction: UserAction,
val serviceName: String,
val request: String,
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?,
val messageStringId: Int
) : Parcelable {
@JvmOverloads
constructor(
// 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(
throwable: Throwable,
userAction: UserAction,
request: String,
serviceId: Int? = null,
openInBrowserUrl: String? = null,
serviceName: String,
request: String
) : this(
throwableToStringList(throwable),
userAction,
serviceName,
request,
serviceId,
getMessage(throwable, userAction, serviceId),
isReportable(throwable),
isRetryable(throwable),
(throwable as? ReCaptchaException)?.url,
openInBrowserUrl,
)
getMessageStringId(throwable, userAction)
) {
this.throwable = throwable
}
@JvmOverloads
constructor(
throwables: List<Throwable>,
private constructor(
throwable: List<Throwable>,
userAction: UserAction,
request: String,
serviceId: Int? = null,
openInBrowserUrl: String? = null,
serviceName: String,
request: String
) : this(
throwableListToStringList(throwables),
throwableListToStringList(throwable),
userAction,
serviceName,
request,
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)
getMessageStringId(throwable.firstOrNull(), userAction)
) {
this.throwable = throwable.firstOrNull()
}
fun getMessage(context: Context): String {
return message.getString(context)
}
// 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)
companion object {
@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
const val SERVICE_NONE = "none"
fun throwableToStringList(throwable: Throwable) = arrayOf(throwable.stackTraceToString())
fun throwableListToStringList(throwableList: List<Throwable>) =
throwableList.map { it.stackTraceToString() }.toTypedArray()
fun getMessage(
private fun getInfoServiceName(info: Info?) =
if (info == null) SERVICE_NONE else ServiceHelper.getNameOfServiceById(info.serviceId)
@StringRes
private fun getMessageStringId(
throwable: Throwable?,
action: UserAction?,
serviceId: Int?,
): ErrorMessage {
action: UserAction
): Int {
return when {
// player exceptions
// some may be IOException, so do these checks before isNetworkRelated!
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
throwable is ExoPlaybackException -> {
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())
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
}
}
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)
}
}
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
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
}
}
}

View file

@ -2,6 +2,7 @@ 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
@ -13,14 +14,28 @@ 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!!
@ -41,16 +56,13 @@ class ErrorPanelHelper(
errorPanelRoot.findViewById(R.id.error_open_in_browser)
private var errorDisposable: Disposable? = null
private var retryShouldBeShown: Boolean = (onRetry != null)
init {
if (onRetry != null) {
errorDisposable = errorRetryButton.clicks()
.debounce(300, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe { onRetry.run() }
}
}
private fun ensureDefaultVisibility() {
errorTextView.isVisible = true
@ -63,32 +75,64 @@ class ErrorPanelHelper(
}
fun showError(errorInfo: ErrorInfo) {
ensureDefaultVisibility()
errorTextView.text = errorInfo.getMessage(context)
if (errorInfo.recaptchaUrl != null) {
showAndSetErrorButtonAction(R.string.recaptcha_solve) {
if (errorInfo.throwable != null && errorInfo.throwable!!.isInterruptedCaused) {
if (DEBUG) {
Log.w(TAG, "onError() isInterruptedCaused! = [$errorInfo.throwable]")
}
return
}
ensureDefaultVisibility()
if (errorInfo.throwable is ReCaptchaException) {
errorTextView.setText(R.string.recaptcha_request_toast)
showAndSetErrorButtonAction(
R.string.recaptcha_solve
) {
// Starting ReCaptcha Challenge Activity
val intent = Intent(context, ReCaptchaActivity::class.java)
intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, errorInfo.recaptchaUrl)
intent.putExtra(
ReCaptchaActivity.RECAPTCHA_URL_EXTRA,
(errorInfo.throwable as ReCaptchaException).url
)
fragment.startActivityForResult(intent, ReCaptchaActivity.RECAPTCHA_REQUEST)
errorActionButton.setOnClickListener(null)
}
} else if (errorInfo.isReportable) {
showAndSetErrorButtonAction(R.string.error_snackbar_action) {
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
) {
ErrorUtil.openActivity(context, errorInfo)
}
}
if (errorInfo.isRetryable) {
errorRetryButton.isVisible = retryShouldBeShown
}
errorTextView.setText(getExceptionDescription(errorInfo.throwable))
if (errorInfo.openInBrowserUrl != null) {
errorOpenInBrowserButton.isVisible = true
errorOpenInBrowserButton.setOnClickListener {
ShareUtils.openUrlInBrowser(context, errorInfo.openInBrowserUrl)
if (errorInfo.throwable !is ContentNotAvailableException &&
errorInfo.throwable !is ContentNotSupportedException
) {
// show retry button only for content which is not unavailable or unsupported
errorRetryButton.isVisible = true
}
showAndSetOpenInBrowserButtonAction(errorInfo)
}
setRootVisible()
@ -106,6 +150,15 @@ class ErrorPanelHelper(
errorActionButton.setOnClickListener(listener)
}
fun showAndSetOpenInBrowserButtonAction(
errorInfo: ErrorInfo
) {
errorOpenInBrowserButton.isVisible = true
errorOpenInBrowserButton.setOnClickListener {
ShareUtils.openUrlInBrowser(context, errorInfo.request)
}
}
fun showTextError(errorString: String) {
ensureDefaultVisibility()
@ -136,5 +189,27 @@ 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
}
}
}
}
}
}

View file

@ -10,11 +10,8 @@ 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
/**
@ -38,21 +35,13 @@ 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) {
if (PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(MainActivity.KEY_IS_IN_BACKGROUND, true)
) {
createNotification(context, errorInfo)
} else {
context.startActivity(getErrorActivityIntent(context, errorInfo))
}
}
/**
* Show a bottom snackbar to the user, with a report button that opens the error activity.
@ -122,7 +111,7 @@ class ErrorUtil {
)
.setSmallIcon(R.drawable.ic_bug_report)
.setContentTitle(context.getString(R.string.error_report_notification_title))
.setContentText(errorInfo.getMessage(context))
.setContentText(context.getString(errorInfo.messageStringId))
.setAutoCancel(true)
.setContentIntent(
PendingIntentCompat.getActivity(
@ -137,12 +126,10 @@ class ErrorUtil {
NotificationManagerCompat.from(context)
.notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build())
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 {
val intent = Intent(context, ErrorActivity::class.java)
@ -156,10 +143,10 @@ class ErrorUtil {
// fallback to showing a notification if no root view is available
createNotification(context, errorInfo)
} else {
Snackbar.make(rootView, errorInfo.getMessage(context), Snackbar.LENGTH_LONG)
Snackbar.make(rootView, R.string.error_snackbar_message, Snackbar.LENGTH_LONG)
.setActionTextColor(Color.YELLOW)
.setAction(context.getString(R.string.error_snackbar_action).uppercase()) {
context.startActivity(getErrorActivityIntent(context, errorInfo))
openActivity(context, errorInfo)
}.show()
}
}

View file

@ -32,10 +32,7 @@ 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"),
GETTING_MAIN_SCREEN_TAB("getting main screen tab"),
PLAY_ON_POPUP("play on popup"),
SUBSCRIPTIONS("loading subscriptions");
OPEN_INFO_ITEM_DIALOG("open info item dialog");
private final String message;

View file

@ -7,57 +7,16 @@ 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");
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;
}
return inflater.inflate(R.layout.fragment_blank, container, false);
}
@Override

View file

@ -36,9 +36,8 @@ 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.error.UserAction;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
import org.schabi.newpipe.settings.tabs.Tab;
import org.schabi.newpipe.settings.tabs.TabsManager;
@ -304,9 +303,9 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
final Fragment fragment;
try {
fragment = tab.getFragment(context);
} catch (final Throwable t) {
return new BlankFragment(new ErrorInfo(t, UserAction.GETTING_MAIN_SCREEN_TAB,
"Tab " + tab.getClass().getSimpleName() + ":" + tab.getTabName(context)));
} catch (final ExtractionException e) {
ErrorUtil.showUiErrorSnackbar(context, "Getting fragment item", e);
return new BlankFragment();
}
if (fragment instanceof BaseFragment) {

View file

@ -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()));
streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale(getContext())));
}
addMetadataItem(inflater, layout, true, R.string.metadata_support,

View file

@ -93,7 +93,6 @@ 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;
@ -237,14 +236,11 @@ public final class VideoDetailFragment
// Service management
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onServiceConnected(@NonNull final PlayerService connectedPlayerService) {
playerService = connectedPlayerService;
}
@Override
public void onPlayerConnected(@NonNull final Player connectedPlayer,
public void onServiceConnected(final Player connectedPlayer,
final PlayerService connectedPlayerService,
final boolean playAfterConnect) {
player = connectedPlayer;
playerService = connectedPlayerService;
// It will do nothing if the player is not in fullscreen mode
hideSystemUiIfNeeded();
@ -276,29 +272,22 @@ 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 url,
@Nullable final String videoUrl,
@NonNull final String name,
@Nullable final PlayQueue queue) {
final VideoDetailFragment instance = new VideoDetailFragment();
instance.setInitialData(serviceId, url, name, queue);
instance.setInitialData(serviceId, videoUrl, name, queue);
return instance;
}
@ -877,7 +866,7 @@ public final class VideoDetailFragment
}
}
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_STREAM,
url == null ? "no url" : url, serviceId, url)));
url == null ? "no url" : url, serviceId)));
}
/*//////////////////////////////////////////////////////////////////////////
@ -1167,12 +1156,8 @@ public final class VideoDetailFragment
final PlayQueue queue = setupPlayQueueForIntent(false);
tryAddVideoPlayerView();
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);
final Intent playerIntent = NavigationHelper.getPlayerIntent(requireContext(),
PlayerService.class, queue, true, autoPlayEnabled);
ContextCompat.startForegroundService(activity, playerIntent);
}
@ -1416,8 +1401,10 @@ public final class VideoDetailFragment
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
}
// Rebound to the service if it was closed via notification or mini player
playerHolder.setListener(VideoDetailFragment.this);
playerHolder.tryBindIfNeeded(context);
if (!playerHolder.isBound()) {
playerHolder.startService(
false, VideoDetailFragment.this);
}
break;
}
}
@ -1426,8 +1413,7 @@ public final class VideoDetailFragment
intentFilter.addAction(ACTION_SHOW_MAIN_PLAYER);
intentFilter.addAction(ACTION_HIDE_MAIN_PLAYER);
intentFilter.addAction(ACTION_PLAYER_STARTED);
ContextCompat.registerReceiver(activity, broadcastReceiver, intentFilter,
ContextCompat.RECEIVER_EXPORTED);
activity.registerReceiver(broadcastReceiver, intentFilter);
}
@ -1596,8 +1582,8 @@ public final class VideoDetailFragment
}
if (!info.getErrors().isEmpty()) {
showSnackBarError(new ErrorInfo(info.getErrors(), UserAction.REQUESTED_STREAM,
"Some info not extracted: " + info.getUrl(), info));
showSnackBarError(new ErrorInfo(info.getErrors(),
UserAction.REQUESTED_STREAM, info.getUrl(), info));
}
}
@ -1750,7 +1736,7 @@ public final class VideoDetailFragment
playQueue = queue;
if (DEBUG) {
Log.d(TAG, "onQueueUpdate() called with: serviceId = ["
+ serviceId + "], url = [" + url + "], name = ["
+ serviceId + "], videoUrl = [" + url + "], name = ["
+ title + "], playQueue = [" + playQueue + "]");
}
@ -1862,8 +1848,6 @@ public final class VideoDetailFragment
@Override
public void onServiceStopped() {
// the binding could be null at this point, if the app is finishing
if (binding != null) {
setOverlayPlayPauseImage(false);
if (currentInfo != null) {
updateOverlayData(currentInfo.getName(),
@ -1872,7 +1856,6 @@ public final class VideoDetailFragment
}
updateOverlayPlayQueueButtonVisibility();
}
}
@Override
public void onFullscreenStateChanged(final boolean fullscreen) {

View file

@ -8,7 +8,6 @@ import android.util.Log;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.evernote.android.state.State;
@ -43,7 +42,6 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
private final UserAction errorUserAction;
protected L currentInfo;
@Nullable
protected Page currentNextPage;
protected Disposable currentWorker;
@ -153,7 +151,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
handleResult(result);
}, throwable ->
showError(new ErrorInfo(throwable, errorUserAction,
"Start loading: " + url, serviceId, url)));
"Start loading: " + url, serviceId)));
}
/**
@ -184,7 +182,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, url)));
errorUserAction, "Loading more items: " + url, serviceId)));
}
private void forbidDownwardFocusScroll() {
@ -210,7 +208,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, url));
"Get next items of: " + url, serviceId));
}
}
@ -250,7 +248,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
if (!errors.isEmpty()) {
dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(result.getErrors(),
errorUserAction, "Start loading: " + url, serviceId, url));
errorUserAction, "Start loading: " + url, serviceId));
}
}
}

View file

@ -81,7 +81,9 @@ public class ChannelAboutFragment extends BaseDescriptionFragment {
if (channelInfo.getSubscriberCount() != UNKNOWN_SUBSCRIBER_COUNT) {
addMetadataItem(inflater, layout, false, R.string.metadata_subscribers,
Localization.localizeNumber(channelInfo.getSubscriberCount()));
Localization.localizeNumber(
requireContext(),
channelInfo.getSubscriberCount()));
}
addImagesMetadataItem(inflater, layout, R.string.metadata_avatars,

View file

@ -121,22 +121,8 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onAttach(@NonNull final Context context) {
super.onAttach(context);
subscriptionManager = new SubscriptionManager(activity);
}
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
binding = FragmentChannelBinding.inflate(inflater, container, false);
return binding.getRoot();
}
@Override
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
super.onViewCreated(rootView, savedInstanceState);
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
menuProvider = new MenuProvider() {
@Override
public void onCreateMenu(@NonNull final Menu menu,
@ -195,6 +181,20 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
activity.addMenuProvider(menuProvider);
}
@Override
public void onAttach(@NonNull final Context context) {
super.onAttach(context);
subscriptionManager = new SubscriptionManager(activity);
}
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
binding = FragmentChannelBinding.inflate(inflater, container, false);
return binding.getRoot();
}
@Override // called from onViewCreated in BaseFragment.onViewCreated
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
@ -232,14 +232,6 @@ 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();
@ -248,6 +240,7 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
}
disposables.clear();
binding = null;
activity.removeMenuProvider(menuProvider);
menuProvider = null;
}
@ -577,7 +570,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)));
url == null ? "No URL" : url, serviceId)));
}
@Override

View file

@ -28,7 +28,6 @@ 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;
@ -111,7 +110,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();
};
}

View file

@ -54,7 +54,6 @@ 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;
@ -145,7 +144,6 @@ 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;
@ -221,15 +219,6 @@ 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();
}
@ -935,21 +924,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
infoListAdapter.clearStreamItemList();
showEmptyState();
} else {
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;
showError(new ErrorInfo(exception, UserAction.SEARCHED, searchString, serviceId));
}
}
@ -961,20 +936,6 @@ 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)) {
@ -1037,7 +998,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, getOpenInBrowserUrlForErrors()));
searchString, serviceId));
}
searchSuggestion = result.getSearchSuggestion();
@ -1104,26 +1065,15 @@ 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()) {
// 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()));
serviceId));
}
}
// 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);
}

View file

@ -101,16 +101,14 @@ 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);
final String uploaderName = Localization.localizeUserName(item.getUploaderName());
itemTitleView.setText(Localization.concatenateStrings(
uploaderName,
Localization.relativeTimeOrTextual(
itemBuilder.getContext(),
item.getUploadDate(),
itemTitleView.setText(Localization.concatenateStrings(item.getUploaderName(),
Localization.relativeTimeOrTextual(itemBuilder.getContext(), item.getUploadDate(),
item.getTextualUploadDate())));
// setup bottom row, with likes, heart and replies button
itemLikesCountView.setText(
Localization.likeCount(itemBuilder.getContext(), item.getLikeCount()));

View file

@ -7,16 +7,3 @@ 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()
}

View file

@ -17,10 +17,8 @@ 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"
/**
@ -40,7 +38,7 @@ fun View.animate(
delay: Long = 0,
execOnEnd: Runnable? = null
) {
if (DEBUG) {
if (MainActivity.DEBUG) {
val id = try {
resources.getResourceEntryName(id)
} catch (e: Exception) {
@ -53,7 +51,7 @@ fun View.animate(
Log.d(TAG, "animate(): $msg")
}
if (isVisible && enterOrExit) {
if (DEBUG) {
if (MainActivity.DEBUG) {
Log.d(TAG, "animate(): view was already visible > view = [$this]")
}
animate().setListener(null).cancel()
@ -62,7 +60,7 @@ fun View.animate(
execOnEnd?.run()
return
} else if ((isGone || isInvisible) && !enterOrExit) {
if (DEBUG) {
if (MainActivity.DEBUG) {
Log.d(TAG, "animate(): view was already gone > view = [$this]")
}
animate().setListener(null).cancel()
@ -91,7 +89,7 @@ fun View.animate(
* @param colorEnd the background color to end with
*/
fun View.animateBackgroundColor(duration: Long, @ColorInt colorStart: Int, @ColorInt colorEnd: Int) {
if (DEBUG) {
if (MainActivity.DEBUG) {
Log.d(
TAG,
"animateBackgroundColor() called with: view = [$this], duration = [$duration], " +
@ -111,7 +109,7 @@ fun View.animateBackgroundColor(duration: Long, @ColorInt colorStart: Int, @Colo
}
fun View.animateHeight(duration: Long, targetHeight: Int): ValueAnimator {
if (DEBUG) {
if (MainActivity.DEBUG) {
Log.d(TAG, "animateHeight: duration = [$duration], from $height to → $targetHeight in: $this")
}
val animator = ValueAnimator.ofFloat(height.toFloat(), targetHeight.toFloat())
@ -129,7 +127,7 @@ fun View.animateHeight(duration: Long, targetHeight: Int): ValueAnimator {
}
fun View.animateRotation(duration: Long, targetRotation: Int) {
if (DEBUG) {
if (MainActivity.DEBUG) {
Log.d(TAG, "animateRotation: duration = [$duration], from $rotation to → $targetRotation in: $this")
}
animate().setListener(null).cancel()

View file

@ -194,6 +194,9 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
if (itemsList != null) {
animateHideRecyclerViewAllowingScrolling(itemsList);
}
if (headerRootBinding != null) {
animate(headerRootBinding.getRoot(), false, 200);
}
}
@Override
@ -202,6 +205,9 @@ 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
@ -247,6 +253,9 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
if (itemsList != null) {
animateHideRecyclerViewAllowingScrolling(itemsList);
}
if (headerRootBinding != null) {
animate(headerRootBinding.getRoot(), false, 200);
}
}
@Override

View file

@ -269,13 +269,8 @@ class FeedFragment : BaseStateFragment<FeedState>() {
override fun onDestroyOptionsMenu() {
super.onDestroyOptionsMenu()
if (
(groupName != "") &&
(activity?.supportActionBar?.subtitle == groupName)
) {
activity?.supportActionBar?.subtitle = null
}
}
override fun onDestroy() {
disposables.dispose()
@ -286,14 +281,8 @@ class FeedFragment : BaseStateFragment<FeedState>() {
}
super.onDestroy()
if (
(groupName != "") &&
(activity?.supportActionBar?.subtitle == groupName)
) {
activity?.supportActionBar?.subtitle = null
}
}
override fun onDestroyView() {
// Ensure that all animations are canceled

View file

@ -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, data.url, bitmap)
showStreamNotifications(newStreams, data.serviceId, 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, data.url, null)
showStreamNotifications(newStreams, data.serviceId, null)
// Show summary notification
manager.notify(data.pseudoId, summaryBuilder.build())
iconLoadingTargets.remove(this) // allow it to be garbage-collected
@ -118,11 +118,10 @@ 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, channelUrl, channelIcon)
val notification = createStreamNotification(stream, serviceId, channelIcon)
manager.notify(stream.url.hashCode(), notification)
}
}
@ -130,7 +129,6 @@ class NotificationHelper(val context: Context) {
private fun createStreamNotification(
item: StreamInfoItem,
serviceId: Int,
channelUrl: String,
channelIcon: Bitmap?
): Notification {
return NotificationCompat.Builder(
@ -141,7 +139,7 @@ class NotificationHelper(val context: Context) {
.setLargeIcon(channelIcon)
.setContentTitle(item.name)
.setContentText(item.uploaderName)
.setGroup(channelUrl)
.setGroup(item.uploaderUrl)
.setColor(ContextCompat.getColor(context, R.color.ic_launcher_background))
.setColorized(true)
.setAutoCancel(true)

View file

@ -1,8 +1,6 @@
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
@ -85,9 +83,7 @@ class NotificationWorker(
.setPriority(NotificationCompat.PRIORITY_LOW)
.setContentTitle(applicationContext.getString(R.string.feed_notification_loading))
.build()
// 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))
setForegroundAsync(ForegroundInfo(FeedLoadService.NOTIFICATION_ID, notification))
}
companion object {

View file

@ -31,7 +31,6 @@ 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
@ -201,7 +200,7 @@ class FeedLoadService : Service() {
}
}
}
ContextCompat.registerReceiver(this, broadcastReceiver, IntentFilter(ACTION_CANCEL), ContextCompat.RECEIVER_NOT_EXPORTED)
registerReceiver(broadcastReceiver, IntentFilter(ACTION_CANCEL))
}
// /////////////////////////////////////////////////////////////////////////

View file

@ -1,72 +0,0 @@
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 }
}

View file

@ -2,13 +2,8 @@ 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;
@ -32,6 +27,7 @@ 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;
@ -389,41 +385,34 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
}
/**
* 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>
* Shares the playlist as a list of stream URLs if {@code shouldSharePlaylistDetails} is
* set to {@code false}. Shares the playlist name along with a list of video titles and URLs
* if {@code shouldSharePlaylistDetails} is set to {@code true}.
*
* @param shareMode The way the playlist should be shared.
* @param shouldSharePlaylistDetails Whether the playlist details should be included in the
* shared content.
*/
private void sharePlaylist(final PlayListShareMode shareMode) {
private void sharePlaylist(final boolean shouldSharePlaylistDetails) {
final Context context = requireContext();
disposables.add(playlistManager.getPlaylistStreams(playlistId)
.flatMapSingle(playlist -> Single.just(export(
shareMode,
playlist,
context
)))
.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 -> {
final String content = shareMode == WITH_TITLES
.subscribe(urlsText -> ShareUtils.shareText(
context, name, shouldSharePlaylistDetails
? context.getString(R.string.share_playlist_content_details,
name,
urlsText
)
: urlsText;
ShareUtils.shareText(context, name, content);
},
throwable -> showUiErrorSnackbar(this, "Sharing playlist", throwable)
)
);
name, urlsText) : urlsText),
throwable -> showUiErrorSnackbar(this, "Sharing playlist", throwable)));
}
public void removeWatchedStreams(final boolean removePartiallyWatched) {
@ -883,15 +872,13 @@ 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(WITH_TITLES)
)
.setNeutralButton(R.string.share_playlist_as_youtube_temporary_playlist,
(dialog, which) -> sharePlaylist(YOUTUBE_TEMP_PLAYLIST)
sharePlaylist(/* shouldSharePlaylistDetails= */ true)
)
.setNegativeButton(R.string.share_playlist_with_list, (dialog, which) ->
sharePlaylist(JUST_URLS)
sharePlaylist(/* shouldSharePlaylistDetails= */ false)
)
.show();
}

View file

@ -1,8 +0,0 @@
package org.schabi.newpipe.local.playlist;
public enum PlayListShareMode {
JUST_URLS,
WITH_TITLES,
YOUTUBE_TEMP_PLAYLIST
}

View file

@ -26,10 +26,6 @@ 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());

View file

@ -1,5 +1,7 @@
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;
@ -33,6 +35,7 @@ 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)

View file

@ -13,7 +13,6 @@ 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
@ -461,7 +460,6 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
}
companion object {
val JSON_MIME_TYPE = MimeTypeMap.getSingleton()
.getMimeTypeFromExtension("json") ?: "application/octet-stream"
const val JSON_MIME_TYPE = "application/json"
}
}

View file

@ -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();
}

View file

@ -2,6 +2,7 @@ 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;
@ -83,6 +84,7 @@ public final class PlayQueueActivity extends AppCompatActivity
@Override
protected void onCreate(final Bundle savedInstanceState) {
assureCorrectAppLanguage(this);
super.onCreate(savedInstanceState);
ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this));
@ -181,10 +183,7 @@ 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);
@ -222,7 +221,7 @@ public final class PlayQueueActivity extends AppCompatActivity
Log.d(TAG, "Player service is connected");
if (service instanceof PlayerService.LocalBinder) {
player = ((PlayerService.LocalBinder) service).getService().getPlayer();
player = ((PlayerService.LocalBinder) service).getPlayer();
}
if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) {

View file

@ -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,6 +44,7 @@ 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;
@ -54,14 +55,11 @@ 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;
@ -73,7 +71,6 @@ 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;
@ -89,8 +86,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.Image;
import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.Image;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.extractor.stream.VideoStream;
@ -110,7 +107,6 @@ 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;
@ -120,25 +116,21 @@ 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;
@ -160,13 +152,15 @@ 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 PLAYER_INTENT_TYPE = "player_intent_type";
public static final String PLAYER_INTENT_DATA = "player_intent_data";
public static final String IS_MUTED = "is_muted";
/*//////////////////////////////////////////////////////////////////////////
// Time constants
@ -251,8 +245,6 @@ 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,
@ -277,16 +269,7 @@ public final class Player implements PlaybackListener, Listener {
//////////////////////////////////////////////////////////////////////////*/
//region Constructor
/**
* @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) {
public Player(@NonNull final PlayerService service) {
this.service = service;
context = service;
prefs = PreferenceManager.getDefaultSharedPreferences(context);
@ -319,7 +302,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, mediaSession, sessionConnector),
new MediaSessionPlayerUi(this),
new NotificationPlayerUi(this)
);
}
@ -353,121 +336,49 @@ public final class Player implements PlaybackListener, Listener {
@SuppressWarnings("MethodLength")
public void handleIntent(@NonNull final Intent intent) {
final var playerIntentType = IntentCompat.getSerializableExtra(intent, PLAYER_INTENT_TYPE,
PlayerIntentType.class);
if (playerIntentType == null) {
// fail fast if no play queue was provided
final String queueCache = intent.getStringExtra(PLAY_QUEUE_KEY);
if (queueCache == null) {
return;
}
// TODO: this should be in the second switch below, but Im 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 PlayQueue newQueue = SerializedCache.getInstance().take(queueCache, PlayQueue.class);
if (newQueue == null) {
return;
}
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));
}
final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true);
switch (playerIntentType) {
case Enqueue -> {
if (playQueue != null) {
final PlayQueue newQueue = getPlayQueueFromCache(intent);
if (newQueue == null) {
return;
}
// Resolve enqueue intents
if (intent.getBooleanExtra(ENQUEUE, false) && playQueue != null) {
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);
// 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);
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);
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());
// 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;
}
// 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:
@ -482,7 +393,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().isSameItem(playQueue.getItem())
&& newQueue.getItem().getUrl().equals(playQueue.getItem().getUrl())
&& newQueue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) {
// Player can have state = IDLE when playback is stopped or failed
// and we should retry in this case
@ -508,8 +419,7 @@ public final class Player implements PlaybackListener, Listener {
} else if (intent.getBooleanExtra(RESUME_PLAYBACK, false)
&& DependentPreferenceHelper.getResumePlaybackEnabled(context)
// !samePlayQueue
&& (playQueue == null || !playQueue.equalStreamsAndIndex(newQueue))
&& !samePlayQueue
&& !newQueue.isEmpty()
&& newQueue.getItem() != null
&& newQueue.getItem().getRecoveryPosition() == PlayQueueItem.RECOVERY_UNSET) {
@ -525,30 +435,30 @@ public final class Player implements PlaybackListener, Listener {
newQueue.setRecovery(newQueue.getIndex(),
state.getProgressMillis());
}
initPlayback(newQueue, playWhenReady);
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
playbackSkipSilence, playWhenReady, isMuted);
},
error -> {
if (DEBUG) {
Log.w(TAG, "Failed to start playback", error);
}
// In case any error we can start playback without history
initPlayback(newQueue, playWhenReady);
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
playbackSkipSilence, playWhenReady, isMuted);
},
() -> {
// Completed but not found in history
initPlayback(newQueue, playWhenReady);
initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
playbackSkipSilence, playWhenReady, isMuted);
}
));
} 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, playWhenReady);
initPlayback(samePlayQueue ? playQueue : newQueue, repeatMode, playbackSpeed,
playbackPitch, playbackSkipSilence, playWhenReady, isMuted);
}
}
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)
@ -559,19 +469,6 @@ 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)) {
@ -605,13 +502,16 @@ public final class Player implements PlaybackListener, Listener {
}
private void initPlayback(@NonNull final PlayQueue queue,
final boolean playOnReady) {
@RepeatMode final int repeatMode,
final float playbackSpeed,
final float playbackPitch,
final boolean playbackSkipSilence,
final boolean playOnReady,
final boolean isMuted) {
destroyPlayer();
initPlayer(playOnReady);
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);
setRepeatMode(repeatMode);
setPlaybackParameters(playbackSpeed, playbackPitch, playbackSkipSilence);
playQueue = queue;
playQueue.init();
@ -619,7 +519,7 @@ public final class Player implements PlaybackListener, Listener {
UIs.call(PlayerUi::initPlayback);
simpleExoPlayer.setVolume(isMuted() ? 0 : 1);
simpleExoPlayer.setVolume(isMuted ? 0 : 1);
notifyQueueUpdateToListeners();
}
@ -701,7 +601,6 @@ 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
@ -747,7 +646,7 @@ public final class Player implements PlaybackListener, Listener {
Log.d(TAG, "onPlaybackShutdown() called");
}
// destroys the service, which in turn will destroy the player
service.destroyPlayerAndStopService();
service.stopService();
}
public void smoothStopForImmediateReusing() {
@ -819,7 +718,7 @@ public final class Player implements PlaybackListener, Listener {
pause();
break;
case ACTION_CLOSE:
service.destroyPlayerAndStopService();
service.stopService();
break;
case ACTION_PLAY_PAUSE:
playPause();
@ -843,6 +742,7 @@ 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");
}
@ -855,8 +755,7 @@ public final class Player implements PlaybackListener, Listener {
private void registerBroadcastReceiver() {
// Try to unregister current first
unregisterBroadcastReceiver();
ContextCompat.registerReceiver(context, broadcastReceiver, intentFilter,
ContextCompat.RECEIVER_EXPORTED);
context.registerReceiver(broadcastReceiver, intentFilter);
}
private void unregisterBroadcastReceiver() {
@ -1268,25 +1167,16 @@ public final class Player implements PlaybackListener, Listener {
return exoPlayerIsNull() ? REPEAT_MODE_OFF : simpleExoPlayer.getRepeatMode();
}
public void cycleNextRepeatMode() {
public void setRepeatMode(@RepeatMode final int repeatMode) {
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) {
@ -1389,8 +1279,7 @@ public final class Player implements PlaybackListener, Listener {
UserAction.PLAY_STREAM,
"Loading failed for [" + currentMetadata.getTitle()
+ "]: " + currentMetadata.getStreamUrl(),
currentMetadata.getServiceId(),
currentMetadata.getStreamUrl());
currentMetadata.getServiceId());
ErrorUtil.createNotification(context, errorInfo);
}
@ -1486,19 +1375,6 @@ 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
@ -1606,7 +1482,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.getStreamUrl());
currentMetadata.getServiceId());
}
ErrorUtil.createNotification(context, errorInfo);
}

View file

@ -1,24 +0,0 @@
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

View file

@ -19,162 +19,96 @@
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 MediaBrowserServiceCompat {
public final class PlayerService extends Service {
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);
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.
player = new Player(this);
/*
Create the player notification and start immediately the service in foreground,
otherwise if nothing is played or initializing the player and its components (especially
loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the
service would never be put in the foreground while we said to the system we would do so
*/
player.UIs().get(NotificationPlayerUi.class)
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
}
@Override
public int onStartCommand(final Intent intent, final int flags, final int startId) {
if (DEBUG) {
Log.d(TAG, "onStartCommand() called with: intent = [" + intent
+ "], extras = [" + BundleKt.toDebugString(intent.getExtras())
+ "], flags = [" + flags + "], startId = [" + startId + "]");
}
// 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.
/*
Be sure that the player notification is set and the service is started in foreground,
otherwise, the app may crash on Android 8+ as the service would never be put in the
foreground while we said to the system we would do so
The service is always requested to be started in foreground, so always creating a
notification if there is no one already and starting the service in foreground should
not create any issues
If the service is already started in foreground, requesting it to be started shouldn't
do anything
*/
if (player != null) {
player.UIs().get(NotificationPlayerUi.class)
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
if (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 (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();
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
&& (player == null || player.getPlayQueue() == null)) {
/*
No need to process media button's actions if the player is not working, otherwise
the player service would strangely start with nothing to play
Stop the service in this case, which will be removed from the foreground and its
notification cancelled in its destruction
*/
stopSelf();
return START_NOT_STICKY;
}
final PlayerType oldPlayerType = player.getPlayerType();
if (player != null) {
player.handleIntent(intent);
player.handleIntentPost(oldPlayerType);
player.UIs().get(MediaSessionPlayerUi.class)
.ifPresent(ui -> ui.handleMediaButtonIntent(intent));
}
return START_NOT_STICKY;
}
@ -208,84 +142,29 @@ public final class PlayerService extends MediaBrowserServiceCompat {
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);
}
/**
* 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");
}
public void stopService() {
cleanup();
// 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));
stopSelf();
}
@Override
protected void attachBaseContext(final Context base) {
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base));
}
//endregion
//region Bind
@Override
public IBinder onBind(final Intent intent) {
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 {
@ -298,51 +177,9 @@ public final class PlayerService extends MediaBrowserServiceCompat {
public PlayerService getService() {
return playerService.get();
}
}
/**
* @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);
return playerService.get().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
}

View file

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

View file

@ -14,12 +14,10 @@ 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.isWebEmbeddedPlayerStreamingUrl;
import static org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper.isTvHtml5SimplyEmbeddedPlayerStreamingUrl;
import static java.lang.Math.min;
import android.net.Uri;
@ -272,7 +270,6 @@ 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;
@ -661,11 +658,8 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD
}
}
final boolean isTvHtml5StreamingUrl = isTvHtml5StreamingUrl(requestUrl);
if (isWebStreamingUrl(requestUrl)
|| isTvHtml5StreamingUrl
|| isWebEmbeddedPlayerStreamingUrl(requestUrl)) {
|| isTvHtml5SimplyEmbeddedPlayerStreamingUrl(requestUrl)) {
httpURLConnection.setRequestProperty(HttpHeaders.ORIGIN, YOUTUBE_BASE_URL);
httpURLConnection.setRequestProperty(HttpHeaders.REFERER, YOUTUBE_BASE_URL);
httpURLConnection.setRequestProperty(HttpHeaders.SEC_FETCH_DEST, "empty");
@ -685,9 +679,6 @@ 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);
@ -696,16 +687,22 @@ public final class YoutubeHttpDataSource extends BaseDataSource implements HttpD
httpURLConnection.setRequestProperty(HttpHeaders.ACCEPT_ENCODING,
allowGzip ? "gzip" : "identity");
httpURLConnection.setInstanceFollowRedirects(followRedirects);
// Most clients use POST requests to fetch contents
httpURLConnection.setRequestMethod("POST");
httpURLConnection.setDoOutput(true);
httpURLConnection.setFixedLengthStreamingMode(POST_BODY.length);
httpURLConnection.setDoOutput(httpBody != null);
// Mobile clients uses POST requests to fetch contents
httpURLConnection.setRequestMethod(isAndroidStreamingUrl || isIosStreamingUrl
? "POST"
: DataSpec.getStringForHttpMethod(httpMethod));
if (httpBody != null) {
httpURLConnection.setFixedLengthStreamingMode(httpBody.length);
httpURLConnection.connect();
final OutputStream os = httpURLConnection.getOutputStream();
os.write(POST_BODY);
os.write(httpBody);
os.close();
} else {
httpURLConnection.connect();
}
return httpURLConnection;
}

View file

@ -1,48 +1,11 @@
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 {
/**
* 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 onServiceConnected(Player player,
PlayerService playerService,
boolean playAfterConnect);
void onServiceDisconnected();
}

View file

@ -154,6 +154,9 @@ 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);

View file

@ -2,6 +2,7 @@ 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;
@ -144,6 +145,7 @@ public class PlaybackParameterDialog extends DialogFragment {
@NonNull
@Override
public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
assureCorrectAppLanguage(getContext());
Bridge.restoreInstanceState(this, savedInstanceState);
binding = DialogPlaybackParameterBinding.inflate(getLayoutInflater());

View file

@ -1,5 +1,8 @@
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;
@ -22,6 +25,7 @@ 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;
@ -29,9 +33,11 @@ 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;
@ -41,14 +47,13 @@ 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;
@ -57,7 +62,11 @@ import java.util.Set;
import java.util.concurrent.TimeUnit;
public final class PlayerHelper {
private static final FormattersProvider FORMATTERS_PROVIDER = new FormattersProvider();
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("##%");
@Retention(SOURCE)
@IntDef({AUTOPLAY_TYPE_ALWAYS, AUTOPLAY_TYPE_WIFI,
@ -80,11 +89,9 @@ public final class PlayerHelper {
private PlayerHelper() {
}
// region Exposed helpers
public static void resetFormat() {
FORMATTERS_PROVIDER.reset();
}
////////////////////////////////////////////////////////////////////////////
// Exposed helpers
////////////////////////////////////////////////////////////////////////////
@NonNull
public static String getTimeString(final int milliSeconds) {
@ -93,24 +100,35 @@ public final class PlayerHelper {
final int hours = (milliSeconds % 86400000) / 3600000;
final int days = (milliSeconds % (86400000 * 7)) / 86400000;
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);
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();
}
@NonNull
public static String formatSpeed(final double speed) {
return FORMATTERS_PROVIDER.formatters().speed().format(speed);
return SPEED_FORMATTER.format(speed);
}
@NonNull
public static String formatPitch(final double pitch) {
return FORMATTERS_PROVIDER.formatters().pitch().format(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());
}
}
@NonNull
@ -201,8 +219,9 @@ public final class PlayerHelper {
? null : getAutoQueuedSinglePlayQueue(autoQueueItems.get(0));
}
// endregion
// region Resolution
////////////////////////////////////////////////////////////////////////////
// Settings Resolution
////////////////////////////////////////////////////////////////////////////
public static boolean isResumeAfterAudioFocusGain(@NonNull final Context context) {
return getPreferences(context)
@ -296,6 +315,10 @@ 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,
@ -382,8 +405,9 @@ public final class PlayerHelper {
return Integer.parseInt(preferredIntervalBytes) * 1024;
}
// endregion
// region Private helpers
////////////////////////////////////////////////////////////////////////////
// Private helpers
////////////////////////////////////////////////////////////////////////////
@NonNull
private static SharedPreferences getPreferences(@NonNull final Context context) {
@ -402,8 +426,23 @@ public final class PlayerHelper {
return singlePlayQueue;
}
// endregion
// region Utils used by player
////////////////////////////////////////////////////////////////////////////
// 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;
}
}
@ResizeMode
public static int retrieveResizeModeFromPrefs(final Player player) {
@ -464,43 +503,4 @@ 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
}

View file

@ -22,10 +22,6 @@ 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 {
@ -48,16 +44,7 @@ public final class PlayerHolder {
private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection();
private boolean bound;
@Nullable private PlayerService playerService;
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()));
}
@Nullable private Player player;
/**
* Returns the current {@link PlayerType} of the {@link PlayerService} service,
@ -67,15 +54,21 @@ public final class PlayerHolder {
*/
@Nullable
public PlayerType getType() {
return getPlayer().map(Player::getPlayerType).orElse(null);
if (player == null) {
return null;
}
return player.getPlayerType();
}
public boolean isPlaying() {
return getPlayer().map(Player::isPlaying).orElse(false);
if (player == null) {
return false;
}
return player.isPlaying();
}
public boolean isPlayerOpen() {
return getPlayer().isPresent();
return player != null;
}
/**
@ -84,7 +77,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 getPlayQueue().isPresent();
return player != null && player.getPlayQueue() != null;
}
public boolean isBound() {
@ -92,11 +85,18 @@ public final class PlayerHolder {
}
public int getQueueSize() {
return getPlayQueue().map(PlayQueue::size).orElse(0);
if (player == null || player.getPlayQueue() == null) {
// player play queue might be null e.g. while player is starting
return 0;
}
return player.getPlayQueue().size();
}
public int getQueuePosition() {
return getPlayQueue().map(PlayQueue::getIndex).orElse(0);
if (player == null || player.getPlayQueue() == null) {
return 0;
}
return player.getPlayQueue().getIndex();
}
public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) {
@ -107,10 +107,9 @@ public final class PlayerHolder {
}
// Force reload data from service
if (playerService != null) {
listener.onServiceConnected(playerService);
if (player != null) {
listener.onServiceConnected(player, playerService, false);
startPlayerListener();
// ^ will call listener.onPlayerConnected() down the line if there is an active player
}
}
@ -122,9 +121,6 @@ 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) {
@ -134,24 +130,14 @@ public final class PlayerHolder {
// and NullPointerExceptions inside the service because the service will be
// bound twice. Prevent it with unbinding first
unbind(context);
final Intent intent = new Intent(context, PlayerService.class);
intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true);
ContextCompat.startForegroundService(context, intent);
ContextCompat.startForegroundService(context, new Intent(context, PlayerService.class));
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));
}
@ -159,11 +145,6 @@ 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;
}
@ -186,17 +167,11 @@ public final class PlayerHolder {
final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service;
playerService = localBinder.getService();
player = localBinder.getPlayer();
if (listener != null) {
listener.onServiceConnected(playerService);
listener.onServiceConnected(player, playerService, playAfterConnect);
}
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());
}
}
}
@ -204,28 +179,15 @@ public final class PlayerHolder {
if (DEBUG) {
Log.d(TAG, "bind() called");
}
// BIND_AUTO_CREATE starts the service if it's not already running
bound = bind(context, Context.BIND_AUTO_CREATE);
final Intent serviceIntent = new Intent(context, PlayerService.class);
bound = context.bindService(serviceIntent, serviceConnection,
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");
@ -236,32 +198,25 @@ public final class PlayerHolder {
bound = false;
stopPlayerListener();
playerService = null;
player = null;
if (listener != null) {
listener.onPlayerDisconnected();
listener.onServiceDisconnected();
}
}
}
private void startPlayerListener() {
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);
if (player != null) {
player.setFragmentListener(internalListener);
}
getPlayer().ifPresent(p -> p.setFragmentListener(internalListener));
}
private void stopPlayerListener() {
if (playerService != null) {
playerService.setPlayerListener(null);
if (player != null) {
player.removeFragmentListener(internalListener);
}
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
@ -348,25 +303,4 @@ 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);
}
}
};
}

View file

@ -1,40 +0,0 @@
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")
}

View file

@ -1,421 +0,0 @@
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
}
}
}
}

View file

@ -1,262 +0,0 @@
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
}
}

View file

@ -1,240 +0,0 @@
/*
* 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"

View file

@ -38,10 +38,10 @@ public class MediaSessionPlayerUi extends PlayerUi
implements SharedPreferences.OnSharedPreferenceChangeListener {
private static final String TAG = "MediaSessUi";
@NonNull
private final MediaSessionCompat mediaSession;
@NonNull
private final MediaSessionConnector sessionConnector;
@Nullable
private MediaSessionCompat mediaSession;
@Nullable
private MediaSessionConnector sessionConnector;
private final String ignoreHardwareMediaButtonsKey;
private boolean shouldIgnoreHardwareMediaButtons = false;
@ -50,13 +50,9 @@ public class MediaSessionPlayerUi extends PlayerUi
private List<NotificationActionData> prevNotificationActions = List.of();
public MediaSessionPlayerUi(@NonNull final Player player,
@NonNull final MediaSessionCompat mediaSession,
@NonNull final MediaSessionConnector sessionConnector) {
public MediaSessionPlayerUi(@NonNull final Player player) {
super(player);
this.mediaSession = mediaSession;
this.sessionConnector = sessionConnector;
this.ignoreHardwareMediaButtonsKey =
ignoreHardwareMediaButtonsKey =
context.getString(R.string.ignore_hardware_media_buttons_key);
}
@ -65,8 +61,10 @@ 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());
@ -91,19 +89,28 @@ 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;
}
prevNotificationActions = List.of();
}
@Override
public void onThumbnailLoaded(@Nullable final Bitmap bitmap) {
super.onThumbnailLoaded(bitmap);
if (sessionConnector != null) {
// the thumbnail is now loaded: invalidate the metadata to trigger a metadata update
sessionConnector.invalidateMediaSessionMetadata();
}
}
@Override
@ -193,8 +200,8 @@ public class MediaSessionPlayerUi extends PlayerUi
return;
}
if (!mediaSession.isActive()) {
// mediaSession will be inactive after destroyPlayer is called
if (sessionConnector == null) {
// sessionConnector will be null after destroyPlayer is called
return;
}

View file

@ -5,9 +5,7 @@ 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;
@ -25,8 +23,6 @@ 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;
@ -93,9 +89,12 @@ 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
@ -108,9 +107,18 @@ public final class NotificationUtil {
.ifPresent(mediaStyle::setMediaSession);
// setup notification builder
final var builder = setupNotificationBuilder(player.getContext(), mediaStyle)
builder.setStyle(mediaStyle)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setCategory(NotificationCompat.CATEGORY_TRANSPORT)
.setShowWhen(false)
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
.setColor(ContextCompat.getColor(player.getContext(),
R.color.dark_background_color))
.setColorized(player.getPrefs().getBoolean(
player.getContext().getString(R.string.notification_colorize_key), true));
player.getContext().getString(R.string.notification_colorize_key), true))
.setDeleteIntent(PendingIntentCompat.getBroadcast(player.getContext(),
NOTIFICATION_ID, new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT, false));
// set the initial value for the video thumbnail, updatable with updateNotificationThumbnail
setLargeIcon(builder);
@ -159,17 +167,19 @@ 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();
startForeground(player.getService(), notificationBuilder.build());
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build(),
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
} else {
player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build());
}
}
public void cancelNotificationAndStopForeground() {
@ -183,34 +193,6 @@ 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
/////////////////////////////////////////////////////
@ -274,9 +256,7 @@ 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,
PlayerIntentType.AllOthers);
intent.putExtra(Player.RESUME_PLAYBACK, true);
player.getContext(), MainActivity.class, null, true);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.setAction(Intent.ACTION_MAIN);
intent.addCategory(Intent.CATEGORY_LAUNCHER);

View file

@ -3,7 +3,6 @@ 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;
@ -24,23 +23,18 @@ 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()),
index);
0);
}
protected AbstractInfoPlayQueue(final int serviceId,

View file

@ -291,22 +291,6 @@ 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>
@ -545,7 +529,8 @@ 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.isSameItem(otherStream)) {
if (stream.getServiceId() != otherStream.getServiceId()
|| !stream.getUrl().equals(otherStream.getUrl())) {
return false;
}
}

View file

@ -38,7 +38,7 @@ public class PlayQueueItem implements Serializable {
private long recoveryPosition;
private Throwable error;
public PlayQueueItem(@NonNull final StreamInfo info) {
PlayQueueItem(@NonNull final StreamInfo info) {
this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(),
info.getThumbnails(), info.getUploaderName(),
info.getUploaderUrl(), info.getStreamType());
@ -71,22 +71,6 @@ 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;

View file

@ -16,10 +16,6 @@ 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,

View file

@ -16,9 +16,7 @@ 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);

View file

@ -25,7 +25,9 @@ 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;
@ -42,7 +44,6 @@ 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;
@ -521,8 +522,11 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
@Override
protected void setupSubtitleView(final float captionScale) {
binding.subtitleView.setFractionalTextSize(
SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * captionScale);
final DisplayMetrics metrics = context.getResources().getDisplayMetrics();
final int minimumLength = Math.min(metrics.heightPixels, metrics.widthPixels);
final float captionRatioInverse = 20f + 4f * (1.0f - captionScale);
binding.subtitleView.setFixedTextSize(
TypedValue.COMPLEX_UNIT_PX, minimumLength / captionRatioInverse);
}
//endregion

View file

@ -382,7 +382,7 @@ public final class PopupPlayerUi extends VideoPlayerUi {
private void end() {
windowManager.removeView(closeOverlayBinding.getRoot());
closeOverlayBinding = null;
player.getService().destroyPlayerAndStopService();
player.getService().stopService();
}
}).start();
}
@ -424,8 +424,9 @@ 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 * captionScale);
SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * captionRatio);
}
@Override

View file

@ -1414,10 +1414,6 @@ 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

View file

@ -1,6 +1,7 @@
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;
@ -125,6 +126,7 @@ 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();
@ -137,6 +139,7 @@ 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();

View file

@ -1,76 +1,44 @@
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.player.helper.PlayerHelper;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.extractor.localization.ContentCountry;
import org.schabi.newpipe.extractor.localization.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();
setupAppLanguagePreferences();
setupImageQualityPref();
}
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");
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(
final Preference imageQualityPreference = requirePreference(R.string.image_quality_key);
imageQualityPreference.setOnPreferenceChangeListener(
(preference, newValue) -> {
ImageStrategy.setPreferredImageQuality(PreferredImageQuality
.fromPreferenceKey(requireContext(), (String) newValue));
@ -104,10 +72,20 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
public void onDestroy() {
super.onDestroy();
final Context context = requireContext();
NewPipe.setupLocalization(
Localization.getPreferredLocalization(context),
Localization.getPreferredContentCountry(context));
PlayerHelper.resetFormat();
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);
}
}
}

View file

@ -1,5 +1,7 @@
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;
@ -207,6 +209,8 @@ public class DownloadSettingsFragment extends BasePreferenceFragment {
}
private void requestDownloadPathResult(final ActivityResult result, final String key) {
assureCorrectAppLanguage(getContext());
if (result.getResultCode() != Activity.RESULT_OK) {
return;
}

View file

@ -13,7 +13,6 @@ 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;
@ -47,7 +46,7 @@ public final class NewPipeSettings {
public static void initSettings(final Context context) {
// first run migrations, then setDefaultValues, since the latter requires the correct types
MigrationManager.runMigrationsIfNeeded(context);
SettingMigrations.runMigrationsIfNeeded(context);
// readAgain is true so that if new settings are added their default value is set
PreferenceManager.setDefaultValues(context, R.xml.main_settings, true);
@ -103,12 +102,12 @@ public final class NewPipeSettings {
}
public static boolean useStorageAccessFramework(final Context context) {
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).
// 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) {
return true;
}
final String key = context.getString(R.string.storage_use_saf);

View file

@ -1,214 +0,0 @@
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);
}
}
}
}

View file

@ -1,15 +1,10 @@
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;
package org.schabi.newpipe.settings;
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;
@ -17,32 +12,20 @@ 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 java.util.stream.Collectors;
import static org.schabi.newpipe.MainActivity.DEBUG;
/**
* 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.
* 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)
*/
public final class SettingMigrations {
@ -146,7 +129,7 @@ public final class SettingMigrations {
}
};
private static final Migration MIGRATION_5_6 = new Migration(5, 6) {
public static final Migration MIGRATION_5_6 = new Migration(5, 6) {
@Override
protected void migrate(@NonNull final Context context) {
final boolean loadImages = sp.getBoolean("download_thumbnail_key", true);
@ -160,67 +143,6 @@ 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>
@ -234,17 +156,15 @@ 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 = 8;
private static final int VERSION = 6;
static void runMigrationsIfNeeded(@NonNull final Context context) {
public static void runMigrationsIfNeeded(@NonNull final Context context) {
// setup migrations and check if there is something to do
sp = PreferenceManager.getDefaultSharedPreferences(context);
final String lastPrefVersionKey = context.getString(R.string.last_used_preferences_version);

View file

@ -1,5 +1,7 @@
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;
@ -87,6 +89,7 @@ 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);
@ -225,6 +228,7 @@ 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);

View file

@ -1,103 +0,0 @@
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();
}
}

View file

@ -34,7 +34,6 @@ 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;
@ -204,14 +203,6 @@ 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;
@ -253,11 +244,6 @@ 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));
@ -410,9 +396,6 @@ 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());
}

View file

@ -93,8 +93,6 @@ public abstract class Tab {
return new ChannelTab(jsonObject);
case PLAYLIST:
return new PlaylistTab(jsonObject);
case FEEDGROUP:
return new FeedGroupTab(jsonObject);
}
}
@ -164,8 +162,7 @@ public abstract class Tab {
HISTORY(new HistoryTab()),
KIOSK(new KioskTab()),
CHANNEL(new ChannelTab()),
PLAYLIST(new PlaylistTab()),
FEEDGROUP(new FeedGroupTab());
PLAYLIST(new PlaylistTab());
private final Tab tab;
@ -461,7 +458,7 @@ public abstract class Tab {
final ChannelTab other = (ChannelTab) obj;
return super.equals(obj)
&& channelServiceId == other.channelServiceId
&& channelUrl.equals(other.channelUrl)
&& channelUrl.equals(other.channelName)
&& channelName.equals(other.channelName);
}
@ -655,93 +652,4 @@ 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;
}
}
}

View file

@ -24,11 +24,7 @@ public class SrtFromTtmlWriter {
private final boolean ignoreEmptyFrames;
private final Charset charset = StandardCharsets.UTF_8;
// 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;
private int frameIndex = 0;
public SrtFromTtmlWriter(final SharpStream out, final boolean ignoreEmptyFrames) {
this.out = out;
@ -43,8 +39,7 @@ public class SrtFromTtmlWriter {
private void writeFrame(final String begin, final String end, final StringBuilder text)
throws IOException {
writeString(String.valueOf(frameIndex));
frameIndex += 1;
writeString(String.valueOf(frameIndex++));
writeString(NEW_LINE);
writeString(begin);
writeString(" --> ");

View file

@ -24,7 +24,6 @@ public final class ChannelTabHelper {
switch (tab) {
case ChannelTabs.VIDEOS:
case ChannelTabs.TRACKS:
case ChannelTabs.LIKES:
case ChannelTabs.SHORTS:
case ChannelTabs.LIVESTREAMS:
return true;
@ -63,8 +62,6 @@ 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;
}
@ -81,8 +78,6 @@ 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;
}
@ -105,8 +100,6 @@ 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;
}

View file

@ -17,7 +17,6 @@ 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;
@ -336,17 +335,4 @@ 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;
}
}
}

View file

@ -52,14 +52,6 @@ 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;
}
@ -85,14 +77,6 @@ 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;
}

View file

@ -322,7 +322,7 @@ public final class ListHelper {
}
// Sort collected streams by name
return collectedStreams.values().stream().sorted(getAudioTrackNameComparator())
return collectedStreams.values().stream().sorted(getAudioTrackNameComparator(context))
.collect(Collectors.toList());
}
@ -359,7 +359,7 @@ public final class ListHelper {
}
// Sort tracks alphabetically, sort track streams by quality
final Comparator<AudioStream> nameCmp = getAudioTrackNameComparator();
final Comparator<AudioStream> nameCmp = getAudioTrackNameComparator(context);
final Comparator<AudioStream> formatCmp = getAudioFormatComparator(context);
return collectedStreams.values().stream()
@ -867,10 +867,12 @@ 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() {
final Locale appLoc = Localization.getAppLocale();
private static Comparator<AudioStream> getAudioTrackNameComparator(
@NonNull final Context context) {
final Locale appLoc = Localization.getAppLocale(context);
return Comparator.comparing(AudioStream::getAudioLocale, Comparator.nullsLast(
Comparator.comparing(locale -> locale.getDisplayName(appLoc))))

View file

@ -5,21 +5,19 @@ 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.Log;
import android.util.DisplayMetrics;
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;
@ -65,7 +63,6 @@ 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;
@ -83,20 +80,6 @@ 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
@ -117,34 +100,35 @@ public final class Localization {
return getLocaleFromPrefs(context, R.string.content_language_key);
}
public static Locale getAppLocale() {
final Locale customLocale = AppCompatDelegate.getApplicationLocales().get(0);
return customLocale != null ? customLocale : Locale.getDefault();
public static Locale getAppLocale(@NonNull final Context context) {
return getLocaleFromPrefs(context, R.string.app_language_key);
}
public static String localizeNumber(final long number) {
return localizeNumber((double) number);
public static String localizeNumber(@NonNull final Context context, final long number) {
return localizeNumber(context, (double) number);
}
public static String localizeNumber(final double number) {
return NumberFormat.getInstance(getAppLocale()).format(number);
public static String localizeNumber(@NonNull final Context context, final double number) {
final NumberFormat nf = NumberFormat.getInstance(getAppLocale(context));
return nf.format(number);
}
public static String formatDate(@NonNull final OffsetDateTime offsetDateTime) {
public static String formatDate(@NonNull final Context context,
@NonNull final OffsetDateTime offsetDateTime) {
return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(getAppLocale())
.format(offsetDateTime.atZoneSameInstant(ZoneId.systemDefault()));
.withLocale(getAppLocale(context)).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(offsetDateTime));
return context.getString(R.string.upload_date_text, formatDate(context, offsetDateTime));
}
public static String localizeViewCount(@NonNull final Context context, final long viewCount) {
return getQuantity(context, R.plurals.views, R.string.no_views, viewCount,
localizeNumber(viewCount));
localizeNumber(context, viewCount));
}
public static String localizeStreamCount(@NonNull final Context context,
@ -158,7 +142,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(streamCount));
localizeNumber(context, streamCount));
}
}
@ -179,33 +163,27 @@ 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(watchingCount));
localizeNumber(context, 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(),
return CompactDecimalFormat.getInstance(getAppLocale(context),
CompactDecimalFormat.CompactStyle.SHORT).format(count);
}
final double value = (double) count;
if (count >= 1000000000) {
final double shortenedValue = value / 1000000000;
final int scale = shortenedValue >= 100 ? 0 : 1;
return context.getString(R.string.short_billion,
localizeNumber(round(shortenedValue, scale)));
return localizeNumber(context, round(value / 1000000000))
+ context.getString(R.string.short_billion);
} else if (count >= 1000000) {
final double shortenedValue = value / 1000000;
final int scale = shortenedValue >= 100 ? 0 : 1;
return context.getString(R.string.short_million,
localizeNumber(round(shortenedValue, scale)));
return localizeNumber(context, round(value / 1000000))
+ context.getString(R.string.short_million);
} else if (count >= 1000) {
final double shortenedValue = value / 1000;
final int scale = shortenedValue >= 100 ? 0 : 1;
return context.getString(R.string.short_thousand,
localizeNumber(round(shortenedValue, scale)));
return localizeNumber(context, round(value / 1000))
+ context.getString(R.string.short_thousand);
} else {
return localizeNumber(value);
return localizeNumber(context, value);
}
}
@ -330,7 +308,7 @@ public final class Localization {
* <ul>
* <li>English (original)</li>
* <li>English (descriptive)</li>
* <li>Spanish (Spain) (dubbed)</li>
* <li>Spanish (dubbed)</li>
* </ul>
*
* @param context the context used to get the app language
@ -340,7 +318,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().getDisplayName();
name = track.getAudioLocale().getDisplayLanguage(getAppLocale(context));
} else if (track.getAudioTrackName() != null) {
name = track.getAudioTrackName();
} else {
@ -375,8 +353,8 @@ public final class Localization {
prettyTime.removeUnit(Decade.class);
}
public static PrettyTime resolvePrettyTime() {
return new PrettyTime(getAppLocale());
public static PrettyTime resolvePrettyTime(@NonNull final Context context) {
return new PrettyTime(getAppLocale(context));
}
public static String relativeTime(@NonNull final OffsetDateTime offsetDateTime) {
@ -394,10 +372,9 @@ 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,
@Nullable final String textual) {
final String textual) {
if (parsed == null) {
return textual;
} else if (DEBUG && context != null && PreferenceManager
@ -409,6 +386,14 @@ 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);
@ -422,8 +407,8 @@ public final class Localization {
}
}
private static double round(final double value, final int scale) {
return new BigDecimal(value).setScale(scale, RoundingMode.HALF_UP).doubleValue();
private static double round(final double value) {
return new BigDecimal(value).setScale(1, RoundingMode.HALF_UP).doubleValue();
}
private static String getQuantity(@NonNull final Context context,
@ -442,35 +427,4 @@ 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"
);
}
}
}
}
}

View file

@ -57,10 +57,8 @@ 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;
@ -69,7 +67,6 @@ 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";
@ -88,32 +85,53 @@ public final class NavigationHelper {
public static <T> Intent getPlayerIntent(@NonNull final Context context,
@NonNull final Class<T> targetClazz,
@Nullable final PlayQueue playQueue,
@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);
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
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);
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);
}
@NonNull
public static <T> Intent getPlayerEnqueueNextIntent(@NonNull final Context context,
@NonNull final Class<T> targetClazz,
@Nullable final PlayQueue playQueue) {
return getPlayerIntent(context, targetClazz, playQueue, PlayerIntentType.EnqueueNext)
// see comment in `getPlayerEnqueueIntent` as to why `resumePlayback` is false
.putExtra(Player.RESUME_PLAYBACK, false);
return getPlayerIntent(context, targetClazz, playQueue, false)
.putExtra(Player.ENQUEUE_NEXT, true);
}
/* PLAY */
@ -147,10 +165,8 @@ public final class NavigationHelper {
Toast.makeText(context, R.string.popup_playing_toast, Toast.LENGTH_SHORT).show();
final var intent = getPlayerIntent(context, PlayerService.class, queue,
PlayerIntentType.AllOthers)
.putExtra(Player.PLAYER_TYPE, PlayerType.POPUP)
.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
final Intent intent = getPlayerIntent(context, PlayerService.class, queue, resumePlayback);
intent.putExtra(Player.PLAYER_TYPE, PlayerType.POPUP.valueForIntent());
ContextCompat.startForegroundService(context, intent);
}
@ -160,10 +176,8 @@ 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,
PlayerIntentType.AllOthers)
.putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO)
.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
final Intent intent = getPlayerIntent(context, PlayerService.class, queue, resumePlayback);
intent.putExtra(Player.PLAYER_TYPE, PlayerType.AUDIO.valueForIntent());
ContextCompat.startForegroundService(context, intent);
}
@ -176,18 +190,9 @@ public final class NavigationHelper {
}
Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show();
final Intent intent = getPlayerEnqueueIntent(context, PlayerService.class, queue);
// 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);
intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent());
ContextCompat.startForegroundService(context, intent);
}
@ -209,8 +214,9 @@ 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)
.putExtra(Player.PLAYER_TYPE, playerType);
final Intent intent = getPlayerEnqueueNextIntent(context, PlayerService.class, queue);
intent.putExtra(Player.PLAYER_TYPE, playerType.valueForIntent());
ContextCompat.startForegroundService(context, intent);
}
@ -446,12 +452,8 @@ 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, null, title, playQueue);
.getInstance(serviceId, url, title, playQueue);
instance.setAutoPlay(autoPlay);
defaultTransaction(fragmentManager)

View file

@ -9,11 +9,9 @@ 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;
@ -115,7 +113,6 @@ public final class PermissionHelper {
@RequiresApi(api = Build.VERSION_CODES.M)
public static boolean checkSystemAlertWindowPermission(final Context context) {
if (!Settings.canDrawOverlays(context)) {
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);
@ -124,38 +121,6 @@ public final class PermissionHelper {
} catch (final ActivityNotFoundException ignored) {
}
return false;
// from Android R the ACTION_MANAGE_OVERLAY_PERMISSION will only point to the menu,
// so lets 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 dont need the package name here, since it wont 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;
}
} else {
return true;
}

View file

@ -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, url)
"Loading stream info: " + url, serviceId)
));
}
}

View file

@ -5,9 +5,10 @@ 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;
@ -22,7 +23,6 @@ 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,9 +62,8 @@ public final class ShareUtils {
}
/**
* 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).
* Open the url with the system default browser. If no browser is set as default, falls back to
* {@link #openAppChooser(Context, Intent, boolean)}.
* <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.
@ -78,28 +77,46 @@ public final class ShareUtils {
* @param url the url to browse
**/
public static void openUrlInBrowser(@NonNull final Context context, final String url) {
// Target a generic http://, so we are sure to get a browser and not e.g. the yt app.
// Resolve using a generic http://, so we are sure to get a browser and not e.g. the yt app.
// Note that this requires the `http` schema to be added to `<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);
// See https://stackoverflow.com/a/58801285 and `setSelector` documentation
intent.setSelector(browserIntent);
if (defaultBrowserInfo == null) {
// No app installed to open a web URL, but it may be handled by other apps so try
// opening a system chooser for the link in this case (it could be bypassed by the
// system if there is only one app which can open the link or a default app associated
// with the link domain on Android 12 and higher)
openAppChooser(context, intent, true);
return;
}
final String defaultBrowserPackage = defaultBrowserInfo.activityInfo.packageName;
if (defaultBrowserPackage.equals("android")) {
// No browser set as default (doesn't work on some devices)
openAppChooser(context, intent, true);
} else {
try {
intent.setPackage(defaultBrowserPackage);
context.startActivity(intent);
} catch (final ActivityNotFoundException e) {
// 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);
// Not a browser but an app chooser because of OEMs changes
intent.setPackage(null);
openAppChooser(context, intent, true);
}
}
}
/**
* Open a url with the system default app using {@link Intent#ACTION_VIEW}, showing a toast in
@ -173,18 +190,6 @@ 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

View file

@ -1,113 +0,0 @@
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()
}

View file

@ -1,13 +0,0 @@
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