1
0
mirror of https://github.com/TeamNewPipe/NewPipe synced 2026-01-15 22:18:03 +00:00

Compare commits

..

8 Commits

Author SHA1 Message Date
Profpatsch
6c3f31a721 WIP: integrate onBackPressed flow as callback
This sets up a little JavaFlow wrapper so we can register callbacks on
the back press flow in Java-land.

Inspired by one of the answers in
https://stackoverflow.com/questions/60605176/kotlin-flows-java-interop-callback

Kotlin generates default interface instances, but only if
`-Xjvm-default=all` is set in the compiler flags. The Java IDE
would propose using a lambda, which would fail because the kotlin
compiler would not generate the right ABI without that flag.
2024-12-25 18:33:49 +01:00
Profpatsch
10163e1082 WIP: try to integrate the newplayer a little better
* Should pause the player now if back button is hit.
* Video reloads if a different item is requested.
2024-12-25 18:33:49 +01:00
Profpatsch
0911d1ce7d WIP: initial repository setup for media.ccc streams
This uses the media.ccc.de URL as item-ID and the actual extractor to
fetch the streams.

Now we have a full top-to-bottom integration going, meaning we can
work on the stream selection based on actual data, not just made up
data.
2024-12-25 18:33:49 +01:00
Profpatsch
df3b56ed63 WIP: Correctly initialize NewPlayerViewModel
We need to pass the viewModel to the view after inflating our player
for the UI to render.
2024-12-25 18:33:49 +01:00
Profpatsch
cf351c28b0 WIP: Comment out old player
This just does an initial commenting pass to remove all references to
the old player from the video fragment, so that it won’t interfere
with FrankenPipe.
2024-12-25 18:33:49 +01:00
Profpatsch
4409a990de WIP: Play a simple media.ccc stream from the video fragment
This barely works, if you click on any video it should start playing a
media.ccc.de stream, but it does not display anything in the video
view yet.
2024-12-25 18:33:48 +01:00
Profpatsch
16b372dece TMP: temporary local gradle changes 2024-12-25 18:13:39 +01:00
Profpatsch
c02fb89359 WIP: Inject (a local) NewPlayer into the NewPipe application
This is the basic setup that allows us to inject a NewPlayer instance
into NewPipe. Frankenpipe, rise!
2024-12-25 18:12:55 +01:00
821 changed files with 4028 additions and 13029 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

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

View File

@@ -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

@@ -20,7 +20,7 @@
<p align="center"><a href="https://newpipe.net">Website</a> &bull; <a href="https://newpipe.net/blog/">Blog</a> &bull; <a href="https://newpipe.net/FAQ/">FAQ</a> &bull; <a href="https://newpipe.net/press/">Press</a></p>
<hr>
*Read this document in other languages: [Deutsch](doc/README.de.md), [English](README.md), [Español](doc/README.es.md), [Français](doc/README.fr.md), [हिन्दी](doc/README.hi.md), [Italiano](doc/README.it.md), [한국어](doc/README.ko.md), [Português Brasil](doc/README.pt_BR.md), [Polski](doc/README.pl.md), [ਪੰਜਾਬੀ ](doc/README.pa.md), [日本語](doc/README.ja.md), [Română](doc/README.ro.md), [Soomaali](doc/README.so.md), [Türkçe](doc/README.tr.md), [正體中文](doc/README.zh_TW.md), [অসমীয়া](doc/README.asm.md), [Српски](doc/README.sr.md), [العربية](README.ar.md)*
*Read this document in other languages: [Deutsch](doc/README.de.md), [English](README.md), [Español](doc/README.es.md), [Français](doc/README.fr.md), [हिन्दी](doc/README.hi.md), [Italiano](doc/README.it.md), [한국어](doc/README.ko.md), [Português Brasil](doc/README.pt_BR.md), [Polski](doc/README.pl.md), [ਪੰਜਾਬੀ ](doc/README.pa.md), [日本語](doc/README.ja.md), [Română](doc/README.ro.md), [Soomaali](doc/README.so.md), [Türkçe](doc/README.tr.md), [正體中文](doc/README.zh_TW.md), [অসমীয়া](doc/README.asm.md), [Српски](doc/README.sr.md)*
> [!warning]
> <b>THIS APP IS IN BETA, SO YOU MAY ENCOUNTER BUGS. IF YOU DO, OPEN AN ISSUE IN OUR GITHUB REPOSITORY BY FILLING OUT THE ISSUE TEMPLATE.</b>
@@ -96,7 +96,7 @@ Also, since they are free and open source software, neither the app nor the Extr
## Installation and updates
You can install NewPipe using one of the following methods:
1. Add our custom repo to F-Droid and install it from there. The instructions are here: https://newpipe.net/FAQ/tutorials/install-add-fdroid-repo/
2. Download the APK from [GitHub Releases](https://github.com/TeamNewPipe/NewPipe/releases), [compare the signing key](#apk-info) and install it.
2. Download the APK from [GitHub Releases](https://github.com/TeamNewPipe/NewPipe/releases) and install it.
3. Update via F-Droid. This is the slowest method of getting updates, as F-Droid must recognize changes, build the APK itself, sign it, and then push the update to users.
4. Build a debug APK yourself. This is the fastest way to get new features on your device, but is much more complicated, so we recommend using one of the other methods.
5. If you're interested in a specific feature or bugfix provided in a Pull Request in this repo, you can also download its APK from within the PR. Read the PR description for instructions. The great thing about PR-specific APKs is that they're installed side-by-side the official app, so you don't have to worry about losing your data or messing anything up.
@@ -109,15 +109,7 @@ In the meanwhile, if you want to switch sources for some reason (e.g. NewPipe's
3. Download the APK from the new source and install it
4. Import the data from step 1 via Settings > Backup and Restore > Import Database
> [!Note]
> When you're importing a database into the official app, always make sure that it is the one you exported _from_ the official app. If you import a database exported from an APK other than the official app, it may break things. Such an action is unsupported, and you should only do so when you're absolutely certain you know what you're doing.
### APK Info
This is the SHA fingerprint of NewPipe's signing key to verify downloaded APKs which are signed by us. The fingerprint is also available on [NewPipe's website](https://newpipe.net#download). This is relevant for method 2.
```
CB:84:06:9B:D6:81:16:BA:FA:E5:EE:4E:E5:B0:8A:56:7A:A6:D8:98:40:4E:7C:B1:2F:9E:75:6D:F5:CF:5C:AB
```
<b>Note: when you're importing a database into the official app, always make sure that it is the one you exported _from_ the official app. If you import a database exported from an APK other than the official app, it may break things. Such an action is unsupported, and you should only do so when you're absolutely certain you know what you're doing.</b>
## Contribution
Whether you have ideas, translations, design changes, code cleaning, or even major code changes, help is always welcome. The app gets better and better with each contribution, no matter how big or small! If you'd like to get involved, check our [contribution notes](.github/CONTRIBUTING.md).

View File

@@ -24,15 +24,8 @@ android {
resValue "string", "app_name", "NewPipe"
minSdk 21
targetSdk 33
if (System.properties.containsKey('versionCodeOverride')) {
versionCode System.getProperty('versionCodeOverride') as Integer
} else {
versionCode 1004
}
versionName "0.27.7"
if (System.properties.containsKey('versionNameSuffix')) {
versionNameSuffix System.getProperty('versionNameSuffix')
}
versionCode 999
versionName "0.27.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@@ -49,7 +42,7 @@ android {
// suffix the app id and the app name with git branch name
def workingBranch = getGitWorkingBranch()
def normalizedWorkingBranch = workingBranch.replaceFirst("^[^A-Za-z]+", "").replaceAll("[^0-9A-Za-z]+", "")
def normalizedWorkingBranch = ""
if (normalizedWorkingBranch.isEmpty() || workingBranch == "master" || workingBranch == "dev") {
// default values when branch name could not be determined or is master or dev
applicationIdSuffix ".debug"
@@ -95,16 +88,17 @@ android {
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17
freeCompilerArgs += [
// Generate default method implementations for interfaces
// https://kotlinlang.org/docs/java-to-kotlin-interop.html#default-methods-in-interfaces
'-Xjvm-default=all'
]
}
sourceSets {
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
}
androidResources {
generateLocaleConfig = true
}
buildFeatures {
viewBinding true
compose true
@@ -179,7 +173,8 @@ afterEvaluate {
if (!System.properties.containsKey('skipFormatKtlint')) {
preDebugBuild.dependsOn formatKtlint
}
preDebugBuild.dependsOn runCheckstyle, runKtlint, checkDependenciesOrder
// preDebugBuild.dependsOn runCheckstyle,
preDebugBuild.dependsOn runKtlint, checkDependenciesOrder
}
sonar {
@@ -210,6 +205,8 @@ dependencies {
implementation libs.teamnewpipe.newpipe.extractor
implementation libs.teamnewpipe.nononsense.filepicker
implementation 'com.github.TeamNewPipe:NewPlayer'
/** Checkstyle **/
checkstyle libs.tools.checkstyle
ktlint libs.tools.ktlint
@@ -237,7 +234,9 @@ dependencies {
implementation libs.androidx.work.runtime
implementation libs.androidx.work.rxjava3
implementation libs.androidx.material
implementation libs.androidx.webkit
implementation libs.androidx.media3.common
implementation libs.androidx.media3.exoplayer
implementation libs.androidx.media3.ui
/** Third-party libraries **/
// Instance state boilerplate elimination

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

@@ -64,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
@@ -432,10 +429,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

@@ -28,7 +28,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.hasAssignableCause
import org.schabi.newpipe.settings.NewPipeSettings
import org.schabi.newpipe.util.BridgeStateSaverInitializer
@@ -37,7 +36,6 @@ import org.schabi.newpipe.util.ServiceHelper
import org.schabi.newpipe.util.StateSaver
import org.schabi.newpipe.util.image.ImageStrategy
import org.schabi.newpipe.util.image.PreferredImageQuality
import org.schabi.newpipe.util.potoken.PoTokenProviderImpl
import java.io.IOException
import java.io.InterruptedIOException
import java.net.SocketException
@@ -118,8 +116,6 @@ open class App :
)
configureRxJavaErrorHandler()
YoutubeStreamExtractor.setPoTokenProvider(PoTokenProviderImpl)
}
override fun newImageLoader(context: Context): ImageLoader =

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:128.0) Gecko/20100101 Firefox/128.0";
"Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0";
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY =
"youtube_restricted_mode_key";
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000";
@@ -142,8 +142,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);
@@ -151,33 +150,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();
try (
okhttp3.Response response = client.newCall(requestBuilder.build()).execute()
) {
if (response.code() == 429) {
throw new ReCaptchaException("reCaptcha Challenge requested", url);
}
String responseBodyToReturn = null;
try (ResponseBody body = response.body()) {
if (body != null) {
responseBodyToReturn = body.string();
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 String latestUrl = response.request().url().toString();
return new Response(
response.code(),
response.message(),
response.headers().toMultimap(),
responseBodyToReturn,
latestUrl);
}
final okhttp3.Response response = client.newCall(requestBuilder.build()).execute();
if (response.code() == 429) {
response.close();
throw new ReCaptchaException("reCaptcha Challenge requested", url);
}
final ResponseBody body = response.body();
String responseBodyToReturn = null;
if (body != null) {
responseBodyToReturn = body.string();
}
final String latestUrl = response.request().url().toString();
return new Response(response.code(), response.message(), response.headers().toMultimap(),
responseBodyToReturn, latestUrl);
}
}

View File

@@ -38,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;
@@ -89,13 +88,17 @@ 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;
import java.util.List;
import java.util.Objects;
import javax.inject.Inject;
import dagger.hilt.android.AndroidEntryPoint;
@AndroidEntryPoint
public class MainActivity extends AppCompatActivity {
private static final String TAG = "MainActivity";
@SuppressWarnings("ConstantConditions")
@@ -118,8 +121,7 @@ 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;
@@ -137,19 +139,6 @@ public class MainActivity extends AppCompatActivity {
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);
@@ -186,8 +175,6 @@ public class MainActivity extends AppCompatActivity {
&& ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
UpdateSettingsFragment.askForConsentToUpdateChecks(this);
}
Localization.migrateAppLanguageSettingIfNecessary(getApplicationContext());
}
@Override
@@ -276,10 +263,6 @@ public class MainActivity extends AppCompatActivity {
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);
@@ -355,9 +338,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;
@@ -594,8 +574,8 @@ public class MainActivity extends AppCompatActivity {
if (player instanceof BackPressable backPressable && !backPressable.onBackPressed()) {
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder)
.setState(BottomSheetBehavior.STATE_COLLAPSED);
return;
}
return;
}
if (fragmentManager.getBackStackEntryCount() == 1) {
@@ -842,8 +822,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.
@@ -855,10 +834,6 @@ public class MainActivity extends AppCompatActivity {
final IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(VideoDetailFragment.ACTION_PLAYER_STARTED);
registerReceiver(broadcastReceiver, intentFilter);
// 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);
}
}
@@ -870,5 +845,4 @@ public class MainActivity extends AppCompatActivity {
return sheetState == BottomSheetBehavior.STATE_HIDDEN
|| sheetState == BottomSheetBehavior.STATE_COLLAPSED;
}
}

View File

@@ -0,0 +1,268 @@
/* NewPlayer
*
* @author Christian Schabesberger
*
* Copyright (C) NewPipe e.V. 2024 <code(at)newpipe-ev.de>
*
* NewPlayer 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.
*
* NewPlayer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with NewPlayer. If not, see <http://www.gnu.org/licenses/>.
*/
package net.newpipe.newplayer.testapp
import android.app.Application
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import android.util.Log
import androidx.annotation.OptIn
import androidx.core.graphics.drawable.IconCompat
import androidx.media3.common.MediaMetadata
import androidx.media3.common.util.UnstableApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import net.newpipe.newplayer.NewPlayer
import net.newpipe.newplayer.NewPlayerImpl
import net.newpipe.newplayer.data.AudioStreamTrack
import net.newpipe.newplayer.data.Chapter
import net.newpipe.newplayer.data.Stream
import net.newpipe.newplayer.data.Subtitle
import net.newpipe.newplayer.data.VideoStreamTrack
import net.newpipe.newplayer.repository.CachingRepository
import net.newpipe.newplayer.repository.MediaRepository
import net.newpipe.newplayer.repository.PrefetchingRepository
import okhttp3.OkHttpClient
import okhttp3.Request
import org.schabi.newpipe.App
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.services.media_ccc.extractors.MediaCCCStreamExtractor
import org.schabi.newpipe.extractor.services.media_ccc.extractors.data.MediaCCCRecording
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NewPlayerComponent {
@Provides
@Singleton
fun provideNewPlayer(app: Application): NewPlayer {
val player = NewPlayerImpl(
app = app,
repository = PrefetchingRepository(CachingRepository(MediaCCCTestRepository())),
notificationIcon = IconCompat.createWithResource(app, net.newpipe.newplayer.R.drawable.new_player_tiny_icon),
playerActivityClass = MainActivity::class.java,
// rescueStreamFault = …
)
if (app is App) {
CoroutineScope(Dispatchers.IO).launch {
while (true) {
player.errorFlow.collect { e ->
Log.e("NewPlayerException", e.stackTraceToString())
}
}
}
}
return player
}
}
class TestMediaRepository() : MediaRepository {
private val client = OkHttpClient()
override fun getRepoInfo() =
MediaRepository.RepoMetaInfo(canHandleTimestampedLinks = true, pullsDataFromNetwork = true)
@OptIn(UnstableApi::class)
override suspend fun getMetaInfo(item: String): MediaMetadata =
MediaMetadata.Builder()
.setTitle("BGP and the rule of bla")
.setArtist("mr BGP")
.setArtworkUri(Uri.parse("https://static.media.ccc.de/media/congress/2017/9072-hd.jpg"))
.setDurationMs(
1871L * 1000L
)
.build()
override suspend fun getStreams(item: String): List<Stream> {
return listOf(
Stream(
item = "bgp",
streamUri = Uri.parse("https://cdn.media.ccc.de/congress/2017/h264-hd/34c3-9072-eng-BGP_and_the_Rule_of_Custom.mp4"),
mimeType = null,
streamTracks = listOf(
AudioStreamTrack(
bitrate = 480000,
fileFormat = "MPEG4",
language = "en"
),
VideoStreamTrack(
width = 1920,
height = 1080,
frameRate = 25,
fileFormat = "MPEG4"
)
)
)
)
}
override suspend fun getSubtitles(item: String) =
emptyList<Subtitle>()
override suspend fun getPreviewThumbnail(item: String, timestampInMs: Long): Bitmap? {
val templateUrl = "https://static.media.ccc.de/media/congress/2017/9072-hd.jpg"
val thumbnailId = (timestampInMs / (10 * 1000)) + 1
if (getPreviewThumbnailsInfo(item).count < thumbnailId) {
return null
}
val thumbUrl = String.format(templateUrl, thumbnailId)
val bitmap = withContext(Dispatchers.IO) {
val request = Request.Builder().url(thumbUrl).build()
val response = client.newCall(request).execute()
try {
val responseBody = response.body
val bitmap = BitmapFactory.decodeStream(responseBody?.byteStream())
return@withContext bitmap
} catch (e: Exception) {
return@withContext null
}
}
return bitmap
}
override suspend fun getPreviewThumbnailsInfo(item: String) =
MediaRepository.PreviewThumbnailsInfo(0, 0)
override suspend fun getChapters(item: String) =
listOf<Chapter>()
override suspend fun getTimestampLink(item: String, timestampInSeconds: Long) =
""
}
class MediaCCCTestRepository() : MediaRepository {
private val client = OkHttpClient()
private val service = ServiceList.MediaCCC
suspend fun fetchPage(item: String): MediaCCCStreamExtractor {
return withContext(Dispatchers.IO) {
// TODO: handle MediaCCCLiveStreamExtractor as well
val extractor = service.getStreamExtractor(item) as MediaCCCStreamExtractor
extractor.fetchPage()
extractor
}
}
override fun getRepoInfo() =
MediaRepository.RepoMetaInfo(canHandleTimestampedLinks = true, pullsDataFromNetwork = true)
@OptIn(UnstableApi::class)
override suspend fun getMetaInfo(item: String): MediaMetadata {
val extractor = fetchPage(item)
return MediaMetadata.Builder().apply {
setTitle(extractor.name)
setArtist(extractor.subChannelName)
setDurationMs(
extractor.length * 1000L
)
extractor.thumbnails.firstOrNull()?.url?.let {
setArtworkUri(Uri.parse(it))
}
}.build()
}
override suspend fun getStreams(item: String): List<Stream> {
val extractor = fetchPage(item)
return extractor.recordings.filterIsInstance<MediaCCCRecording.Video>()
.filter { it.recordingType == MediaCCCRecording.VideoType.MAIN }
.map { track ->
Stream(
item = item,
streamUri = Uri.parse(track.url),
streamTracks =
listOf(
VideoStreamTrack(
width = track.width,
height = track.height,
fileFormat = track.mimeType
),
) +
// one audio track per language
// (TODO: probably dont need to attach the audio track here?)
track.languages.map { language ->
AudioStreamTrack(
// TODO: should we pass the Locale instead??
language = language.language,
fileFormat = track.mimeType,
// TODO: thats something ExoPlayer should determine for us,
// we dont know that from the metadata
bitrate = 44100,
)
}
)
}
}
override suspend fun getSubtitles(item: String) =
emptyList<Subtitle>()
override suspend fun getPreviewThumbnail(item: String, timestampInMs: Long): Bitmap? {
val extractor = fetchPage(item)
val templateUrl = extractor.thumbnails.firstOrNull()?.url ?: return null
val thumbnailId = (timestampInMs / (10 * 1000)) + 1
if (getPreviewThumbnailsInfo(item).count < thumbnailId) {
return null
}
val thumbUrl = String.format(templateUrl, thumbnailId)
val bitmap = withContext(Dispatchers.IO) {
val request = Request.Builder().url(thumbUrl).build()
val response = client.newCall(request).execute()
try {
val responseBody = response.body
val bitmap = BitmapFactory.decodeStream(responseBody?.byteStream())
return@withContext bitmap
} catch (e: Exception) {
return@withContext null
}
}
return bitmap
}
override suspend fun getPreviewThumbnailsInfo(item: String) =
MediaRepository.PreviewThumbnailsInfo(1, 0)
override suspend fun getChapters(item: String) =
listOf<Chapter>()
override suspend fun getTimestampLink(item: String, timestampInSeconds: Long) =
""
}

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

@@ -154,6 +154,6 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
+ " AND :streamUrl = :streamUrl"
+ " GROUP BY " + JOIN_PLAYLIST_ID
+ " ORDER BY " + PLAYLIST_DISPLAY_INDEX + ", " + PLAYLIST_NAME)
+ " ORDER BY " + PLAYLIST_DISPLAY_INDEX)
Flowable<List<PlaylistDuplicatesEntry>> getPlaylistDuplicatesMetadata(String streamUrl);
}

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

@@ -9,6 +9,7 @@ import com.google.auto.service.AutoService;
import org.acra.config.CoreConfiguration;
import org.acra.sender.ReportSender;
import org.acra.sender.ReportSenderFactory;
import org.schabi.newpipe.App;
/*
* Created by Christian Schabesberger on 13.09.16.

View File

@@ -26,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;
@@ -67,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;
@@ -103,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"));
@@ -248,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");

View File

@@ -121,6 +121,67 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
// LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
menuProvider = new MenuProvider() {
@Override
public void onCreateMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) {
inflater.inflate(R.menu.menu_channel, menu);
if (DEBUG) {
Log.d(TAG, "onCreateOptionsMenu() called with: "
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
}
}
@Override
public void onPrepareMenu(@NonNull final Menu menu) {
menuRssButton = menu.findItem(R.id.menu_item_rss);
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
updateRssButton();
updateNotifyButton(channelSubscription);
}
@Override
public boolean onMenuItemSelected(@NonNull final MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_item_notify:
final boolean value = !item.isChecked();
item.setEnabled(false);
setNotify(value);
break;
case R.id.action_settings:
NavigationHelper.openSettings(requireContext());
break;
case R.id.menu_item_rss:
if (currentInfo != null) {
ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl());
}
break;
case R.id.menu_item_openInBrowser:
if (currentInfo != null) {
ShareUtils.openUrlInBrowser(requireContext(),
currentInfo.getOriginalUrl());
}
break;
case R.id.menu_item_share:
if (currentInfo != null) {
ShareUtils.shareText(requireContext(), name,
currentInfo.getOriginalUrl(), currentInfo.getAvatars());
}
break;
default:
return false;
}
return true;
}
};
activity.addMenuProvider(menuProvider);
}
@Override
public void onAttach(@NonNull final Context context) {
super.onAttach(context);
@@ -135,67 +196,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
return binding.getRoot();
}
@Override
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
super.onViewCreated(rootView, savedInstanceState);
menuProvider = new MenuProvider() {
@Override
public void onCreateMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) {
inflater.inflate(R.menu.menu_channel, menu);
if (DEBUG) {
Log.d(TAG, "onCreateOptionsMenu() called with: "
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
}
}
@Override
public void onPrepareMenu(@NonNull final Menu menu) {
menuRssButton = menu.findItem(R.id.menu_item_rss);
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
updateRssButton();
updateNotifyButton(channelSubscription);
}
@Override
public boolean onMenuItemSelected(@NonNull final MenuItem item) {
switch (item.getItemId()) {
case R.id.menu_item_notify:
final boolean value = !item.isChecked();
item.setEnabled(false);
setNotify(value);
break;
case R.id.action_settings:
NavigationHelper.openSettings(requireContext());
break;
case R.id.menu_item_rss:
if (currentInfo != null) {
ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl());
}
break;
case R.id.menu_item_openInBrowser:
if (currentInfo != null) {
ShareUtils.openUrlInBrowser(requireContext(),
currentInfo.getOriginalUrl());
}
break;
case R.id.menu_item_share:
if (currentInfo != null) {
ShareUtils.shareText(requireContext(), name,
currentInfo.getOriginalUrl(), currentInfo.getAvatars());
}
break;
default:
return false;
}
return true;
}
};
activity.addMenuProvider(menuProvider);
}
@Override // called from onViewCreated in BaseFragment.onViewCreated
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
@@ -238,14 +238,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();
@@ -254,6 +246,7 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
}
disposables.clear();
binding = null;
activity.removeMenuProvider(menuProvider);
menuProvider = null;
}

View File

@@ -3,6 +3,7 @@ package org.schabi.newpipe.fragments.list.comments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
@@ -19,7 +20,7 @@ class CommentsFragment : Fragment() {
savedInstanceState: Bundle?
) = content {
AppTheme {
Surface {
Surface(color = MaterialTheme.colorScheme.background) {
CommentSection()
}
}

View File

@@ -3,6 +3,7 @@ package org.schabi.newpipe.fragments.list.videos
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
@@ -20,7 +21,7 @@ class RelatedItemsFragment : Fragment() {
savedInstanceState: Bundle?
) = content {
AppTheme {
Surface {
Surface(color = MaterialTheme.colorScheme.background) {
RelatedItems(requireArguments().serializable<StreamInfo>(KEY_INFO)!!)
}
}

View File

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

View File

@@ -7,16 +7,3 @@ import java.io.Serializable
inline fun <reified T : Serializable> Bundle.serializable(key: String?): T? {
return BundleCompat.getSerializable(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

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

View File

@@ -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

@@ -160,8 +160,7 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
.subscribe(ignored -> {
successToast.show();
if (playlist.thumbnailUrl != null
&& playlist.thumbnailUrl.equals(PlaylistEntity.DEFAULT_THUMBNAIL)) {
if (playlist.thumbnailUrl.equals(PlaylistEntity.DEFAULT_THUMBNAIL)) {
playlistDisposables.add(manager
.changePlaylistThumbnail(playlist.getUid(), streams.get(0).getUid(),
false)

View File

@@ -17,10 +17,8 @@ import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.extractor.Info
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.feed.FeedInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.ktx.getStringSafe
import org.schabi.newpipe.local.feed.FeedDatabaseManager
import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.util.ChannelTabHelper
@@ -71,10 +69,12 @@ class FeedLoadManager(private val context: Context) {
val outdatedThreshold = if (ignoreOutdatedThreshold) {
OffsetDateTime.now(ZoneOffset.UTC)
} else {
val thresholdOutdatedSeconds = defaultSharedPreferences.getStringSafe(
context.getString(R.string.feed_update_threshold_key),
context.getString(R.string.feed_update_threshold_default_value)
).toInt()
val thresholdOutdatedSeconds = (
defaultSharedPreferences.getString(
context.getString(R.string.feed_update_threshold_key),
context.getString(R.string.feed_update_threshold_default_value)
) ?: context.getString(R.string.feed_update_threshold_default_value)
).toInt()
OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(thresholdOutdatedSeconds.toLong())
}
@@ -91,10 +91,6 @@ class FeedLoadManager(private val context: Context) {
else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
}
// like `currentProgress`, but counts the number of YouTube extractions that have begun, so
// they can be properly throttled every once in a while (see doOnNext below)
val youtubeExtractionCount = AtomicInteger()
return outdatedSubscriptions
.take(1)
.doOnNext {
@@ -110,15 +106,6 @@ class FeedLoadManager(private val context: Context) {
.observeOn(Schedulers.io())
.flatMap { Flowable.fromIterable(it) }
.takeWhile { !cancelSignal.get() }
.doOnNext { subscriptionEntity ->
// throttle YouTube extractions once every BATCH_SIZE to avoid being rate limited
if (subscriptionEntity.serviceId == ServiceList.YouTube.serviceId) {
val previousCount = youtubeExtractionCount.getAndIncrement()
if (previousCount != 0 && previousCount % BATCH_SIZE == 0) {
Thread.sleep(DELAY_BETWEEN_BATCHES_MILLIS.random())
}
}
}
.parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2)
.runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
.filter { !cancelSignal.get() }
@@ -342,19 +329,7 @@ class FeedLoadManager(private val context: Context) {
/**
* How many extractions will be running in parallel.
*/
private const val PARALLEL_EXTRACTIONS = 3
/**
* How many YouTube extractions to perform before waiting [DELAY_BETWEEN_BATCHES_MILLIS]
* to avoid being rate limited
*/
private const val BATCH_SIZE = 50
/**
* Wait a random delay in this range once every [BATCH_SIZE] YouTube extractions to avoid
* being rate limited
*/
private val DELAY_BETWEEN_BATCHES_MILLIS = (6000L..12000L)
private const val PARALLEL_EXTRACTIONS = 6
/**
* Number of items to buffer to mass-insert in the database.

View File

@@ -332,6 +332,10 @@ public class StatisticsPlaylistFragment
StreamDialogDefaultEntry.DELETE,
(f, i) -> deleteEntry(
Math.max(itemListAdapter.getItemsList().indexOf(item), 0)))
.setAction(
StreamDialogDefaultEntry.START_HERE_ON_BACKGROUND,
(f, i) -> NavigationHelper.playOnBackgroundPlayer(
context, getPlayQueueStartingAt(item), true))
.create()
.show();
} catch (final IllegalArgumentException e) {

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
)))
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
urlsText -> {
final String content = shareMode == WITH_TITLES
? context.getString(R.string.share_playlist_content_details,
name,
urlsText
)
: urlsText;
ShareUtils.shareText(context, name, content);
},
throwable -> showUiErrorSnackbar(this, "Sharing playlist", throwable)
)
);
.flatMapSingle(playlist -> Single.just(playlist.stream()
.map(PlaylistStreamEntry::getStreamEntity)
.map(streamEntity -> {
if (shouldSharePlaylistDetails) {
return context.getString(R.string.video_details_list_item,
streamEntity.getTitle(), streamEntity.getUrl());
} else {
return streamEntity.getUrl();
}
})
.collect(Collectors.joining("\n"))))
.observeOn(AndroidSchedulers.mainThread())
.subscribe(urlsText -> ShareUtils.shareText(
context, name, shouldSharePlaylistDetails
? context.getString(R.string.share_playlist_content_details,
name, urlsText) : urlsText),
throwable -> showUiErrorSnackbar(this, "Sharing playlist", throwable)));
}
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

@@ -76,10 +76,7 @@ public class SubscriptionsExportService extends BaseImportExportService {
try {
outFile = new StoredFileHelper(this, path, "application/json");
// truncate the file before writing to it, otherwise if the new content is smaller than
// the previous file size, the file will retain part of the previous content and be
// corrupted
outputStream = new SharpOutputStream(outFile.openAndTruncateStream());
outputStream = new SharpOutputStream(outFile.getStream());
} catch (final IOException e) {
handleError(e);
return START_NOT_STICKY;

View File

@@ -183,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);
@@ -224,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

@@ -55,7 +55,6 @@ import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.media.AudioManager;
import android.support.v4.media.session.MediaSessionCompat;
import android.util.Log;
import android.view.LayoutInflater;
@@ -72,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;
@@ -265,16 +263,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);
@@ -305,7 +294,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)
);
}
@@ -465,6 +454,7 @@ public final class Player implements PlaybackListener, Listener {
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)
setRecovery();
reloadPlayQueueManager();
}
@@ -473,15 +463,14 @@ public final class Player implements PlaybackListener, Listener {
}
private void initUIsForCurrentPlayerType() {
if ((UIs.getOpt(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN)
|| (UIs.getOpt(PopupPlayerUi.class).isPresent()
&& playerType == PlayerType.POPUP)) {
if ((UIs.get(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN)
|| (UIs.get(PopupPlayerUi.class).isPresent() && playerType == PlayerType.POPUP)) {
// correct UI already in place
return;
}
// try to reuse binding if possible
final PlayerBinding binding = UIs.getOpt(VideoPlayerUi.class).map(VideoPlayerUi::getBinding)
final PlayerBinding binding = UIs.get(VideoPlayerUi.class).map(VideoPlayerUi::getBinding)
.orElseGet(() -> {
if (playerType == PlayerType.AUDIO) {
return null;
@@ -492,15 +481,15 @@ public final class Player implements PlaybackListener, Listener {
switch (playerType) {
case MAIN:
UIs.destroyAllOfType(PopupPlayerUi.class);
UIs.destroyAll(PopupPlayerUi.class);
UIs.addAndPrepare(new MainPlayerUi(this, binding));
break;
case POPUP:
UIs.destroyAllOfType(MainPlayerUi.class);
UIs.destroyAll(MainPlayerUi.class);
UIs.addAndPrepare(new PopupPlayerUi(this, binding));
break;
case AUDIO:
UIs.destroyAllOfType(VideoPlayerUi.class);
UIs.destroyAll(VideoPlayerUi.class);
break;
}
}
@@ -591,15 +580,9 @@ public final class Player implements PlaybackListener, Listener {
}
}
/**
* Shut down this player.
* Saves the stream progress, sets recovery.
* Then destroys the player in all UIs and destroys the UIs as well.
*/
public void saveAndShutdown() {
public void destroy() {
if (DEBUG) {
Log.d(TAG, "saveAndShutdown() called");
Log.d(TAG, "destroy() called");
}
saveStreamProgressState();
@@ -612,7 +595,7 @@ public final class Player implements PlaybackListener, Listener {
databaseUpdateDisposable.clear();
progressUpdateDisposable.set(null);
UIs.destroyAllOfType(null);
UIs.destroyAll(Object.class); // destroy every UI: obviously every UI extends Object
}
public void setRecovery() {
@@ -655,7 +638,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() {
@@ -727,7 +710,7 @@ public final class Player implements PlaybackListener, Listener {
pause();
break;
case ACTION_CLOSE:
service.destroyPlayerAndStopService();
service.stopService();
break;
case ACTION_PLAY_PAUSE:
playPause();
@@ -1374,19 +1357,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
@@ -2001,10 +1971,6 @@ public final class Player implements PlaybackListener, Listener {
triggerProgressUpdate();
}
/**
* Remove the listener, if it was set.
* @param listener listener to remove
* */
public void removeFragmentListener(final PlayerServiceEventListener listener) {
if (fragmentListener == listener) {
fragmentListener = null;
@@ -2019,10 +1985,6 @@ public final class Player implements PlaybackListener, Listener {
triggerProgressUpdate();
}
/**
* Remove the listener, if it was set.
* @param listener listener to remove
* */
void removeActivityListener(final PlayerEventListener listener) {
if (activityListener == listener) {
activityListener = null;

View File

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

View File

@@ -1,353 +0,0 @@
/*
* Copyright 2017 Mauricio Colli <mauriciocolli@outlook.com>
* Part of NewPipe
*
* License: GPL-3.0+
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
package org.schabi.newpipe.player
import android.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.core.app.ServiceCompat
import androidx.media.MediaBrowserServiceCompat
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
import org.schabi.newpipe.ktx.toDebugString
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.util.Localization
import org.schabi.newpipe.util.ThemeHelper
import java.lang.ref.WeakReference
import java.util.function.BiConsumer
import java.util.function.Consumer
/**
* One service for all players.
*/
class PlayerService : MediaBrowserServiceCompat() {
// 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 var mediaBrowserImpl: MediaBrowserImpl? = null
private var mediaBrowserPlaybackPreparer: MediaBrowserPlaybackPreparer? = null
// these are instantiated in onCreate() as per
// https://developer.android.com/training/cars/media#browser_workflow
private var mediaSession: MediaSessionCompat? = null
private var sessionConnector: MediaSessionConnector? = null
/**
* @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.
*/
var player: Player? = null
private set
private val mBinder: IBinder = LocalBinder(this)
/**
* The parameter taken by this [Consumer] can be null to indicate the player is being
* stopped.
*/
private var onPlayerStartedOrStopped: Consumer<Player?>? = null
//region Service lifecycle
override fun onCreate() {
super.onCreate()
if (DEBUG) {
Log.d(TAG, "onCreate() called")
}
Localization.assureCorrectAppLanguage(this)
ThemeHelper.setTheme(this)
mediaBrowserImpl = MediaBrowserImpl(
this,
Consumer { parentId: String? ->
this.notifyChildrenChanged(
parentId!!
)
}
)
// see https://developer.android.com/training/cars/media#browser_workflow
mediaSession = MediaSessionCompat(this, "MediaSessionPlayerServ")
setSessionToken(mediaSession!!.sessionToken)
sessionConnector = MediaSessionConnector(mediaSession!!)
sessionConnector!!.setMetadataDeduplicationEnabled(true)
mediaBrowserPlaybackPreparer = MediaBrowserPlaybackPreparer(
this,
BiConsumer { message: String?, code: Int? ->
sessionConnector!!.setCustomErrorMessage(
message,
code!!
)
},
Runnable { sessionConnector!!.setCustomErrorMessage(null) },
Consumer { playWhenReady: Boolean? ->
if (player != null) {
player!!.onPrepare()
}
}
)
sessionConnector!!.setPlaybackPreparer(mediaBrowserPlaybackPreparer)
// Note: you might be tempted to create the player instance and call startForeground here,
// but be aware that the Android system might start the service just to perform media
// queries. In those cases creating a player instance is a waste of resources, and calling
// startForeground means creating a useless empty notification. In case it's really needed
// the player instance can be created here, but startForeground() should definitely not be
// called here unless the service is actually starting in the foreground, to avoid the
// useless notification.
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
if (DEBUG) {
Log.d(
TAG,
(
"onStartCommand() called with: intent = [" + intent +
"], extras = [" + intent.extras.toDebugString() +
"], 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)) {
val playerWasNull = (player == null)
if (playerWasNull) {
// make sure the player exists, in case the service was resumed
player = Player(this, mediaSession!!, sessionConnector!!)
}
// Be sure that the player notification is set and the service is started in foreground,
// otherwise, the app may crash on Android 8+ as the service would never be put in the
// foreground while we said to the system we would do so. The service is always
// requested to be started in foreground, so always creating a notification if there is
// no one already and starting the service in foreground should not create any issues.
// If the service is already started in foreground, requesting it to be started
// shouldn't do anything.
player!!.UIs().get(NotificationPlayerUi::class.java)
?.createNotificationAndStartForeground()
if (playerWasNull && onPlayerStartedOrStopped != null) {
// notify that a new player was created (but do it after creating the foreground
// notification just to make sure we don't incur, due to slowness, in
// "Context.startForegroundService() did not then call Service.startForeground()")
onPlayerStartedOrStopped!!.accept(player)
}
}
if (Intent.ACTION_MEDIA_BUTTON == intent.action &&
(player == null || player!!.playQueue == 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
*/
destroyPlayerAndStopService()
return START_NOT_STICKY
}
val p = player
if (p != null) {
p.handleIntent(intent)
p.UIs().get(MediaSessionPlayerUi::class.java)
?.handleMediaButtonIntent(intent)
}
return START_NOT_STICKY
}
fun stopForImmediateReusing() {
if (DEBUG) {
Log.d(TAG, "stopForImmediateReusing() called")
}
if (player != null && !player!!.exoPlayerIsNull()) {
// Releases wifi & cpu, disables keepScreenOn, etc.
// We can't just pause the player here because it will make transition
// from one stream to a new stream not smooth
player!!.smoothStopForImmediateReusing()
}
}
override fun onTaskRemoved(rootIntent: Intent?) {
super.onTaskRemoved(rootIntent)
if (player != null && !player!!.videoPlayerSelected()) {
return
}
onDestroy()
// Unload from memory completely
Runtime.getRuntime().halt(0)
}
override fun onDestroy() {
if (DEBUG) {
Log.d(TAG, "destroy() called")
}
super.onDestroy()
cleanup()
mediaBrowserPlaybackPreparer!!.dispose()
mediaSession!!.release()
mediaBrowserImpl!!.dispose()
}
private fun cleanup() {
if (player != null) {
if (onPlayerStartedOrStopped != null) {
// notify that the player is being destroyed
onPlayerStartedOrStopped!!.accept(null)
}
player!!.saveAndShutdown()
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 [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).
*/
fun destroyPlayerAndStopService() {
if (DEBUG) {
Log.d(TAG, "destroyPlayerAndStopService() called")
}
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(Intent(this, PlayerService::class.java))
}
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base))
}
//endregion
//region Bind
override fun onBind(intent: Intent): IBinder? {
if (DEBUG) {
Log.d(
TAG,
(
"onBind() called with: intent = [" + intent +
"], extras = [" + intent.extras.toDebugString() + "]"
)
)
}
if (BIND_PLAYER_HOLDER_ACTION == intent.action) {
// 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 (SERVICE_INTERFACE == intent.action) {
// 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
}
}
class LocalBinder internal constructor(playerService: PlayerService) : Binder() {
private val playerService: WeakReference<PlayerService?>
init {
this.playerService = WeakReference<PlayerService?>(playerService)
}
val service: PlayerService?
get() = playerService.get()
}
/**
* Sets the listener that will be called when the player is started or stopped. If a
* `null` listener is passed, then the current listener will be unset. The parameter taken
* by the [Consumer] can be null to indicate that the player is stopping.
* @param listener the listener to set or unset
*/
fun setPlayerListener(listener: Consumer<Player?>?) {
this.onPlayerStartedOrStopped = listener
if (listener != null) {
// if there is no player, then `null` will be sent here, to ensure the state is synced
listener.accept(player)
}
}
//endregion
//region Media browser
override fun onGetRoot(
clientPackageName: String,
clientUid: Int,
rootHints: Bundle?
): BrowserRoot {
// TODO check if the accessing package has permission to view data
return mediaBrowserImpl!!.onGetRoot(clientPackageName, clientUid, rootHints)
}
override fun onLoadChildren(
parentId: String,
result: Result<List<MediaBrowserCompat.MediaItem>>
) {
mediaBrowserImpl!!.onLoadChildren(parentId, result)
}
override fun onSearch(
query: String,
extras: Bundle?,
result: Result<List<MediaBrowserCompat.MediaItem>>
) {
mediaBrowserImpl!!.onSearch(query, result)
} //endregion
companion object {
private val TAG: String = PlayerService::class.java.getSimpleName()
private val DEBUG = Player.DEBUG
const val SHOULD_START_FOREGROUND_EXTRA: String = "should_start_foreground_extra"
const val BIND_PLAYER_HOLDER_ACTION: String = "bind_player_holder_action"
}
}

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.connect();
httpURLConnection.setDoOutput(httpBody != null);
final OutputStream os = httpURLConnection.getOutputStream();
os.write(POST_BODY);
os.close();
// 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(httpBody);
os.close();
} else {
httpURLConnection.connect();
}
return httpURLConnection;
}

View File

@@ -5,7 +5,6 @@ import com.google.android.exoplayer2.PlaybackParameters;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.player.playqueue.PlayQueue;
/** Player-specific events like queue or progress updates. */
public interface PlayerEventListener {
void onQueueUpdate(PlayQueue queue);
void onPlaybackUpdate(int state, int repeatMode, boolean shuffled,

View File

@@ -2,9 +2,6 @@ package org.schabi.newpipe.player.event;
import com.google.android.exoplayer2.PlaybackException;
/** {@link org.schabi.newpipe.player.event.PlayerEventListener} that also gets called for
* application-specific events like screen rotation or UI changes.
*/
public interface PlayerServiceEventListener extends PlayerEventListener {
void onViewCreated();

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

@@ -16,16 +16,12 @@ import com.google.android.exoplayer2.PlaybackParameters;
import org.schabi.newpipe.App;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.PlayerService;
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
}
}
@@ -120,19 +119,8 @@ public final class PlayerHolder {
return App.getInstance();
}
/**
* Connect to (and if needed start) the {@link PlayerService}
* and bind {@link PlayerServiceConnection} to it.
* If the service is already started, only set the listener.
* @param playAfterConnect If this holders service was already started,
* start playing immediately
* @param newListener set this listener
* */
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) {
@@ -142,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));
}
@@ -189,16 +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);
getPlayer().ifPresent(p -> listener.onPlayerConnected(p, playAfterConnect));
listener.onServiceConnected(player, playerService, playAfterConnect);
}
startPlayerListener();
// ^ will call listener.onPlayerConnected() down the line if there is an active player
// notify the main activity that binding the service has completed, so that it can
// open the bottom mini-player
NavigationHelper.sendPlayerStartedEvent(localBinder.getService());
}
}
@@ -206,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");
@@ -238,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
@@ -350,23 +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);
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,399 +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.media.MediaBrowserServiceCompat
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 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)")
}
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 = Uri.parse(parentId)
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(playlist.thumbnailUrl?.let { Uri.parse(it) })
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(Uri.parse(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(Uri.parse(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(Uri.parse(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(Uri.parse(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()
}
}

View File

@@ -1,259 +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 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.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()
}
)
}
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(
context.getString(R.string.content_not_supported),
PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED
)
}
private fun onPrepareError() {
setMediaSessionError.accept(
context.getString(R.string.error_snackbar_message),
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 = Uri.parse(mediaId)
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

@@ -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,18 +89,27 @@ public class MediaSessionPlayerUi extends PlayerUi
public void destroyPlayer() {
super.destroyPlayer();
player.getPrefs().unregisterOnSharedPreferenceChangeListener(this);
sessionConnector.setMediaButtonEventHandler(null);
sessionConnector.setPlayer(null);
sessionConnector.setQueueNavigator(null);
mediaSession.setActive(false);
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);
// the thumbnail is now loaded: invalidate the metadata to trigger a metadata update
sessionConnector.invalidateMediaSessionMetadata();
if (sessionConnector != null) {
// the thumbnail is now loaded: invalidate the metadata to trigger a metadata update
sessionConnector.invalidateMediaSessionMetadata();
}
}
@@ -138,7 +145,7 @@ public class MediaSessionPlayerUi extends PlayerUi
public void play() {
player.play();
// hide the player controls even if the play command came from the media session
player.UIs().getOpt(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0));
player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0));
}
@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

@@ -102,7 +102,7 @@ public final class NotificationUtil {
mediaStyle.setShowActionsInCompactView(compactSlots);
}
player.UIs()
.getOpt(MediaSessionPlayerUi.class)
.get(MediaSessionPlayerUi.class)
.flatMap(MediaSessionPlayerUi::getSessionToken)
.ifPresent(mediaStyle::setMediaSession);

View File

@@ -28,17 +28,13 @@ abstract class AbstractInfoPlayQueue<T extends ListInfo<? extends InfoItem>>
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

@@ -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

@@ -133,8 +133,17 @@ public class SeekbarPreviewThumbnailHolder {
// Get the bounds where the frame is found
final int[] bounds = frameset.getFrameBoundsAt(currentPosMs);
generatedDataForUrl.put(currentPosMs,
createBitmapSupplier(srcBitMap, bounds, frameset));
generatedDataForUrl.put(currentPosMs, () -> {
// It can happen, that the original bitmap could not be downloaded
// In such a case - we don't want a NullPointer - simply return null
if (srcBitMap == null) {
return null;
}
// Cut out the corresponding bitmap form the "srcBitMap"
return Bitmap.createBitmap(srcBitMap, bounds[1], bounds[2],
frameset.getFrameWidth(), frameset.getFrameHeight());
});
currentPosMs += frameset.getDurationPerFrame();
pos++;
@@ -157,45 +166,6 @@ public class SeekbarPreviewThumbnailHolder {
}
}
private Supplier<Bitmap> createBitmapSupplier(final Bitmap srcBitMap,
final int[] bounds,
final Frameset frameset) {
return () -> {
// It can happen, that the original bitmap could not be downloaded
// (or it was recycled though that should not happen)
// In such a case - we don't want a NullPointer/
// "cannot use a recycled source in createBitmap" Exception -> simply return null
if (srcBitMap == null || srcBitMap.isRecycled()) {
return null;
}
// Under some rare circumstances the YouTube API returns slightly too small storyboards,
// (or not the matching frame width/height)
// This would lead to createBitmap cutting out a bitmap that is out of bounds,
// so we need to adjust the bounds accordingly
if (srcBitMap.getWidth() < bounds[1] + frameset.getFrameWidth()) {
bounds[1] = srcBitMap.getWidth() - frameset.getFrameWidth();
}
if (srcBitMap.getHeight() < bounds[2] + frameset.getFrameHeight()) {
bounds[2] = srcBitMap.getHeight() - frameset.getFrameHeight();
}
// Cut out the corresponding bitmap form the "srcBitMap"
final Bitmap cutOutBitmap = Bitmap.createBitmap(srcBitMap, bounds[1], bounds[2],
frameset.getFrameWidth(), frameset.getFrameHeight());
// If the cut out bitmap is identical to its source,
// we need to copy the bitmap to create a new instance.
// createBitmap allows itself to return the original object that is was created with
// this leads to recycled bitmaps being returned (if they are identical)
// Reference: https://stackoverflow.com/a/23683075 + first comment
// Fixes: https://github.com/TeamNewPipe/NewPipe/issues/11461
return cutOutBitmap == srcBitMap
? cutOutBitmap.copy(cutOutBitmap.getConfig(), true) : cutOutBitmap;
};
}
@Nullable
private Bitmap getBitMapFrom(final String url) {
if (url == null) {

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

@@ -0,0 +1,90 @@
package org.schabi.newpipe.player.ui;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
public final class PlayerUiList {
final List<PlayerUi> playerUis = new ArrayList<>();
/**
* Creates a {@link PlayerUiList} starting with the provided player uis. The provided player uis
* will not be prepared like those passed to {@link #addAndPrepare(PlayerUi)}, because when
* the {@link PlayerUiList} constructor is called, the player is still not running and it
* wouldn't make sense to initialize uis then. Instead the player will initialize them by doing
* proper calls to {@link #call(Consumer)}.
*
* @param initialPlayerUis the player uis this list should start with; the order will be kept
*/
public PlayerUiList(final PlayerUi... initialPlayerUis) {
playerUis.addAll(List.of(initialPlayerUis));
}
/**
* Adds the provided player ui to the list and calls on it the initialization functions that
* apply based on the current player state. The preparation step needs to be done since when UIs
* are removed and re-added, the player will not call e.g. initPlayer again since the exoplayer
* is already initialized, but we need to notify the newly built UI that the player is ready
* nonetheless.
* @param playerUi the player ui to prepare and add to the list; its {@link
* PlayerUi#getPlayer()} will be used to query information about the player
* state
*/
public void addAndPrepare(final PlayerUi playerUi) {
if (playerUi.getPlayer().getFragmentListener().isPresent()) {
// make sure UIs know whether a service is connected or not
playerUi.onFragmentListenerSet();
}
if (!playerUi.getPlayer().exoPlayerIsNull()) {
playerUi.initPlayer();
if (playerUi.getPlayer().getPlayQueue() != null) {
playerUi.initPlayback();
}
}
playerUis.add(playerUi);
}
/**
* Destroys all matching player UIs and removes them from the list.
* @param playerUiType the class of the player UI to destroy; the {@link
* Class#isInstance(Object)} method will be used, so even subclasses will be
* destroyed and removed
* @param <T> the class type parameter
*/
public <T> void destroyAll(final Class<T> playerUiType) {
playerUis.stream()
.filter(playerUiType::isInstance)
.forEach(playerUi -> {
playerUi.destroyPlayer();
playerUi.destroy();
});
playerUis.removeIf(playerUiType::isInstance);
}
/**
* @param playerUiType the class of the player UI to return; the {@link
* Class#isInstance(Object)} method will be used, so even subclasses could
* be returned
* @param <T> the class type parameter
* @return the first player UI of the required type found in the list, or an empty {@link
* Optional} otherwise
*/
public <T> Optional<T> get(final Class<T> playerUiType) {
return playerUis.stream()
.filter(playerUiType::isInstance)
.map(playerUiType::cast)
.findFirst();
}
/**
* Calls the provided consumer on all player UIs in the list, in order of addition.
* @param consumer the consumer to call with player UIs
*/
public void call(final Consumer<PlayerUi> consumer) {
//noinspection SimplifyStreamApiCallChains
playerUis.stream().forEachOrdered(consumer);
}
}

View File

@@ -1,124 +0,0 @@
package org.schabi.newpipe.player.ui
import org.schabi.newpipe.util.GuardedByMutex
import java.util.Optional
class PlayerUiList(vararg initialPlayerUis: PlayerUi) {
private var playerUis = GuardedByMutex(mutableListOf<PlayerUi>())
/**
* Creates a [PlayerUiList] starting with the provided player uis. The provided player uis
* will not be prepared like those passed to [.addAndPrepare], because when
* the [PlayerUiList] constructor is called, the player is still not running and it
* wouldn't make sense to initialize uis then. Instead the player will initialize them by doing
* proper calls to [.call].
*
* @param initialPlayerUis the player uis this list should start with; the order will be kept
*/
init {
playerUis.runWithLockSync {
lockData.addAll(listOf(*initialPlayerUis))
}
}
/**
* Adds the provided player ui to the list and calls on it the initialization functions that
* apply based on the current player state. The preparation step needs to be done since when UIs
* are removed and re-added, the player will not call e.g. initPlayer again since the exoplayer
* is already initialized, but we need to notify the newly built UI that the player is ready
* nonetheless.
* @param playerUi the player ui to prepare and add to the list; its [PlayerUi.getPlayer]
* will be used to query information about the player state
*/
fun addAndPrepare(playerUi: PlayerUi) {
if (playerUi.getPlayer().fragmentListener.isPresent) {
// make sure UIs know whether a service is connected or not
playerUi.onFragmentListenerSet()
}
if (!playerUi.getPlayer().exoPlayerIsNull()) {
playerUi.initPlayer()
if (playerUi.getPlayer().playQueue != null) {
playerUi.initPlayback()
}
}
playerUis.runWithLockSync {
lockData.add(playerUi)
}
}
/**
* Destroys all matching player UIs and removes them from the list.
* @param playerUiType the class of the player UI to destroy, everything if `null`.
* The [Class.isInstance] method will be used, so even subclasses will be
* destroyed and removed
* @param T the class type parameter </T>
* */
fun <T : PlayerUi> destroyAllOfType(playerUiType: Class<T>? = null) {
val toDestroy = mutableListOf<PlayerUi>()
// short blocking removal from class to prevent interfering from other threads
playerUis.runWithLockSync {
val new = mutableListOf<PlayerUi>()
for (ui in lockData) {
if (playerUiType == null || playerUiType.isInstance(ui)) {
toDestroy.add(ui)
} else {
new.add(ui)
}
}
lockData = new
}
// then actually destroy the UIs
for (ui in toDestroy) {
ui.destroyPlayer()
ui.destroy()
}
}
/**
* @param playerUiType the class of the player UI to return;
* the [Class.isInstance] method will be used, so even subclasses could be returned
* @param T the class type parameter
* @return the first player UI of the required type found in the list, or null
</T> */
fun <T : PlayerUi> get(playerUiType: Class<T>): T? =
playerUis.runWithLockSync {
for (ui in lockData) {
if (playerUiType.isInstance(ui)) {
when (val r = playerUiType.cast(ui)) {
// try all UIs before returning null
null -> continue
else -> return@runWithLockSync r
}
}
}
return@runWithLockSync null
}
/**
* @param playerUiType the class of the player UI to return;
* the [Class.isInstance] method will be used, so even subclasses could be returned
* @param T the class type parameter
* @return the first player UI of the required type found in the list, or an empty
* [Optional] otherwise
</T> */
@Deprecated("use get", ReplaceWith("get(playerUiType)"))
fun <T : PlayerUi> getOpt(playerUiType: Class<T>): Optional<T> =
Optional.ofNullable(get(playerUiType))
/**
* Calls the provided consumer on all player UIs in the list, in order of addition.
* @param consumer the consumer to call with player UIs
*/
fun call(consumer: java.util.function.Consumer<PlayerUi>) {
// copy the list out of the mutex before calling the consumer which might block
val new = playerUis.runWithLockSync {
lockData.toMutableList()
}
for (ui in new) {
consumer.accept(ui)
}
}
}

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

@@ -16,7 +16,6 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString;
import static org.schabi.newpipe.player.helper.PlayerHelper.nextResizeModeAndSaveToPrefs;
import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences;
import android.annotation.SuppressLint;
import android.content.Intent;
import android.content.res.Resources;
import android.graphics.Bitmap;
@@ -762,7 +761,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
}
/**
* Update the play/pause button (`R.id.playPauseButton`) to reflect the action
* Update the play/pause button ({@link R.id.playPauseButton}) to reflect the action
* that will be performed when the button is clicked..
* @param action the action that is performed when the play/pause button is clicked
*/
@@ -948,8 +947,6 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
player.toggleShuffleModeEnabled();
}
// TODO: dont reference internal exoplayer2 resources
@SuppressLint("PrivateResource")
@Override
public void onRepeatModeChanged(@RepeatMode final int repeatMode) {
super.onRepeatModeChanged(repeatMode);
@@ -1417,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,15 +1,10 @@
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.preference.Preference;
import org.schabi.newpipe.DownloaderImpl;
@@ -20,13 +15,13 @@ import org.schabi.newpipe.extractor.localization.Localization;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PreferredImageQuality;
import java.util.Locale;
import coil3.SingletonImageLoader;
public class ContentSettingsFragment extends BasePreferenceFragment {
private String youtubeRestrictedModeEnabledKey;
private Localization initialSelectedLocalization;
private ContentCountry initialSelectedContentCountry;
private String initialLanguage;
@Override
@@ -35,28 +30,12 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
addPreferencesFromResourceRegistry();
initialSelectedLocalization = org.schabi.newpipe.util.Localization
.getPreferredLocalization(requireContext());
initialSelectedContentCountry = org.schabi.newpipe.util.Localization
.getPreferredContentCountry(requireContext());
initialLanguage = defaultPreferences.getString(getString(R.string.app_language_key), "en");
if (Build.VERSION.SDK_INT >= 33) {
requirePreference(R.string.app_language_key).setVisible(false);
final Preference newAppLanguagePref =
requirePreference(R.string.app_language_android_13_and_up_key);
newAppLanguagePref.setSummaryProvider(preference -> {
final Locale customLocale = AppCompatDelegate.getApplicationLocales().get(0);
if (customLocale != null) {
return customLocale.getDisplayName();
}
return 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);
}
final Preference imageQualityPreference = requirePreference(R.string.image_quality_key);
imageQualityPreference.setOnPreferenceChangeListener(
(preference, newValue) -> {
@@ -91,21 +70,19 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
public void onDestroy() {
super.onDestroy();
final Localization selectedLocalization = org.schabi.newpipe.util.Localization
.getPreferredLocalization(requireContext());
final ContentCountry selectedContentCountry = org.schabi.newpipe.util.Localization
.getPreferredContentCountry(requireContext());
final String selectedLanguage =
defaultPreferences.getString(getString(R.string.app_language_key), "en");
if (!selectedLanguage.equals(initialLanguage)) {
if (Build.VERSION.SDK_INT < 33) {
Toast.makeText(
requireContext(),
R.string.localization_changes_requires_app_restart,
Toast.LENGTH_LONG
).show();
}
final Localization selectedLocalization = org.schabi.newpipe.util.Localization
.getPreferredLocalization(requireContext());
final ContentCountry selectedContentCountry = org.schabi.newpipe.util.Localization
.getPreferredContentCountry(requireContext());
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

@@ -3,10 +3,8 @@ package org.schabi.newpipe.settings
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.graphics.Color
import android.os.Build
import android.os.Bundle
import androidx.preference.Preference
import androidx.preference.SwitchPreference
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
@@ -23,17 +21,15 @@ import org.schabi.newpipe.local.subscription.SubscriptionManager
class NotificationsSettingsFragment : BasePreferenceFragment(), OnSharedPreferenceChangeListener {
private var streamsNotificationsPreference: SwitchPreference? = null
private var notificationWarningSnackbar: Snackbar? = null
private var loader: Disposable? = null
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.notifications_settings)
streamsNotificationsPreference =
findPreference(getString(R.string.enable_streams_notifications))
// main check is done in onResume, but also do it here to prevent flickering
updateEnabledState(NotificationHelper.areNotificationsEnabledOnDevice(requireContext()))
preferenceScreen.isEnabled =
NotificationHelper.areNotificationsEnabledOnDevice(requireContext())
}
override fun onStart() {
@@ -72,7 +68,7 @@ class NotificationsSettingsFragment : BasePreferenceFragment(), OnSharedPreferen
// If they are disabled, show a snackbar informing the user about that
// while allowing them to open the device's app settings.
val enabled = NotificationHelper.areNotificationsEnabledOnDevice(requireContext())
updateEnabledState(enabled)
preferenceScreen.isEnabled = enabled // it is disabled by default, see the xml
if (!enabled) {
if (notificationWarningSnackbar == null) {
notificationWarningSnackbar = Snackbar.make(
@@ -113,16 +109,6 @@ class NotificationsSettingsFragment : BasePreferenceFragment(), OnSharedPreferen
super.onPause()
}
private fun updateEnabledState(enabled: Boolean) {
// On Android 13 player notifications are exempt from notification settings
// so the preferences in app should always be available.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
streamsNotificationsPreference?.isEnabled = enabled
} else {
preferenceScreen.isEnabled = enabled
}
}
private fun updateSubscriptions(subscriptions: List<SubscriptionEntity>) {
val notified = subscriptions.count { it.notificationMode != NotificationMode.DISABLED }
val preference = findPreference<Preference>(getString(R.string.streams_notifications_channels_key))

View File

@@ -24,9 +24,8 @@ class ImportExportManager(private val fileLocator: BackupFileLocator) {
*/
@Throws(Exception::class)
fun exportDatabase(preferences: SharedPreferences, file: StoredFileHelper) {
// truncate the file before writing to it, otherwise if the new content is smaller than the
// previous file size, the file will retain part of the previous content and be corrupted
ZipOutputStream(SharpOutputStream(file.openAndTruncateStream()).buffered()).use { outZip ->
file.create()
ZipOutputStream(SharpOutputStream(file.stream).buffered()).use { outZip ->
// add the database
ZipHelper.addFileToZip(
outZip,

View File

@@ -189,19 +189,6 @@ public class StoredFileHelper implements Serializable {
}
}
public SharpStream openAndTruncateStream() throws IOException {
final SharpStream sharpStream = getStream();
try {
sharpStream.setLength(0);
} catch (final Throwable e) {
// we can't use try-with-resources here, since we only want to close the stream if an
// exception occurs, but leave it open if everything goes well
sharpStream.close();
throw e;
}
return sharpStream;
}
/**
* Indicates whether it's using the {@code java.io} API.
*

View File

@@ -13,6 +13,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SearchBar
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
@@ -90,7 +91,14 @@ fun Toolbar(
active = true,
onActiveChange = {
isSearchActive = it
}
},
colors = SearchBarDefaults.colors(
containerColor = MaterialTheme.colorScheme.background,
inputFieldColors = SearchBarDefaults.inputFieldColors(
focusedTextColor = MaterialTheme.colorScheme.onBackground,
unfocusedTextColor = MaterialTheme.colorScheme.onBackground
)
)
) {
onSearchQueryChange?.invoke(query)?.takeIf { it.isNotEmpty() }
?.map { suggestionText -> SearchSuggestionItem(text = suggestionText) }

View File

@@ -3,11 +3,16 @@ package org.schabi.newpipe.ui.components.common
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
fun LoadingIndicator(modifier: Modifier = Modifier) {
CircularProgressIndicator(modifier = modifier.fillMaxSize().wrapContentSize(Alignment.Center))
CircularProgressIndicator(
modifier = modifier.fillMaxSize().wrapContentSize(Alignment.Center),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
}

View File

@@ -4,7 +4,6 @@ import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import my.nanihadesuka.compose.LazyColumnScrollbar
import my.nanihadesuka.compose.ScrollbarSettings
@Composable
@@ -21,7 +20,7 @@ fun LazyColumnThemedScrollbar(
indicatorContent: (@Composable (index: Int, isThumbSelected: Boolean) -> Unit)? = null,
content: @Composable () -> Unit
) {
LazyColumnScrollbar(
my.nanihadesuka.compose.LazyColumnScrollbar(
state = state,
modifier = modifier,
settings = settings,

View File

@@ -64,7 +64,7 @@ private fun PlaylistListItemPreview() {
playlist.uploaderName = "Uploader"
AppTheme {
Surface {
Surface(color = MaterialTheme.colorScheme.background) {
PlaylistListItem(playlist)
}
}

View File

@@ -78,7 +78,7 @@ private fun StreamListItemPreview(
@PreviewParameter(StreamItemPreviewProvider::class) stream: StreamInfoItem
) {
AppTheme {
Surface {
Surface(color = MaterialTheme.colorScheme.background) {
StreamListItem(stream, showProgress = false, isSelected = false)
}
}

View File

@@ -1,7 +1,9 @@
package org.schabi.newpipe.ui.components.items.stream
import androidx.annotation.StringRes
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
@@ -32,8 +34,8 @@ fun StreamMenu(
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
if (playerHolder.isPlayQueueReady) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.enqueue_stream)) },
StreamMenuItem(
text = R.string.enqueue_stream,
onClick = {
onDismissRequest()
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
@@ -43,8 +45,8 @@ fun StreamMenu(
)
if (playerHolder.queuePosition < playerHolder.queueSize - 1) {
DropdownMenuItem(
text = { Text(text = stringResource(R.string.enqueue_next_stream)) },
StreamMenuItem(
text = R.string.enqueue_next_stream,
onClick = {
onDismissRequest()
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
@@ -55,8 +57,8 @@ fun StreamMenu(
}
}
DropdownMenuItem(
text = { Text(text = stringResource(R.string.start_here_on_background)) },
StreamMenuItem(
text = R.string.start_here_on_background,
onClick = {
onDismissRequest()
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
@@ -64,8 +66,8 @@ fun StreamMenu(
}
}
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.start_here_on_popup)) },
StreamMenuItem(
text = R.string.start_here_on_popup,
onClick = {
onDismissRequest()
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
@@ -73,8 +75,8 @@ fun StreamMenu(
}
}
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.download)) },
StreamMenuItem(
text = R.string.download,
onClick = {
onDismissRequest()
SparseItemUtil.fetchStreamInfoAndSaveToDatabase(
@@ -87,8 +89,8 @@ fun StreamMenu(
}
}
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.add_to_playlist)) },
StreamMenuItem(
text = R.string.add_to_playlist,
onClick = {
onDismissRequest()
val list = listOf(StreamEntity(stream))
@@ -101,29 +103,29 @@ fun StreamMenu(
}
}
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.share)) },
StreamMenuItem(
text = R.string.share,
onClick = {
onDismissRequest()
ShareUtils.shareText(context, stream.name, stream.url, stream.thumbnails)
}
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.open_in_browser)) },
StreamMenuItem(
text = R.string.open_in_browser,
onClick = {
onDismissRequest()
ShareUtils.openUrlInBrowser(context, stream.url)
}
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.mark_as_watched)) },
StreamMenuItem(
text = R.string.mark_as_watched,
onClick = {
onDismissRequest()
streamViewModel.markAsWatched(stream)
}
)
DropdownMenuItem(
text = { Text(text = stringResource(R.string.show_channel_details)) },
StreamMenuItem(
text = R.string.show_channel_details,
onClick = {
onDismissRequest()
SparseItemUtil.fetchUploaderUrlIfSparse(
@@ -136,3 +138,16 @@ fun StreamMenu(
)
}
}
@Composable
private fun StreamMenuItem(
@StringRes text: Int,
onClick: () -> Unit
) {
DropdownMenuItem(
text = {
Text(text = stringResource(text), color = MaterialTheme.colorScheme.onBackground)
},
onClick = onClick
)
}

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
@@ -93,7 +94,7 @@ private fun RelatedItemsPreview() {
)
AppTheme {
Surface {
Surface(color = MaterialTheme.colorScheme.background) {
RelatedItems(info)
}
}

View File

@@ -99,12 +99,10 @@ fun Comment(comment: CommentsInfoItem, onCommentAuthorOpened: () -> Unit) {
}
val nameAndDate = remember(comment) {
Localization.concatenateStrings(
Localization.localizeUserName(comment.uploaderName),
Localization.relativeTimeOrTextual(
context, comment.uploadDate, comment.textualUploadDate
)
val date = Localization.relativeTimeOrTextual(
context, comment.uploadDate, comment.textualUploadDate
)
Localization.concatenateStrings(comment.uploaderName, date)
}
Text(
text = nameAndDate,
@@ -259,7 +257,7 @@ private fun CommentPreview(
@PreviewParameter(CommentPreviewProvider::class) commentsInfoItem: CommentsInfoItem
) {
AppTheme {
Surface {
Surface(color = MaterialTheme.colorScheme.background) {
Comment(commentsInfoItem) {}
}
}
@@ -269,7 +267,7 @@ private fun CommentPreview(
@Composable
private fun CommentListPreview() {
AppTheme {
Surface {
Surface(color = MaterialTheme.colorScheme.background) {
Column {
for (comment in CommentPreviewProvider().values) {
Comment(comment) {}

View File

@@ -6,11 +6,14 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
@@ -84,64 +87,71 @@ private fun CommentRepliesDialog(
sheetState = sheetState,
onDismissRequest = onDismissRequest,
) {
LazyColumnThemedScrollbar(state = listState) {
LazyColumn(
modifier = Modifier.nestedScroll(nestedScrollInterop),
state = listState
) {
item {
CommentRepliesHeader(
comment = parentComment,
onCommentAuthorOpened = nestedOnCommentAuthorOpened,
)
HorizontalDivider(
thickness = 1.dp,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
)
}
if (parentComment.replyCount >= 0) {
CompositionLocalProvider(
// contentColorFor(MaterialTheme.colorScheme.containerColor), i.e. ModalBottomSheet's
// default background color, does not resolve correctly, so need to manually set the
// content color for MaterialTheme.colorScheme.background instead
LocalContentColor provides contentColorFor(MaterialTheme.colorScheme.background)
) {
LazyColumnThemedScrollbar(state = listState) {
LazyColumn(
modifier = Modifier.nestedScroll(nestedScrollInterop),
state = listState
) {
item {
Text(
modifier = Modifier.padding(
horizontal = 12.dp,
vertical = 4.dp
),
text = pluralStringResource(
R.plurals.replies,
parentComment.replyCount,
parentComment.replyCount,
),
maxLines = 1,
style = MaterialTheme.typography.titleMedium
)
}
}
if (comments.itemCount == 0) {
item {
val refresh = comments.loadState.refresh
if (refresh is LoadState.Loading) {
LoadingIndicator(modifier = Modifier.padding(top = 8.dp))
} else if (refresh is LoadState.Error) {
// TODO use error panel instead
EmptyStateComposable(
EmptyStateSpec.DisabledComments.copy(
descriptionText = {
stringResource(R.string.error_unable_to_load_comments)
},
),
)
} else {
EmptyStateComposable(EmptyStateSpec.NoComments)
}
}
} else {
items(comments.itemCount) {
Comment(
comment = comments[it]!!,
CommentRepliesHeader(
comment = parentComment,
onCommentAuthorOpened = nestedOnCommentAuthorOpened,
)
HorizontalDivider(
thickness = 1.dp,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
)
}
if (parentComment.replyCount >= 0) {
item {
Text(
modifier = Modifier.padding(
horizontal = 12.dp,
vertical = 4.dp
),
text = pluralStringResource(
R.plurals.replies,
parentComment.replyCount,
parentComment.replyCount,
),
maxLines = 1,
style = MaterialTheme.typography.titleMedium
)
}
}
if (comments.itemCount == 0) {
item {
val refresh = comments.loadState.refresh
if (refresh is LoadState.Loading) {
LoadingIndicator(modifier = Modifier.padding(top = 8.dp))
} else if (refresh is LoadState.Error) {
// TODO use error panel instead
EmptyStateComposable(
EmptyStateSpec.DisabledComments.copy(
descriptionText = {
stringResource(R.string.error_unable_to_load_comments)
}
)
)
} else {
EmptyStateComposable(EmptyStateSpec.NoComments)
}
}
} else {
items(comments.itemCount) {
Comment(
comment = comments[it]!!,
onCommentAuthorOpened = nestedOnCommentAuthorOpened,
)
}
}
}
}

View File

@@ -143,7 +143,7 @@ fun CommentRepliesHeaderPreview() {
)
AppTheme {
Surface {
Surface(color = MaterialTheme.colorScheme.background) {
CommentRepliesHeader(comment) {}
}
}

View File

@@ -139,7 +139,7 @@ private fun CommentSection(
@Composable
private fun CommentSectionLoadingPreview() {
AppTheme {
Surface {
Surface(color = MaterialTheme.colorScheme.background) {
CommentSection(uiState = Resource.Loading, commentsFlow = flowOf())
}
}
@@ -167,7 +167,7 @@ private fun CommentSectionSuccessPreview() {
}
AppTheme {
Surface {
Surface(color = MaterialTheme.colorScheme.background) {
CommentSection(
uiState = Resource.Success(
CommentInfo(
@@ -186,7 +186,7 @@ private fun CommentSectionSuccessPreview() {
@Composable
private fun CommentSectionErrorPreview() {
AppTheme {
Surface {
Surface(color = MaterialTheme.colorScheme.background) {
CommentSection(uiState = Resource.Error(RuntimeException()), commentsFlow = flowOf())
}
}

View File

@@ -2,74 +2,62 @@ package org.schabi.newpipe.ui.theme
import androidx.compose.ui.graphics.Color
val primaryLight = Color(0xFF904A45)
val onPrimaryLight = Color(0xFFFFFFFF)
val primaryContainerLight = Color(0xFFFFDAD6)
val onPrimaryContainerLight = Color(0xFF3B0908)
val secondaryLight = Color(0xFF775653)
val onSecondaryLight = Color(0xFFFFFFFF)
val secondaryContainerLight = Color(0xFFFFDAD6)
val onSecondaryContainerLight = Color(0xFF2C1513)
val tertiaryLight = Color(0xFF725B2E)
val onTertiaryLight = Color(0xFFFFFFFF)
val tertiaryContainerLight = Color(0xFFFEDEA6)
val onTertiaryContainerLight = Color(0xFF261900)
val errorLight = Color(0xFFBA1A1A)
val onErrorLight = Color(0xFFFFFFFF)
val errorContainerLight = Color(0xFFFFDAD6)
val onErrorContainerLight = Color(0xFF410002)
val backgroundLight = Color(0xFFFFF8F7)
val onBackgroundLight = Color(0xFF231918)
val surfaceLight = Color(0xFFFFF8F7)
val onSurfaceLight = Color(0xFF231918)
val surfaceVariantLight = Color(0xFFF5DDDB)
val onSurfaceVariantLight = Color(0xFF534342)
val outlineLight = Color(0xFF857371)
val outlineVariantLight = Color(0xFFD8C2BF)
val scrimLight = Color(0xFF000000)
val inverseSurfaceLight = Color(0xFF392E2D)
val inverseOnSurfaceLight = Color(0xFFFFEDEB)
val inversePrimaryLight = Color(0xFFFFB3AC)
val surfaceDimLight = Color(0xFFE8D6D4)
val surfaceBrightLight = Color(0xFFFFF8F7)
val surfaceContainerLowestLight = Color(0xFFFFFFFF)
val surfaceContainerLowLight = Color(0xFFFFF0EF)
val surfaceContainerLight = Color(0xFFFCEAE8)
val surfaceContainerHighLight = Color(0xFFF6E4E2)
val surfaceContainerHighestLight = Color(0xFFF1DEDC)
val md_theme_light_primary = Color(0xFFBB171C)
val md_theme_light_onPrimary = Color(0xFFFFFFFF)
val md_theme_light_primaryContainer = Color(0xFFFFDAD6)
val md_theme_light_onPrimaryContainer = Color(0xFF410002)
val md_theme_light_secondary = Color(0xFF984061)
val md_theme_light_onSecondary = Color(0xFFFFFFFF)
val md_theme_light_secondaryContainer = Color(0xFFFFD9E2)
val md_theme_light_onSecondaryContainer = Color(0xFF3E001D)
val md_theme_light_tertiary = Color(0xFF006874)
val md_theme_light_onTertiary = Color(0xFFFFFFFF)
val md_theme_light_tertiaryContainer = Color(0xFF97F0FF)
val md_theme_light_onTertiaryContainer = Color(0xFF001F24)
val md_theme_light_error = Color(0xFFBA1A1A)
val md_theme_light_errorContainer = Color(0xFFFFDAD6)
val md_theme_light_onError = Color(0xFFFFFFFF)
val md_theme_light_onErrorContainer = Color(0xFF410002)
val md_theme_light_background = Color(0xFFEEEEEE)
val md_theme_light_onBackground = Color(0xFF1B1B1B)
val md_theme_light_surface = Color(0xFFE53835)
val md_theme_light_onSurface = Color(0xFFFFFFFF)
val md_theme_light_surfaceVariant = Color(0xFFF5DDDB)
val md_theme_light_onSurfaceVariant = Color(0xFF534341)
val md_theme_light_outline = Color(0xFF857371)
val md_theme_light_inverseOnSurface = Color(0xFFD6F6FF)
val md_theme_light_inverseSurface = Color(0xFF00363F)
val md_theme_light_inversePrimary = Color(0xFFFFB4AC)
val md_theme_light_surfaceTint = Color(0xFFBB171C)
val md_theme_light_outlineVariant = Color(0xFFD8C2BF)
val md_theme_light_scrim = Color(0xFF000000)
val primaryDark = Color(0xFFFFB3AC)
val onPrimaryDark = Color(0xFF571E1B)
val primaryContainerDark = Color(0xFF73332F)
val onPrimaryContainerDark = Color(0xFFFFDAD6)
val secondaryDark = Color(0xFFE7BDB8)
val onSecondaryDark = Color(0xFF442927)
val secondaryContainerDark = Color(0xFF5D3F3C)
val onSecondaryContainerDark = Color(0xFFFFDAD6)
val tertiaryDark = Color(0xFFE1C38C)
val onTertiaryDark = Color(0xFF402D04)
val tertiaryContainerDark = Color(0xFF584419)
val onTertiaryContainerDark = Color(0xFFFEDEA6)
val errorDark = Color(0xFFFFB4AB)
val onErrorDark = Color(0xFF690005)
val errorContainerDark = Color(0xFF93000A)
val onErrorContainerDark = Color(0xFFFFDAD6)
val backgroundDark = Color(0xFF1A1110)
val onBackgroundDark = Color(0xFFF1DEDC)
val surfaceDark = Color(0xFF1A1110)
val onSurfaceDark = Color(0xFFF1DEDC)
val surfaceVariantDark = Color(0xFF534342)
val onSurfaceVariantDark = Color(0xFFD8C2BF)
val outlineDark = Color(0xFFA08C8A)
val outlineVariantDark = Color(0xFF534342)
val scrimDark = Color(0xFF000000)
val inverseSurfaceDark = Color(0xFFF1DEDC)
val inverseOnSurfaceDark = Color(0xFF392E2D)
val inversePrimaryDark = Color(0xFF904A45)
val surfaceDimDark = Color(0xFF1A1110)
val surfaceBrightDark = Color(0xFF423735)
val surfaceContainerLowestDark = Color(0xFF140C0B)
val surfaceContainerLowDark = Color(0xFF231918)
val surfaceContainerDark = Color(0xFF271D1C)
val surfaceContainerHighDark = Color(0xFF322827)
val surfaceContainerHighestDark = Color(0xFF3D3231)
val md_theme_dark_primary = Color(0xFFFFB4AC)
val md_theme_dark_onPrimary = Color(0xFF690006)
val md_theme_dark_primaryContainer = Color(0xFF93000D)
val md_theme_dark_onPrimaryContainer = Color(0xFFFFDAD6)
val md_theme_dark_secondary = Color(0xFFFFB1C8)
val md_theme_dark_onSecondary = Color(0xFF5E1133)
val md_theme_dark_secondaryContainer = Color(0xFF7B2949)
val md_theme_dark_onSecondaryContainer = Color(0xFFFFD9E2)
val md_theme_dark_tertiary = Color(0xFF4FD8EB)
val md_theme_dark_onTertiary = Color(0xFF00363D)
val md_theme_dark_tertiaryContainer = Color(0xFF004F58)
val md_theme_dark_onTertiaryContainer = Color(0xFF97F0FF)
val md_theme_dark_error = Color(0xFFFFB4AB)
val md_theme_dark_errorContainer = Color(0xFF93000A)
val md_theme_dark_onError = Color(0xFF690005)
val md_theme_dark_onErrorContainer = Color(0xFFFFDAD6)
val md_theme_dark_background = Color(0xFF212121)
val md_theme_dark_onBackground = Color(0xFFFFFFFF)
val md_theme_dark_surface = Color(0xFF992521)
val md_theme_dark_onSurface = Color(0xFFFFFFFF)
val md_theme_dark_surfaceVariant = Color(0xFF534341)
val md_theme_dark_onSurfaceVariant = Color(0xFFD8C2BF)
val md_theme_dark_outline = Color(0xFFA08C8A)
val md_theme_dark_inverseOnSurface = Color(0xFF001F25)
val md_theme_dark_inverseSurface = Color(0xFFA6EEFF)
val md_theme_dark_inversePrimary = Color(0xFFBB171C)
val md_theme_dark_surfaceTint = Color(0xFFFFB4AC)
val md_theme_dark_outlineVariant = Color(0xFF534341)
val md_theme_dark_scrim = Color(0xFF000000)

View File

@@ -5,102 +5,75 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.preference.PreferenceManager
private val lightScheme = lightColorScheme(
primary = primaryLight,
onPrimary = onPrimaryLight,
primaryContainer = primaryContainerLight,
onPrimaryContainer = onPrimaryContainerLight,
secondary = secondaryLight,
onSecondary = onSecondaryLight,
secondaryContainer = secondaryContainerLight,
onSecondaryContainer = onSecondaryContainerLight,
tertiary = tertiaryLight,
onTertiary = onTertiaryLight,
tertiaryContainer = tertiaryContainerLight,
onTertiaryContainer = onTertiaryContainerLight,
error = errorLight,
onError = onErrorLight,
errorContainer = errorContainerLight,
onErrorContainer = onErrorContainerLight,
background = backgroundLight,
onBackground = onBackgroundLight,
surface = surfaceLight,
onSurface = onSurfaceLight,
surfaceVariant = surfaceVariantLight,
onSurfaceVariant = onSurfaceVariantLight,
outline = outlineLight,
outlineVariant = outlineVariantLight,
scrim = scrimLight,
inverseSurface = inverseSurfaceLight,
inverseOnSurface = inverseOnSurfaceLight,
inversePrimary = inversePrimaryLight,
surfaceDim = surfaceDimLight,
surfaceBright = surfaceBrightLight,
surfaceContainerLowest = surfaceContainerLowestLight,
surfaceContainerLow = surfaceContainerLowLight,
surfaceContainer = surfaceContainerLight,
surfaceContainerHigh = surfaceContainerHighLight,
surfaceContainerHighest = surfaceContainerHighestLight,
private val LightColors = lightColorScheme(
primary = md_theme_light_primary,
onPrimary = md_theme_light_onPrimary,
primaryContainer = md_theme_light_primaryContainer,
onPrimaryContainer = md_theme_light_onPrimaryContainer,
secondary = md_theme_light_secondary,
onSecondary = md_theme_light_onSecondary,
secondaryContainer = md_theme_light_secondaryContainer,
onSecondaryContainer = md_theme_light_onSecondaryContainer,
tertiary = md_theme_light_tertiary,
onTertiary = md_theme_light_onTertiary,
tertiaryContainer = md_theme_light_tertiaryContainer,
onTertiaryContainer = md_theme_light_onTertiaryContainer,
error = md_theme_light_error,
errorContainer = md_theme_light_errorContainer,
onError = md_theme_light_onError,
onErrorContainer = md_theme_light_onErrorContainer,
background = md_theme_light_background,
onBackground = md_theme_light_onBackground,
surface = md_theme_light_surface,
onSurface = md_theme_light_onSurface,
surfaceVariant = md_theme_light_surfaceVariant,
onSurfaceVariant = md_theme_light_onSurfaceVariant,
outline = md_theme_light_outline,
inverseOnSurface = md_theme_light_inverseOnSurface,
inverseSurface = md_theme_light_inverseSurface,
inversePrimary = md_theme_light_inversePrimary,
surfaceTint = md_theme_light_surfaceTint,
outlineVariant = md_theme_light_outlineVariant,
scrim = md_theme_light_scrim,
)
private val darkScheme = darkColorScheme(
primary = primaryDark,
onPrimary = onPrimaryDark,
primaryContainer = primaryContainerDark,
onPrimaryContainer = onPrimaryContainerDark,
secondary = secondaryDark,
onSecondary = onSecondaryDark,
secondaryContainer = secondaryContainerDark,
onSecondaryContainer = onSecondaryContainerDark,
tertiary = tertiaryDark,
onTertiary = onTertiaryDark,
tertiaryContainer = tertiaryContainerDark,
onTertiaryContainer = onTertiaryContainerDark,
error = errorDark,
onError = onErrorDark,
errorContainer = errorContainerDark,
onErrorContainer = onErrorContainerDark,
background = backgroundDark,
onBackground = onBackgroundDark,
surface = surfaceDark,
onSurface = onSurfaceDark,
surfaceVariant = surfaceVariantDark,
onSurfaceVariant = onSurfaceVariantDark,
outline = outlineDark,
outlineVariant = outlineVariantDark,
scrim = scrimDark,
inverseSurface = inverseSurfaceDark,
inverseOnSurface = inverseOnSurfaceDark,
inversePrimary = inversePrimaryDark,
surfaceDim = surfaceDimDark,
surfaceBright = surfaceBrightDark,
surfaceContainerLowest = surfaceContainerLowestDark,
surfaceContainerLow = surfaceContainerLowDark,
surfaceContainer = surfaceContainerDark,
surfaceContainerHigh = surfaceContainerHighDark,
surfaceContainerHighest = surfaceContainerHighestDark,
private val DarkColors = darkColorScheme(
primary = md_theme_dark_primary,
onPrimary = md_theme_dark_onPrimary,
primaryContainer = md_theme_dark_primaryContainer,
onPrimaryContainer = md_theme_dark_onPrimaryContainer,
secondary = md_theme_dark_secondary,
onSecondary = md_theme_dark_onSecondary,
secondaryContainer = md_theme_dark_secondaryContainer,
onSecondaryContainer = md_theme_dark_onSecondaryContainer,
tertiary = md_theme_dark_tertiary,
onTertiary = md_theme_dark_onTertiary,
tertiaryContainer = md_theme_dark_tertiaryContainer,
onTertiaryContainer = md_theme_dark_onTertiaryContainer,
error = md_theme_dark_error,
errorContainer = md_theme_dark_errorContainer,
onError = md_theme_dark_onError,
onErrorContainer = md_theme_dark_onErrorContainer,
background = md_theme_dark_background,
onBackground = md_theme_dark_onBackground,
surface = md_theme_dark_surface,
onSurface = md_theme_dark_onSurface,
surfaceVariant = md_theme_dark_surfaceVariant,
onSurfaceVariant = md_theme_dark_onSurfaceVariant,
outline = md_theme_dark_outline,
inverseOnSurface = md_theme_dark_inverseOnSurface,
inverseSurface = md_theme_dark_inverseSurface,
inversePrimary = md_theme_dark_inversePrimary,
surfaceTint = md_theme_dark_surfaceTint,
outlineVariant = md_theme_dark_outlineVariant,
scrim = md_theme_dark_scrim,
)
private val blackScheme = darkScheme.copy(surface = Color.Black)
@Composable
fun AppTheme(useDarkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(LocalContext.current)
val theme = sharedPreferences.getString("theme", "auto_device_theme")
val nightTheme = sharedPreferences.getString("night_theme", "dark_theme")
MaterialTheme(
colorScheme = if (!useDarkTheme) {
lightScheme
} else if (theme == "black_theme" || nightTheme == "black_theme") {
blackScheme
} else {
darkScheme
},
colorScheme = if (useDarkTheme) DarkColors else LightColors,
content = 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

@@ -1,47 +0,0 @@
package org.schabi.newpipe.util
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/** Guard the given data so that it can only be accessed by locking the mutex first.
*
* Inspired by [this blog post](https://jonnyzzz.com/blog/2017/03/01/guarded-by-lock/)
* */
class GuardedByMutex<T>(
private var data: T,
private val lock: Mutex = Mutex(locked = false),
) {
/** Lock the mutex and access the data, blocking the current thread.
* @param action to run with locked mutex
* */
fun <Y> runWithLockSync(
action: MutexData<T>.() -> Y
) =
runBlocking {
lock.withLock {
MutexData(data, { d -> data = d }).action()
}
}
/** Lock the mutex and access the data, suspending the coroutine.
* @param action to run with locked mutex
* */
suspend fun <Y> runWithLock(action: MutexData<T>.() -> Y) =
lock.withLock {
MutexData(data, { d -> data = d }).action()
}
}
/** The data inside a [GuardedByMutex], which can be accessed via [lockData].
* [lockData] is a `var`, so you can `set` it as well.
* */
class MutexData<T>(data: T, val setFun: (T) -> Unit) {
/** The data inside this [GuardedByMutex] */
var lockData: T = data
set(t) {
setFun(t)
field = t
}
}

View File

@@ -0,0 +1,36 @@
package org.schabi.newpipe.util
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
class JavaFlow<T>(
private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default)
) {
interface OperatorCallback <T> {
fun onStart() = Unit
fun onCompletion(thr: Throwable?) = Unit
fun onResult(result: T)
}
fun collect(
flow: Flow<T>,
operatorCallback: OperatorCallback<T>,
) {
coroutineScope.launch {
flow
.onStart { operatorCallback.onStart() }
.onCompletion { operatorCallback.onCompletion(it) }
.collect { operatorCallback.onResult(it) }
}
}
fun close() {
coroutineScope.cancel()
}
}

View File

@@ -48,12 +48,10 @@ public final class ListHelper {
private static final Set<String> HIGH_RESOLUTION_LIST = Set.of("1440p", "2160p");
// Audio track types in order of priority. 0=lowest, n=highest
private static final List<AudioTrackType> AUDIO_TRACK_TYPE_RANKING =
List.of(AudioTrackType.DESCRIPTIVE, AudioTrackType.SECONDARY, AudioTrackType.DUBBED,
AudioTrackType.ORIGINAL);
List.of(AudioTrackType.DESCRIPTIVE, AudioTrackType.DUBBED, AudioTrackType.ORIGINAL);
// Audio track types in order of priority when descriptive audio is preferred.
private static final List<AudioTrackType> AUDIO_TRACK_TYPE_RANKING_DESCRIPTIVE =
List.of(AudioTrackType.SECONDARY, AudioTrackType.DUBBED, AudioTrackType.ORIGINAL,
AudioTrackType.DESCRIPTIVE);
List.of(AudioTrackType.ORIGINAL, AudioTrackType.DUBBED, AudioTrackType.DESCRIPTIVE);
/**
* List of supported YouTube Itag ids.

View File

@@ -10,18 +10,13 @@ import android.content.res.Resources;
import android.icu.text.CompactDecimalFormat;
import android.os.Build;
import android.text.TextUtils;
import android.text.format.DateUtils;
import android.text.BidiFormatter;
import android.util.DisplayMetrics;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.PluralsRes;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AppCompatDelegate;
import androidx.core.math.MathUtils;
import androidx.core.os.LocaleListCompat;
import androidx.preference.PreferenceManager;
import org.ocpsoft.prettytime.PrettyTime;
@@ -43,7 +38,6 @@ import java.time.format.FormatStyle;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.stream.Collectors;
@@ -68,7 +62,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;
@@ -86,25 +79,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}.
*
* @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) {
final BidiFormatter bidi = BidiFormatter.getInstance();
if (plainName.startsWith("@")) {
return "@" + bidi.unicodeWrap(plainName.substring(1));
} else {
return bidi.unicodeWrap(plainName);
}
}
public static org.schabi.newpipe.extractor.localization.Localization getPreferredLocalization(
final Context context) {
return org.schabi.newpipe.extractor.localization.Localization
@@ -126,10 +100,6 @@ public final class Localization {
}
public static Locale getAppLocale(@NonNull final Context context) {
if (Build.VERSION.SDK_INT >= 33) {
final Locale customLocale = AppCompatDelegate.getApplicationLocales().get(0);
return Objects.requireNonNullElseGet(customLocale, Locale::getDefault);
}
return getLocaleFromPrefs(context, R.string.app_language_key);
}
@@ -264,27 +234,43 @@ public final class Localization {
}
/**
* Get a readable text for a duration in the format {@code hours:minutes:seconds}.
*
* Get a readable text for a duration in the format {@code days:hours:minutes:seconds}.
* Prepended zeros are removed.
* @param duration the duration in seconds
* @return a formatted duration String or {@code 00:00} if the duration is zero.
* @return a formatted duration String or {@code 0:00} if the duration is zero.
*/
public static String getDurationString(final long duration) {
return DateUtils.formatElapsedTime(Math.max(duration, 0));
return getDurationString(duration, true, false);
}
/**
* Get a readable text for a duration in the format {@code hours:minutes:seconds+}. If the given
* duration is incomplete, a plus is appended to the duration string.
*
* Get a readable text for a duration in the format {@code days:hours:minutes:seconds+}.
* Prepended zeros are removed. If the given duration is incomplete, a plus is appended to the
* duration string.
* @param duration the duration in seconds
* @param isDurationComplete whether the given duration is complete or whether info is missing
* @param showDurationPrefix whether the duration-prefix shall be shown
* @return a formatted duration String or {@code 00:00} if the duration is zero.
* @return a formatted duration String or {@code 0:00} if the duration is zero.
*/
public static String getDurationString(final long duration, final boolean isDurationComplete,
final boolean showDurationPrefix) {
final String output = getDurationString(duration);
final String output;
final long days = duration / (24 * 60 * 60L); /* greater than a day */
final long hours = duration % (24 * 60 * 60L) / (60 * 60L); /* greater than an hour */
final long minutes = duration % (24 * 60 * 60L) % (60 * 60L) / 60L;
final long seconds = duration % 60L;
if (duration < 0) {
output = "0:00";
} else if (days > 0) {
//handle days
output = String.format(Locale.US, "%d:%02d:%02d:%02d", days, hours, minutes, seconds);
} else if (hours > 0) {
output = String.format(Locale.US, "%d:%02d:%02d", hours, minutes, seconds);
} else {
output = String.format(Locale.US, "%d:%02d", minutes, seconds);
}
final String durationPrefix = showDurationPrefix ? "" : "";
final String durationPostfix = isDurationComplete ? "" : "+";
return durationPrefix + output + durationPostfix;
@@ -332,7 +318,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
@@ -342,7 +328,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 {
@@ -351,20 +337,25 @@ public final class Localization {
if (track.getAudioTrackType() != null) {
final String trackType = audioTrackType(context, track.getAudioTrackType());
return context.getString(R.string.audio_track_name, name, trackType);
if (trackType != null) {
return context.getString(R.string.audio_track_name, name, trackType);
}
}
return name;
}
@NonNull
@Nullable
private static String audioTrackType(@NonNull final Context context,
@NonNull final AudioTrackType trackType) {
return switch (trackType) {
case ORIGINAL -> context.getString(R.string.audio_track_type_original);
case DUBBED -> context.getString(R.string.audio_track_type_dubbed);
case DESCRIPTIVE -> context.getString(R.string.audio_track_type_descriptive);
case SECONDARY -> context.getString(R.string.audio_track_type_secondary);
};
final AudioTrackType trackType) {
switch (trackType) {
case ORIGINAL:
return context.getString(R.string.audio_track_type_original);
case DUBBED:
return context.getString(R.string.audio_track_type_dubbed);
case DESCRIPTIVE:
return context.getString(R.string.audio_track_type_descriptive);
}
return null;
}
/*//////////////////////////////////////////////////////////////////////////
@@ -451,32 +442,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);
}
public static void migrateAppLanguageSettingIfNecessary(@NonNull final Context context) {
// Starting with pull request #12093, NewPipe on Android 13+ exclusively uses Android's
// public per-app language APIs to read and set the UI language for NewPipe.
// If running on Android 13+, the following code will migrate any existing custom
// app language in SharedPreferences to use the public per-app language APIs instead.
if (Build.VERSION.SDK_INT >= 33) {
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) {
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

@@ -98,7 +98,6 @@ public final class NavigationHelper {
}
intent.putExtra(Player.PLAYER_TYPE, PlayerType.MAIN.valueForIntent());
intent.putExtra(Player.RESUME_PLAYBACK, resumePlayback);
intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true);
return intent;
}
@@ -455,12 +454,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

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

View File

@@ -1,35 +0,0 @@
package org.schabi.newpipe.util.potoken
import android.content.Context
import io.reactivex.rxjava3.core.Single
import java.io.Closeable
/**
* This interface was created to allow for multiple methods to generate poTokens in the future (e.g.
* via WebView and via a local DOM implementation)
*/
interface PoTokenGenerator : Closeable {
/**
* Generates a poToken for the provided identifier, using the `integrityToken` and
* `webPoSignalOutput` previously obtained in the initialization of [PoTokenWebView]. Can be
* called multiple times.
*/
fun generatePoToken(identifier: String): Single<String>
/**
* @return whether the `integrityToken` is expired, in which case all tokens generated by
* [generatePoToken] will be invalid
*/
fun isExpired(): Boolean
interface Factory {
/**
* Initializes a [PoTokenGenerator] by loading the BotGuard VM, running it, and obtaining
* an `integrityToken`. Can then be used multiple times to generate multiple poTokens with
* [generatePoToken].
*
* @param context used e.g. to load the HTML asset or to instantiate a WebView
*/
fun newPoTokenGenerator(context: Context): Single<PoTokenGenerator>
}
}

View File

@@ -1,131 +0,0 @@
package org.schabi.newpipe.util.potoken
import android.os.Handler
import android.os.Looper
import android.util.Log
import org.schabi.newpipe.App
import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.services.youtube.InnertubeClientRequestInfo
import org.schabi.newpipe.extractor.services.youtube.PoTokenProvider
import org.schabi.newpipe.extractor.services.youtube.PoTokenResult
import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper
import org.schabi.newpipe.util.DeviceUtils
object PoTokenProviderImpl : PoTokenProvider {
val TAG = PoTokenProviderImpl::class.simpleName
private val webViewSupported by lazy { DeviceUtils.supportsWebView() }
private var webViewBadImpl = false // whether the system has a bad WebView implementation
private object WebPoTokenGenLock
private var webPoTokenVisitorData: String? = null
private var webPoTokenStreamingPot: String? = null
private var webPoTokenGenerator: PoTokenGenerator? = null
override fun getWebClientPoToken(videoId: String): PoTokenResult? {
if (!webViewSupported || webViewBadImpl) {
return null
}
try {
return getWebClientPoToken(videoId = videoId, forceRecreate = false)
} catch (e: RuntimeException) {
// RxJava's Single wraps exceptions into RuntimeErrors, so we need to unwrap them here
when (val cause = e.cause) {
is BadWebViewException -> {
Log.e(TAG, "Could not obtain poToken because WebView is broken", e)
webViewBadImpl = true
return null
}
null -> throw e
else -> throw cause // includes PoTokenException
}
}
}
/**
* @param forceRecreate whether to force the recreation of [webPoTokenGenerator], to be used in
* case the current [webPoTokenGenerator] threw an error last time
* [PoTokenGenerator.generatePoToken] was called
*/
private fun getWebClientPoToken(videoId: String, forceRecreate: Boolean): PoTokenResult {
// just a helper class since Kotlin does not have builtin support for 4-tuples
data class Quadruple<T1, T2, T3, T4>(val t1: T1, val t2: T2, val t3: T3, val t4: T4)
val (poTokenGenerator, visitorData, streamingPot, hasBeenRecreated) =
synchronized(WebPoTokenGenLock) {
val shouldRecreate = webPoTokenGenerator == null || forceRecreate ||
webPoTokenGenerator!!.isExpired()
if (shouldRecreate) {
val innertubeClientRequestInfo = InnertubeClientRequestInfo.ofWebClient()
innertubeClientRequestInfo.clientInfo.clientVersion =
YoutubeParsingHelper.getClientVersion()
webPoTokenVisitorData = YoutubeParsingHelper.getVisitorDataFromInnertube(
innertubeClientRequestInfo,
NewPipe.getPreferredLocalization(),
NewPipe.getPreferredContentCountry(),
YoutubeParsingHelper.getYouTubeHeaders(),
YoutubeParsingHelper.YOUTUBEI_V1_URL,
null,
false
)
// close the current webPoTokenGenerator on the main thread
webPoTokenGenerator?.let { Handler(Looper.getMainLooper()).post { it.close() } }
// create a new webPoTokenGenerator
webPoTokenGenerator = PoTokenWebView
.newPoTokenGenerator(App.instance).blockingGet()
// The streaming poToken needs to be generated exactly once before generating
// any other (player) tokens.
webPoTokenStreamingPot = webPoTokenGenerator!!
.generatePoToken(webPoTokenVisitorData!!).blockingGet()
}
return@synchronized Quadruple(
webPoTokenGenerator!!,
webPoTokenVisitorData!!,
webPoTokenStreamingPot!!,
shouldRecreate
)
}
val playerPot = try {
// Not using synchronized here, since poTokenGenerator would be able to generate
// multiple poTokens in parallel if needed. The only important thing is for exactly one
// visitorData/streaming poToken to be generated before anything else.
poTokenGenerator.generatePoToken(videoId).blockingGet()
} catch (throwable: Throwable) {
if (hasBeenRecreated) {
// the poTokenGenerator has just been recreated (and possibly this is already the
// second time we try), so there is likely nothing we can do
throw throwable
} else {
// retry, this time recreating the [webPoTokenGenerator] from scratch;
// this might happen for example if NewPipe goes in the background and the WebView
// content is lost
Log.e(TAG, "Failed to obtain poToken, retrying", throwable)
return getWebClientPoToken(videoId = videoId, forceRecreate = true)
}
}
if (BuildConfig.DEBUG) {
Log.d(
TAG,
"poToken for $videoId: playerPot=$playerPot, " +
"streamingPot=$streamingPot, visitor_data=$visitorData"
)
}
return PoTokenResult(visitorData, playerPot, streamingPot)
}
override fun getWebEmbedClientPoToken(videoId: String): PoTokenResult? = null
override fun getAndroidClientPoToken(videoId: String): PoTokenResult? = null
override fun getIosClientPoToken(videoId: String): PoTokenResult? = null
}

View File

@@ -1,395 +0,0 @@
package org.schabi.newpipe.util.potoken
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.webkit.ConsoleMessage
import android.webkit.JavascriptInterface
import android.webkit.WebChromeClient
import android.webkit.WebView
import androidx.annotation.MainThread
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.core.SingleEmitter
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.DownloaderImpl
import java.time.Instant
class PoTokenWebView private constructor(
context: Context,
// to be used exactly once only during initialization!
private val generatorEmitter: SingleEmitter<PoTokenGenerator>,
) : PoTokenGenerator {
private val webView = WebView(context)
private val disposables = CompositeDisposable() // used only during initialization
private val poTokenEmitters = mutableListOf<Pair<String, SingleEmitter<String>>>()
private lateinit var expirationInstant: Instant
//region Initialization
init {
val webViewSettings = webView.settings
//noinspection SetJavaScriptEnabled we want to use JavaScript!
webViewSettings.javaScriptEnabled = true
if (WebViewFeature.isFeatureSupported(WebViewFeature.SAFE_BROWSING_ENABLE)) {
WebSettingsCompat.setSafeBrowsingEnabled(webViewSettings, false)
}
webViewSettings.userAgentString = USER_AGENT
webViewSettings.blockNetworkLoads = true // the WebView does not need internet access
// so that we can run async functions and get back the result
webView.addJavascriptInterface(this, JS_INTERFACE)
webView.webChromeClient = object : WebChromeClient() {
override fun onConsoleMessage(m: ConsoleMessage): Boolean {
if (m.message().contains("Uncaught")) {
// There should not be any uncaught errors while executing the code, because
// everything that can fail is guarded by try-catch. Therefore, this likely
// indicates that there was a syntax error in the code, i.e. the WebView only
// supports a really old version of JS.
val fmt = "\"${m.message()}\", source: ${m.sourceId()} (${m.lineNumber()})"
val exception = BadWebViewException(fmt)
Log.e(TAG, "This WebView implementation is broken: $fmt")
onInitializationErrorCloseAndCancel(exception)
popAllPoTokenEmitters().forEach { (_, emitter) -> emitter.onError(exception) }
}
return super.onConsoleMessage(m)
}
}
}
/**
* Must be called right after instantiating [PoTokenWebView] to perform the actual
* initialization. This will asynchronously go through all the steps needed to load BotGuard,
* run it, and obtain an `integrityToken`.
*/
private fun loadHtmlAndObtainBotguard(context: Context) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "loadHtmlAndObtainBotguard() called")
}
disposables.add(
Single.fromCallable {
val html = context.assets.open("po_token.html").bufferedReader()
.use { it.readText() }
return@fromCallable html
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ html ->
webView.loadDataWithBaseURL(
"https://www.youtube.com",
html.replaceFirst(
"</script>",
// calls downloadAndRunBotguard() when the page has finished loading
"\n$JS_INTERFACE.downloadAndRunBotguard()</script>"
),
"text/html",
"utf-8",
null,
)
},
this::onInitializationErrorCloseAndCancel
)
)
}
/**
* Called during initialization by the JavaScript snippet appended to the HTML page content in
* [loadHtmlAndObtainBotguard] after the WebView content has been loaded.
*/
@JavascriptInterface
fun downloadAndRunBotguard() {
if (BuildConfig.DEBUG) {
Log.d(TAG, "downloadAndRunBotguard() called")
}
makeBotguardServiceRequest(
"https://www.youtube.com/api/jnn/v1/Create",
"[ \"$REQUEST_KEY\" ]",
) { responseBody ->
val parsedChallengeData = parseChallengeData(responseBody)
webView.evaluateJavascript(
"""try {
data = $parsedChallengeData
runBotGuard(data).then(function (result) {
this.webPoSignalOutput = result.webPoSignalOutput
$JS_INTERFACE.onRunBotguardResult(result.botguardResponse)
}, function (error) {
$JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack)
})
} catch (error) {
$JS_INTERFACE.onJsInitializationError(error + "\n" + error.stack)
}""",
null
)
}
}
/**
* Called during initialization by the JavaScript snippets from either
* [downloadAndRunBotguard] or [onRunBotguardResult].
*/
@JavascriptInterface
fun onJsInitializationError(error: String) {
if (BuildConfig.DEBUG) {
Log.e(TAG, "Initialization error from JavaScript: $error")
}
onInitializationErrorCloseAndCancel(buildExceptionForJsError(error))
}
/**
* Called during initialization by the JavaScript snippet from [downloadAndRunBotguard] after
* obtaining the BotGuard execution output [botguardResponse].
*/
@JavascriptInterface
fun onRunBotguardResult(botguardResponse: String) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "botguardResponse: $botguardResponse")
}
makeBotguardServiceRequest(
"https://www.youtube.com/api/jnn/v1/GenerateIT",
"[ \"$REQUEST_KEY\", \"$botguardResponse\" ]",
) { responseBody ->
if (BuildConfig.DEBUG) {
Log.d(TAG, "GenerateIT response: $responseBody")
}
val (integrityToken, expirationTimeInSeconds) = parseIntegrityTokenData(responseBody)
// leave 10 minutes of margin just to be sure
expirationInstant = Instant.now().plusSeconds(expirationTimeInSeconds - 600)
webView.evaluateJavascript(
"this.integrityToken = $integrityToken"
) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "initialization finished, expiration=${expirationTimeInSeconds}s")
}
generatorEmitter.onSuccess(this)
}
}
}
//endregion
//region Obtaining poTokens
override fun generatePoToken(identifier: String): Single<String> =
Single.create { emitter ->
if (BuildConfig.DEBUG) {
Log.d(TAG, "generatePoToken() called with identifier $identifier")
}
runOnMainThread(emitter) {
addPoTokenEmitter(identifier, emitter)
val u8Identifier = stringToU8(identifier)
webView.evaluateJavascript(
"""try {
identifier = "$identifier"
u8Identifier = $u8Identifier
poTokenU8 = obtainPoToken(webPoSignalOutput, integrityToken, u8Identifier)
poTokenU8String = ""
for (i = 0; i < poTokenU8.length; i++) {
if (i != 0) poTokenU8String += ","
poTokenU8String += poTokenU8[i]
}
$JS_INTERFACE.onObtainPoTokenResult(identifier, poTokenU8String)
} catch (error) {
$JS_INTERFACE.onObtainPoTokenError(identifier, error + "\n" + error.stack)
}""",
) {}
}
}
/**
* Called by the JavaScript snippet from [generatePoToken] when an error occurs in calling the
* JavaScript `obtainPoToken()` function.
*/
@JavascriptInterface
fun onObtainPoTokenError(identifier: String, error: String) {
if (BuildConfig.DEBUG) {
Log.e(TAG, "obtainPoToken error from JavaScript: $error")
}
popPoTokenEmitter(identifier)?.onError(buildExceptionForJsError(error))
}
/**
* Called by the JavaScript snippet from [generatePoToken] with the original identifier and the
* result of the JavaScript `obtainPoToken()` function.
*/
@JavascriptInterface
fun onObtainPoTokenResult(identifier: String, poTokenU8: String) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "Generated poToken (before decoding): identifier=$identifier poTokenU8=$poTokenU8")
}
val poToken = try {
u8ToBase64(poTokenU8)
} catch (t: Throwable) {
popPoTokenEmitter(identifier)?.onError(t)
return
}
if (BuildConfig.DEBUG) {
Log.d(TAG, "Generated poToken: identifier=$identifier poToken=$poToken")
}
popPoTokenEmitter(identifier)?.onSuccess(poToken)
}
override fun isExpired(): Boolean {
return Instant.now().isAfter(expirationInstant)
}
//endregion
//region Handling multiple emitters
/**
* Adds the ([identifier], [emitter]) pair to the [poTokenEmitters] list. This makes it so that
* multiple poToken requests can be generated invparallel, and the results will be notified to
* the right emitters.
*/
private fun addPoTokenEmitter(identifier: String, emitter: SingleEmitter<String>) {
synchronized(poTokenEmitters) {
poTokenEmitters.add(Pair(identifier, emitter))
}
}
/**
* Extracts and removes from the [poTokenEmitters] list a [SingleEmitter] based on its
* [identifier]. The emitter is supposed to be used immediately after to either signal a success
* or an error.
*/
private fun popPoTokenEmitter(identifier: String): SingleEmitter<String>? {
return synchronized(poTokenEmitters) {
poTokenEmitters.indexOfFirst { it.first == identifier }.takeIf { it >= 0 }?.let {
poTokenEmitters.removeAt(it).second
}
}
}
/**
* Clears [poTokenEmitters] and returns its previous contents. The emitters are supposed to be
* used immediately after to either signal a success or an error.
*/
private fun popAllPoTokenEmitters(): List<Pair<String, SingleEmitter<String>>> {
return synchronized(poTokenEmitters) {
val result = poTokenEmitters.toList()
poTokenEmitters.clear()
result
}
}
//endregion
//region Utils
/**
* Makes a POST request to [url] with the given [data] by setting the correct headers. Calls
* [onInitializationErrorCloseAndCancel] in case of any network errors and also if the response
* does not have HTTP code 200, therefore this is supposed to be used only during
* initialization. Calls [handleResponseBody] with the response body if the response is
* successful. The request is performed in the background and a disposable is added to
* [disposables].
*/
private fun makeBotguardServiceRequest(
url: String,
data: String,
handleResponseBody: (String) -> Unit,
) {
disposables.add(
Single.fromCallable {
return@fromCallable DownloaderImpl.getInstance().post(
url,
mapOf(
// replace the downloader user agent
"User-Agent" to listOf(USER_AGENT),
"Accept" to listOf("application/json"),
"Content-Type" to listOf("application/json+protobuf"),
"x-goog-api-key" to listOf(GOOGLE_API_KEY),
"x-user-agent" to listOf("grpc-web-javascript/0.1"),
),
data.toByteArray()
)
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{ response ->
val httpCode = response.responseCode()
if (httpCode != 200) {
onInitializationErrorCloseAndCancel(
PoTokenException("Invalid response code: $httpCode")
)
return@subscribe
}
val responseBody = response.responseBody()
handleResponseBody(responseBody)
},
this::onInitializationErrorCloseAndCancel
)
)
}
/**
* Handles any error happening during initialization, releasing resources and sending the error
* to [generatorEmitter].
*/
private fun onInitializationErrorCloseAndCancel(error: Throwable) {
runOnMainThread(generatorEmitter) {
close()
generatorEmitter.onError(error)
}
}
/**
* Releases all [webView] and [disposables] resources.
*/
@MainThread
override fun close() {
disposables.dispose()
webView.clearHistory()
// clears RAM cache and disk cache (globally for all WebViews)
webView.clearCache(true)
// ensures that the WebView isn't doing anything when destroying it
webView.loadUrl("about:blank")
webView.onPause()
webView.removeAllViews()
webView.destroy()
}
//endregion
companion object : PoTokenGenerator.Factory {
private val TAG = PoTokenWebView::class.simpleName
// Public API key used by BotGuard, which has been got by looking at BotGuard requests
private const val GOOGLE_API_KEY = "AIzaSyDyT5W0Jh49F30Pqqtyfdf7pDLFKLJoAnw" // NOSONAR
private const val REQUEST_KEY = "O43z0dpjhgX20SCx4KAo"
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.3"
private const val JS_INTERFACE = "PoTokenWebView"
override fun newPoTokenGenerator(context: Context): Single<PoTokenGenerator> =
Single.create { emitter ->
runOnMainThread(emitter) {
val potWv = PoTokenWebView(context, emitter)
potWv.loadHtmlAndObtainBotguard(context)
emitter.setDisposable(potWv.disposables)
}
}
/**
* Runs [runnable] on the main thread using `Handler(Looper.getMainLooper()).post()`, and
* if the `post` fails emits an error on [emitterIfPostFails].
*/
private fun runOnMainThread(
emitterIfPostFails: SingleEmitter<out Any>,
runnable: Runnable,
) {
if (!Handler(Looper.getMainLooper()).post(runnable)) {
emitterIfPostFails.onError(PoTokenException("Could not run on main thread"))
}
}
}
}

View File

@@ -71,9 +71,6 @@ import java.net.URI;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Iterator;
import java.util.Date;
import java.util.Locale;
import java.text.DateFormat;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
@@ -211,17 +208,11 @@ public class MissionAdapter extends Adapter<ViewHolder> implements Handler.Callb
h.pause.setTitle(mission.unknownLength ? R.string.stop : R.string.pause);
updateProgress(h);
mPendingDownloadsItems.add(h);
h.date.setText("");
} else {
h.progress.setMarquee(false);
h.status.setText("100%");
h.progress.setProgress(1.0f);
h.size.setText(Utility.formatBytes(item.mission.length));
DateFormat dateFormat = DateFormat.getDateInstance(DateFormat.MEDIUM, Locale.getDefault());
Date date = new Date(item.mission.timestamp);
h.date.setText(dateFormat.format(date));
}
}
@@ -673,13 +664,6 @@ public class MissionAdapter extends Adapter<ViewHolder> implements Handler.Callb
return true;
case R.id.md5:
case R.id.sha1:
final StoredFileHelper storage = h.item.mission.storage;
if (!storage.existsAsFile()) {
Toast.makeText(mContext, R.string.missing_file, Toast.LENGTH_SHORT).show();
mDeleter.append(h.item.mission);
applyChanges();
return true;
}
final NotificationManager notificationManager
= ContextCompat.getSystemService(mContext, NotificationManager.class);
final NotificationCompat.Builder progressNotificationBuilder
@@ -694,6 +678,7 @@ public class MissionAdapter extends Adapter<ViewHolder> implements Handler.Callb
notificationManager.notify(HASH_NOTIFICATION_ID, progressNotificationBuilder
.build());
final StoredFileHelper storage = h.item.mission.storage;
compositeDisposable.add(
Observable.fromCallable(() -> Utility.checksum(storage, id))
.subscribeOn(Schedulers.computation())
@@ -841,7 +826,6 @@ public class MissionAdapter extends Adapter<ViewHolder> implements Handler.Callb
ImageView icon;
TextView name;
TextView size;
TextView date;
ProgressDrawable progress;
PopupMenu popupMenu;
@@ -872,7 +856,6 @@ public class MissionAdapter extends Adapter<ViewHolder> implements Handler.Callb
name = itemView.findViewById(R.id.item_name);
icon = itemView.findViewById(R.id.item_icon);
size = itemView.findViewById(R.id.item_size);
date = itemView.findViewById(R.id.item_date);
name.setSelected(true);

View File

@@ -1,9 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:pathData="M640,520 L474,358q-31,-30 -52.5,-66.5T400,212q0,-55 38.5,-93.5T532,80q32,0 60,13.5t48,36.5q20,-23 48,-36.5t60,-13.5q55,0 93.5,38.5T880,212q0,43 -21,79.5T807,358L640,520ZM640,408 L749,301q19,-19 35,-40.5t16,-48.5q0,-22 -15,-37t-37,-15q-14,0 -26.5,5.5T700,182l-60,72 -60,-72q-9,-11 -21.5,-16.5T532,160q-22,0 -37,15t-15,37q0,27 16,48.5t35,40.5l109,107ZM280,740l278,76 238,-74q-5,-9 -14.5,-15.5T760,720L558,720q-27,0 -43,-2t-33,-8l-93,-31 22,-78 81,27q17,5 40,8t68,4q0,-11 -6.5,-21T578,606l-234,-86h-64v220ZM40,880v-440h304q7,0 14,1.5t13,3.5l235,87q33,12 53.5,42t20.5,66h80q50,0 85,33t35,87v40L560,900l-280,-78v58L40,880ZM120,800h80v-280h-80v280ZM640,254Z"
android:fillColor="#FF000000"/>
</vector>

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="@color/white"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M17,3H7c-1.1,0 -1.99,0.9 -1.99,2L5,21l7,-3 7,3V5c0,-1.1 -0.9,-2 -2,-2z" />
</vector>

View File

@@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="@color/white"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M13,3c-4.97,0 -9,4.03 -9,9L1,12l3.89,3.89 0.07,0.14L9,12L6,12c0,-3.87 3.13,-7 7,-7s7,3.13 7,7 -3.13,7 -7,7c-1.93,0 -3.68,-0.79 -4.94,-2.06l-1.42,1.42C8.27,19.99 10.51,21 13,21c4.97,0 9,-4.03 9,-9s-4.03,-9 -9,-9zM12,8v5l4.28,2.54 0.72,-1.21 -3.5,-2.08L13.5,8L12,8z" />
</vector>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<net.newpipe.newplayer.ui.NewPlayerView xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/embedded_player_newplayer"
android:name="net.newpipe.newplayer.VideoPlayerFragment"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="50dp" />

View File

@@ -598,110 +598,110 @@
</androidx.coordinatorlayout.widget.CoordinatorLayout>
<RelativeLayout
android:id="@+id/overlay_layout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:alpha="0.9"
android:background="?attr/windowBackground">
<!-- <RelativeLayout-->
<!-- android:id="@+id/overlay_layout"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="match_parent"-->
<!-- android:alpha="0.9"-->
<!-- android:background="?attr/windowBackground">-->
<ImageButton
android:id="@+id/overlay_thumbnail"
android:layout_width="62dp"
android:layout_height="60dp"
android:layout_alignParentStart="true"
android:background="@color/transparent_background_color"
android:gravity="center_vertical"
android:paddingLeft="@dimen/video_item_search_padding"
android:paddingRight="@dimen/video_item_search_padding"
android:scaleType="fitCenter"
tools:ignore="ContentDescription" />
<!-- <ImageButton-->
<!-- android:id="@+id/overlay_thumbnail"-->
<!-- android:layout_width="62dp"-->
<!-- android:layout_height="60dp"-->
<!-- android:layout_alignParentStart="true"-->
<!-- android:background="@color/transparent_background_color"-->
<!-- android:gravity="center_vertical"-->
<!-- android:paddingLeft="@dimen/video_item_search_padding"-->
<!-- android:paddingRight="@dimen/video_item_search_padding"-->
<!-- android:scaleType="fitCenter"-->
<!-- tools:ignore="ContentDescription" />-->
<LinearLayout
android:id="@+id/overlay_metadata_layout"
android:layout_width="match_parent"
android:layout_height="60dp"
android:layout_toStartOf="@+id/overlay_buttons_layout"
android:layout_toEndOf="@+id/overlay_thumbnail"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:orientation="vertical"
android:theme="@style/ContrastTintTheme"
tools:ignore="RtlHardcoded">
<!-- <LinearLayout-->
<!-- android:id="@+id/overlay_metadata_layout"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="60dp"-->
<!-- android:layout_toStartOf="@+id/overlay_buttons_layout"-->
<!-- android:layout_toEndOf="@+id/overlay_thumbnail"-->
<!-- android:clickable="true"-->
<!-- android:focusable="true"-->
<!-- android:gravity="center_vertical"-->
<!-- android:orientation="vertical"-->
<!-- android:theme="@style/ContrastTintTheme"-->
<!-- tools:ignore="RtlHardcoded">-->
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/overlay_title_text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="marquee"
android:fadingEdge="horizontal"
android:marqueeRepeatLimit="marquee_forever"
android:scrollHorizontally="true"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="@dimen/video_item_search_title_text_size"
tools:ignore="RtlHardcoded"
tools:text="The Video Title LONG very LONVideo Title LONG very LONG" />
<!-- <org.schabi.newpipe.views.NewPipeTextView-->
<!-- android:id="@+id/overlay_title_text_view"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:ellipsize="marquee"-->
<!-- android:fadingEdge="horizontal"-->
<!-- android:marqueeRepeatLimit="marquee_forever"-->
<!-- android:scrollHorizontally="true"-->
<!-- android:singleLine="true"-->
<!-- android:textAppearance="?android:attr/textAppearanceLarge"-->
<!-- android:textSize="@dimen/video_item_search_title_text_size"-->
<!-- tools:ignore="RtlHardcoded"-->
<!-- tools:text="The Video Title LONG very LONVideo Title LONG very LONG" />-->
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/overlay_channel_text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="marquee"
android:fadingEdge="horizontal"
android:marqueeRepeatLimit="marquee_forever"
android:scrollHorizontally="true"
android:singleLine="true"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textSize="@dimen/video_item_search_uploader_text_size"
tools:text="The Video Artist LONG very LONG very Long" />
<!-- <org.schabi.newpipe.views.NewPipeTextView-->
<!-- android:id="@+id/overlay_channel_text_view"-->
<!-- android:layout_width="match_parent"-->
<!-- android:layout_height="wrap_content"-->
<!-- android:ellipsize="marquee"-->
<!-- android:fadingEdge="horizontal"-->
<!-- android:marqueeRepeatLimit="marquee_forever"-->
<!-- android:scrollHorizontally="true"-->
<!-- android:singleLine="true"-->
<!-- android:textAppearance="?android:attr/textAppearanceSmall"-->
<!-- android:textSize="@dimen/video_item_search_uploader_text_size"-->
<!-- tools:text="The Video Artist LONG very LONG very Long" />-->
</LinearLayout>
<!-- </LinearLayout>-->
<LinearLayout
android:id="@+id/overlay_buttons_layout"
android:layout_width="wrap_content"
android:layout_height="60dp"
android:layout_alignParentEnd="true"
android:gravity="center_vertical"
android:theme="@style/ContrastTintTheme"
tools:ignore="RtlHardcoded">
<!-- <LinearLayout-->
<!-- android:id="@+id/overlay_buttons_layout"-->
<!-- android:layout_width="wrap_content"-->
<!-- android:layout_height="60dp"-->
<!-- android:layout_alignParentEnd="true"-->
<!-- android:gravity="center_vertical"-->
<!-- android:theme="@style/ContrastTintTheme"-->
<!-- tools:ignore="RtlHardcoded">-->
<ImageButton
android:id="@+id/overlay_play_queue_button"
android:layout_width="40dp"
android:layout_height="match_parent"
android:background="?attr/selectableItemBackground"
android:contentDescription="@string/title_activity_play_queue"
android:scaleType="center"
android:src="@drawable/ic_list"
tools:ignore="RtlHardcoded" />
<!-- <ImageButton-->
<!-- android:id="@+id/overlay_play_queue_button"-->
<!-- android:layout_width="40dp"-->
<!-- android:layout_height="match_parent"-->
<!-- android:background="?attr/selectableItemBackground"-->
<!-- android:contentDescription="@string/title_activity_play_queue"-->
<!-- android:scaleType="center"-->
<!-- android:src="@drawable/ic_list"-->
<!-- tools:ignore="RtlHardcoded" />-->
<ImageButton
android:id="@+id/overlay_play_pause_button"
android:layout_width="40dp"
android:layout_height="match_parent"
android:background="?attr/selectableItemBackground"
android:contentDescription="@string/pause"
android:focusable="true"
android:focusedByDefault="true"
android:scaleType="center"
android:src="@drawable/ic_play_arrow" />
<!-- <ImageButton-->
<!-- android:id="@+id/overlay_play_pause_button"-->
<!-- android:layout_width="40dp"-->
<!-- android:layout_height="match_parent"-->
<!-- android:background="?attr/selectableItemBackground"-->
<!-- android:contentDescription="@string/pause"-->
<!-- android:focusable="true"-->
<!-- android:focusedByDefault="true"-->
<!-- android:scaleType="center"-->
<!-- android:src="@drawable/ic_play_arrow" />-->
<ImageButton
android:id="@+id/overlay_close_button"
android:layout_width="48dp"
android:layout_height="match_parent"
android:background="?attr/selectableItemBackground"
android:contentDescription="@string/close"
android:paddingRight="8dp"
android:scaleType="center"
android:src="@drawable/ic_close"
tools:ignore="RtlSymmetry" />
<!-- <ImageButton-->
<!-- android:id="@+id/overlay_close_button"-->
<!-- android:layout_width="48dp"-->
<!-- android:layout_height="match_parent"-->
<!-- android:background="?attr/selectableItemBackground"-->
<!-- android:contentDescription="@string/close"-->
<!-- android:paddingRight="8dp"-->
<!-- android:scaleType="center"-->
<!-- android:src="@drawable/ic_close"-->
<!-- tools:ignore="RtlSymmetry" />-->
</LinearLayout>
<!-- </LinearLayout>-->
</RelativeLayout>
<!-- </RelativeLayout>-->
</FrameLayout>

View File

@@ -48,8 +48,6 @@
android:layout_height="55dp"
android:layout_alignParentRight="true"
android:layout_gravity="center_vertical"
android:layout_alignTop="@id/itemTitleView"
android:layout_alignBottom="@id/itemUploaderView"
android:contentDescription="@string/detail_drag_description"
android:paddingLeft="@dimen/video_item_search_image_right_margin"
android:scaleType="center"
@@ -76,13 +74,11 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/itemTitleView"
android:layout_toLeftOf="@id/itemHandle"
android:layout_toRightOf="@+id/itemThumbnailView"
android:ellipsize="end"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textSize="@dimen/video_item_search_uploader_text_size"
tools:ignore="RtlHardcoded"
tools:text="Uploader really long lorem ipsum dolor sit amet consectetur" />
tools:text="Uploader" />
</RelativeLayout>

View File

@@ -82,18 +82,6 @@
android:textColor="@color/white"
android:textSize="12sp" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/item_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/item_name"
android:layout_alignParentRight="true"
android:padding="6dp"
android:singleLine="true"
android:text=""
android:textColor="@color/white"
android:textSize="12sp" />
</RelativeLayout>
</RelativeLayout>

View File

@@ -62,18 +62,6 @@
android:textSize="12sp"
android:textStyle="bold" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/item_date"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/item_name"
android:layout_toLeftOf="@id/item_more"
android:padding="6dp"
android:singleLine="true"
android:text=""
android:textColor="@color/white"
android:textSize="12sp" />
<ImageView
android:id="@+id/item_more"
style="?attr/buttonBarButtonStyle"

View File

@@ -1 +0,0 @@
unqualifiedResLocale=en-US

View File

@@ -854,5 +854,6 @@
<string name="share_playlist_content_details">%1$s
\n%2$s</string>
<string name="share_playlist">شارِك قائمة التشغيل</string>
<string name="share_playlist_with_titles_message">شارِك قائمة التشغيل بتفاصيليها مثل اسم قائمة التشغيل وعناوين الفيديو أو كقائمة بسيطة من عناوين تشعّبيّة للفيديوهات</string>
<string name="video_details_list_item">- %1$s: %2$s</string>
</resources>
</resources>

View File

@@ -18,11 +18,11 @@
<string name="download_path_dialog_title">اختر مجلد التنزيل لملفات الفيديو</string>
<string name="download_path_summary">يتم تخزين ملفات الفيديو التي تم تنزيلها هنا</string>
<string name="download_path_title">مجلد تحميل الفيديو</string>
<string name="install">ثبيت</string>
<string name="install">ثبت</string>
<string name="kore_not_found">تطبيق Kore غير موجود. هل تريد تثبيته؟</string>
<string name="light_theme_title">فاتح</string>
<string name="network_error">خطأ في الشبكة</string>
<string name="no_player_found">لم يتم العثور على مشغل بث. تود تثبيت VLC؟</string>
<string name="no_player_found">لم يتم العثور على مشغل بث. تثبيت VLC؟</string>
<string name="open_in_browser">فتح في متصفح الويب</string>
<string name="play_audio">الصوت</string>
<string name="play_with_kodi_title">تشغيل بواسطة كودي</string>
@@ -45,7 +45,7 @@
<string name="could_not_load_thumbnails">تعذر تحميل كافة الصور المصغرة</string>
<string name="general_error">خطأ</string>
<string name="parsing_error">تعذر تحليل الموقع</string>
<string name="main_bg_subtitle">اضغط على العدسة المكبرة للبدء.</string>
<string name="main_bg_subtitle">اضغط على عدسة المكبرة للبدء.</string>
<string name="subscribe_button_title">اشتراك</string>
<string name="subscribed_button_title">مشترك</string>
<string name="tab_subscriptions">الاشتراكات</string>
@@ -83,7 +83,7 @@
<string name="resume_on_audio_focus_gain_title">استئناف التشغيل</string>
<string name="resume_on_audio_focus_gain_summary">متابعة التشغيل بعد المقاطعات (مثل المكالمات الهاتفية)</string>
<string name="show_hold_to_append_title">إظهار تلميح \"اضغط للفتح\"</string>
<string name="show_hold_to_append_summary">إظهار التلميح عند الضغط على الخلفية أو الزر المنبثق في الفيديو \"التفاصيل:\"</string>
<string name="show_hold_to_append_summary">عرض تلميح عند الضغط على زر استخدام المشغل الخلفي أو النافذة المنبثقة في صفحة تفاصيل الفديو</string>
<string name="settings_category_player_title">المشغل</string>
<string name="settings_category_player_behavior_title">السلوك</string>
<string name="popup_playing_toast">تشغيل في وضع منبثق</string>
@@ -538,7 +538,7 @@
<item quantity="other">%d دقيقة</item>
</plurals>
<string name="new_seek_duration_toast">نظرا لقيود مشغل ExoPlayer مدة التقديم تم ضبطها الى %d ثانية</string>
<string name="unmute">إلغاء الكتم</string>
<string name="unmute">غير صامت</string>
<string name="mute">كتم الصوت</string>
<string name="content_not_supported">هذا المحتوى ليس مدعومًا من قبل NewPipe.
\n
@@ -854,6 +854,7 @@
<string name="share_playlist_content_details">%1$s
\n%2$s</string>
<string name="share_playlist">مشاركة قائمة التشغيل</string>
<string name="share_playlist_with_titles_message">شارك تفاصيل قائمة التشغيل مثل اسم قائمة التشغيل وعناوين الفيديو أو كقائمة بسيطة من عناوين URL للفيديو</string>
<string name="video_details_list_item">- %1$s: %2$s</string>
<plurals name="replies">
<item quantity="zero">رد %s</item>
@@ -877,6 +878,4 @@
<string name="auto_update_check_description">يمكن لـ NewPipe البحث تلقائيًا عن الإصدارات الجديدة من وقت لآخر وإعلامك بمجرد توفرها.
\nهل تريد تمكين هذا؟</string>
<string name="no">لا</string>
<string name="import_settings_vulnerable_format">تستخدم الإعدادات الموجودة في عملية التصدير التي يتم استيرادها تنسيقًا عرضة للاختراق تم إهماله منذ NewPipe 0.27.0. تأكد من أن التصدير الذي يتم استيراده من مصدر موثوق به، ويفضل استخدام عمليات التصدير التي تم الحصول عليها من NewPipe 0.27.0 أو الأحدث في المستقبل فقط. سيتم قريبًا إزالة دعم استيراد الإعدادات بهذا التنسيق الضعيف تمامًا، وبعد ذلك لن تتمكن الإصدارات القديمة من NewPipe من استيراد إعدادات التصدير من الإصدارات الجديدة بعد الآن.</string>
<string name="audio_track_type_secondary">الثانوي</string>
</resources>
</resources>

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="main_bg_subtitle">Başlamaq üçün böyüdücü lupaya toxun.</string>
<string name="main_bg_subtitle">Başlamaq üçün böyüdücü güzgüyə toxun.</string>
<string name="upload_date_text">%1$s tarixində yayımlanıb</string>
<string name="no_player_found">Yayım oynadıcı tapılmadı. \"VLC\" quraşdırılsın\?</string>
<string name="no_player_found_toast">Yayım oynadıcı tapılmadı (Oynatmaq üçün VLC quraşdıra bilərsiniz).</string>
@@ -22,7 +22,7 @@
<string name="channel_unsubscribed">Kanal abunəliyi ləğv edildi</string>
<string name="show_info">Məlumat göstər</string>
<string name="tab_subscriptions">Abunəliklər</string>
<string name="tab_bookmarks">Əlfəcinlənmiş Pleylistlər</string>
<string name="tab_bookmarks">Əlfəcinlənmiş Oynatma Siyahıları</string>
<string name="fragment_feed_title">Yeniliklər</string>
<string name="controls_background_title">Fon</string>
<string name="download_path_title">Video yükləmə qovluğu</string>
@@ -38,8 +38,8 @@
<string name="show_play_with_kodi_title">\"Kodi ilə Oynat\" seçimini göstər</string>
<string name="show_play_with_kodi_summary">Kodi media mərkəzindən video oynatmaq üçün seçim göstər</string>
<string name="play_audio">Səs</string>
<string name="default_audio_format_title">Standart səs kodlayıcı</string>
<string name="default_video_format_title">Standart video kodlayıcı</string>
<string name="default_audio_format_title">Standart səs formatı</string>
<string name="default_video_format_title">Standart video formatı</string>
<string name="theme_title">Tema</string>
<string name="light_theme_title">İşıqlı</string>
<string name="dark_theme_title">Qaranlıq</string>
@@ -63,16 +63,16 @@
<string name="show_search_suggestions_title">Axtarış təklifləri</string>
<string name="auto_queue_toggle">Avto-növbələ</string>
<string name="auto_queue_title">Növbəti Yayımı Avto-növbələ</string>
<string name="metadata_cache_wipe_complete_notice">Üst məlumat qalığı silindi</string>
<string name="metadata_cache_wipe_summary">Toplanmış bütün veb-səhifə məlumatlarını sil</string>
<string name="metadata_cache_wipe_title">Toplanmış üst məlumatı təmizlə</string>
<string name="thumbnail_cache_wipe_complete_notice">Şəkil qalığı silindi</string>
<string name="metadata_cache_wipe_complete_notice">Üst məlumat keşi silindi</string>
<string name="metadata_cache_wipe_summary">Keşlənmiş bütün veb-səhifə məlumatlarını sil</string>
<string name="metadata_cache_wipe_title">Keşlənmiş üst məlumatı təmizlə</string>
<string name="thumbnail_cache_wipe_complete_notice">Şəkil keşi silindi</string>
<string name="show_comments_summary">Şərhləri gizlətmək üçün bağla</string>
<string name="show_comments_title">Şərhləri göstər</string>
<string name="clear_queue_confirmation_description">Aktiv oynadıcı növbəsi dəyişdiriləcək</string>
<string name="clear_queue_confirmation_summary">Bir oynadıcıdan digərinə keçid növbənizi dəyişdirə bilər</string>
<string name="clear_queue_confirmation_title">Növbəni təmizləməzdən əvvəl təsdiq üçün soruş</string>
<string name="use_inexact_seek_title">Sürətli, qeyri-dəqiq axtarış istifadə et</string>
<string name="use_inexact_seek_title">Sürətli qeyri-dəqiq axtarış istifadə et</string>
<string name="use_inexact_seek_summary">Qeyri-dəqiq axtarış oynadıcıya azaldılmış dəqiqliklə mövqeləri daha sürətli axtarmağa imkan verir. 5, 15 və ya 25 saniyəlik axtarış bununla işləmir</string>
<string name="seek_duration_title">Sürətli irəli/geri çəkmə axtarış müddəti</string>
<string name="notification_action_nothing">Heç nə</string>
@@ -95,7 +95,7 @@
<string name="channels">Kanallar</string>
<string name="video_detail_by">%s tərəfindən</string>
<string name="youtube_restricted_mode_enabled_title">YouTube\'un \"Məhdud Rejimi\"ni aç</string>
<string name="show_age_restricted_content_summary">Yaş həddi səbəbiylə (18+ kimi) uşaqlar üçün uyğun olmayan məzmunu göstər</string>
<string name="show_age_restricted_content_summary">Yaş həddi səbəbiylə (məsələn, 18+) uşaqlar üçün uyğun olmayan məzmunu göstər</string>
<string name="show_age_restricted_content_title">Yaş məhdudiyyətli məzmunu göstər</string>
<string name="content">Məzmun</string>
<string name="popup_playing_toast">Ani görüntü rejimində oynadılır</string>
@@ -103,7 +103,7 @@
<string name="settings_category_updates_title">Yeniləmələr</string>
<string name="settings_category_debug_title">Sazlama</string>
<string name="settings_category_appearance_title">Görünüş</string>
<string name="settings_category_history_title">Tarixçə və qalıq</string>
<string name="settings_category_history_title">Tarixçə və keş</string>
<string name="settings_category_video_audio_title">Video və səs</string>
<string name="settings_category_player_behavior_title">Davranış</string>
<string name="settings_category_player_title">Oynadıcı</string>
@@ -112,7 +112,7 @@
<string name="unsupported_url_dialog_message">URL\'i tanımaq olmadı. Başqa tətbiqlə açılsın\?</string>
<string name="unsupported_url">Dəstəklənməyən URL\'i</string>
<string name="show_hold_to_append_title">\"Növbələmək üçün basılı saxla\" tövsiyəsin göstər</string>
<string name="show_next_and_similar_title">\"Növbəti\" və \"Oxşar\" videoları göstər</string>
<string name="show_next_and_similar_title">\"Növbəti\" və \"Bənzər\" videoları göstər</string>
<string name="export_data_summary">Tarixçəni, abunəlikləri, pleylistləri və tənzimləmələri ixrac et</string>
<string name="import_data_summary">Cari tarixçənizi, abunəliklərinizi, pleylistlərinizi və (könüllü) tənzimləmələrinizi etibarsız edir</string>
<string name="recaptcha_cookies_cleared">reCAPTCHA bazaları təmizləndi</string>
@@ -135,11 +135,11 @@
\n
\nOnu görmək istəyirsinizsə, tənzimləmələrdə \"%1$s\" seçimini aktivləşdirin.</string>
<string name="youtube_restricted_mode_enabled_summary">YouTube potensial yetkin məzmunu gizlədən \"Məhdud Rejim\" təmin edir</string>
<string name="peertube_instance_url_title">\"PeerTube\" serverləri</string>
<string name="peertube_instance_url_title">\"PeerTube\" nümunələri</string>
<string name="notification_actions_at_most_three">Yığcam bildirişdə göstərmək üçün ən çoxu üç fəaliyyət seçə bilərsiniz!</string>
<string name="feed_update_threshold_option_always_update">Həmişə yenilə</string>
<string name="settings_category_feed_title">Axın</string>
<string name="feed_group_show_only_ungrouped_subscriptions">Yalnız qrupsuz abunəlikləri göstər</string>
<string name="feed_group_show_only_ungrouped_subscriptions">Yalnız qruplaşdırılmamış abunəlikləri göstər</string>
<string name="feed_create_new_group_button_title">Yeni</string>
<string name="feed_group_dialog_delete_message">Bu qrupu silmək istəyirsiniz\?</string>
<string name="feed_group_dialog_empty_name">Boş qrup adı</string>
@@ -174,7 +174,7 @@
<string name="general_error">Xəta</string>
<string name="search_history_deleted">Axtarış tarixçəsi silindi</string>
<string name="delete_search_history_alert">Bütün axtarış tarixçəsi silinsin\?</string>
<string name="clear_search_history_summary">Açar sözlər axtarışı tarixçəsin silir</string>
<string name="clear_search_history_summary">Açar sözləri axtarışı tarixçəsini silir</string>
<string name="clear_search_history_title">Axtarış tarixçəsini sil</string>
<string name="watch_history_states_deleted">Oynatma mövqeləri silindi</string>
<string name="delete_playback_states_alert">Bütün oynatma mövqeləri silinsin\?</string>
@@ -188,13 +188,13 @@
<string name="resize_zoom">Yaxınlaşdır</string>
<string name="resize_fill">Doldur</string>
<string name="resize_fit">Uyğunlaşdır</string>
<string name="caption_none">Titrlər Yoxdur</string>
<string name="caption_none">Altyazı Yoxdur</string>
<string name="delete">Sil</string>
<string name="no_channel_subscribed_yet">Hələ ki, kanal abunəliyi yoxdur</string>
<string name="select_a_channel">Kanal seç</string>
<string name="channel_page_summary">Kanal Səhifəsi</string>
<string name="default_kiosk_page_summary">Standart Bölmə</string>
<string name="kiosk_page_summary">Kənar Səhifə</string>
<string name="default_kiosk_page_summary">Standart Köşk</string>
<string name="kiosk_page_summary">Köşk Səhifə</string>
<string name="blank_page_summary">Boş Səhifə</string>
<string name="main_page_content_summary">Əsas səhifədə hansı tablar göstərilir</string>
<string name="main_page_content">Əsas səhifə məzmunu</string>
@@ -203,8 +203,8 @@
<string name="limit_mobile_data_usage_title">Mobil internet istifadə edərkən ayırdetməni məhdudlaşdır</string>
<string name="limit_data_usage_none_description">Limitsiz</string>
<string name="one_item_deleted">1 element silindi.</string>
<string name="peertube_instance_add_title">Server əlavə et</string>
<string name="peertube_instance_url_summary">Sevimli \"PeerTube\" serverlərinizi seçin</string>
<string name="peertube_instance_add_title">Nümunə əlavə et</string>
<string name="peertube_instance_url_summary">Sevimli \"PeerTube\" nümunələrinizi seçin</string>
<string name="delete_downloaded_files">Endirilmiş faylları sil</string>
<string name="confirm_prompt">Endirmə tarixçənizi təmizləmək və ya bütün endirilmiş faylları silmək istəyirsiniz\?</string>
<string name="clear_download_history">Endirmə tarixçəsini təmizlə</string>
@@ -213,8 +213,8 @@
<string name="downloads_storage_ask_title">Haraya endiriləcəyini soruş</string>
<string name="downloads_storage_ask_summary">Sizdən hər endirmənin harada saxlanılacağı soruşulacaq.
\nXarici SD karta endirmək istəyirsinizsə, sistem qovluğu seçicisini (SAF) aktiv edin</string>
<string name="downloads_storage_use_saf_summary">\'Yaddaş Giriş Quruluşu\' xarici SD karta endirməyə imkan verir</string>
<string name="systems_language">Sistem standartı</string>
<string name="downloads_storage_use_saf_summary">\'Yaddaş Giriş Çərçivəsi \' xarici SD karta endirməyə imkan verir</string>
<string name="systems_language">Sistem defoltu</string>
<string name="app_language_title">Tətbiq dili</string>
<plurals name="days">
<item quantity="one">%d gün</item>
@@ -233,16 +233,26 @@
<item quantity="other">%d saniyə</item>
</plurals>
<string name="feed_oldest_subscription_update">Axın sonuncu dəfə yeniləndi: %s</string>
<string name="feed_update_threshold_title">Axın yeniləmə aralığı</string>
<string name="feed_update_threshold_title">Axın yeniləmə astanası</string>
<string name="feed_use_dedicated_fetch_method_enable_button">Sürətli rejimi aktivləşdir</string>
<string name="feed_use_dedicated_fetch_method_disable_button">Sürətli rejimi bağla</string>
<string name="feed_use_dedicated_fetch_method_help_text">Axının çox yavaş yükləndiyini düşünürsünüz? \n Elədirsə, sürətli yükləməni işə salmağı sınayın (tənzimləmələrdə dəyişə və ya aşağıdakı düyməni basa bilərsiniz). \n \nNewPipe 2 axın yükləmə üsulu təklif edir: \n• Yavaş, lakin tam şəkildə bütün abunəlik kanalı gətirilir. \n• Ayrılmış xidmət uc nöqtəsi istifadə etmək, bu sürətlidir, amma tam deyil. \n \nİkisi arasında fərq budur ki, sürətlisində, adətən elementin müddəti və növü kimi bəzi məlumatlar çatışmır (canlı video ilə adisini ayırd edə bilmir) və daha az elementlər gətirir. \n \nYouTube öz RSS axını ilə bu sürətli metodu təklif edən xidmətlərdən biridir. \n \nBeləliklə, seçim sizin nəyə üstünlük verməyinizdən asılıdır: sürət yoxsa dəqiq məlumat.</string>
<string name="feed_use_dedicated_fetch_method_disable_button">Sürətli rejimi deaktiv et</string>
<string name="feed_use_dedicated_fetch_method_help_text">Axının çox yavaş yükləndiyini düşünürsünüz\? Əgər elədirsə, sürətli yükləməni işə salmağı sınayın (tənzimləmələrdə dəyişə və ya aşağıdakı düyməni basa bilərsiniz).
\n
\nNewPipe axını yükləmək üçün 2 metod təklif edir:
\n• Bütün abunəlik kanallarını gətirtmək, bu yavaş olsa da tamdır;
\n• Ayrılmış xidmət uc nöqtəsi istifadə etmək, bu sürətlidir, amma tam deyil.
\n
\nBu ikisi arasında fərq odur ki, sürətlisində, adətən elementin müddəti və növü kimi bəzi məlumatlar çatışmır (canlı video ilə adisini ayırd edə bilmir) və daha az elementlər gətirir.
\n
\nYouTube öz RSS axını ilə bu sürətli metodu təklif edən xidmətlərdən biridir.
\n
\nBeləliklə, seçim sizin nəyə üstünlük verməyinizdən asılıdır: sürət yoxsa dəqiq məlumat.</string>
<string name="player_stream_failure">Bu yayımı oynatmaq alınmadı</string>
<string name="app_ui_crash">Tətbiq/UI çökdü</string>
<string name="could_not_setup_download_menu">Endirmə menyusunu qurmaq mümkün olmadı</string>
<string name="content_not_available">Məzmun əlçatmazdır</string>
<string name="could_not_load_thumbnails">Bütün miniatürləri yükləmək alınmadı</string>
<string name="network_error">Şəbəkə səhvi</string>
<string name="network_error">Şəbəkə xətası</string>
<string name="download_to_sdcard_error_message">Xarici SD karta endirmək mümkün deyil. Endirmə qovluğunun yeri sıfırlansın\?</string>
<string name="download_to_sdcard_error_title">Xarici yaddaş əlçatan deyil</string>
<string name="clear_views_history_summary">Oynadılmış yayımlar tarixçəsini və oynatma mövqelərini silir</string>
@@ -252,7 +262,7 @@
<string name="notification_colorize_title">Bildirişi rənglə</string>
<string name="invalid_directory">Belə qovluq yoxdur</string>
<string name="start_main_player_fullscreen_title">Əsas oynadıcını tam ekranda başlat</string>
<string name="external_player_unsupported_link_type">Xarici oynadıcılar bu növ linkləri dəstəkləmir</string>
<string name="external_player_unsupported_link_type">Xarici oynadıcılar bu cür linkləri dəstəkləmir</string>
<string name="local_search_suggestions">Yerli axtarış təklifləri</string>
<string name="video">Video</string>
<string name="related_items_tab_description">Əlaqəli elementlər</string>
@@ -264,7 +274,7 @@
<string name="video_streams_empty">Video yayımı tapılmadı</string>
<string name="comments_tab_description">Şərhlər</string>
<string name="description_tab_description">ıqlama</string>
<string name="empty_list_subtitle">Burada sisəylərdən başqa heç nə yoxdur</string>
<string name="empty_list_subtitle">Burada kriketlərdən başqa heç nə yoxdur</string>
<string name="search_no_results">Nəticə yoxdur</string>
<string name="restore_defaults">Standartları qaytar</string>
<string name="missing_file">Fayl köçürüldü və ya silindi</string>
@@ -276,7 +286,7 @@
<string name="audio_streams_empty">Səs yayımı tapılmadı</string>
<string name="permission_display_over_apps">Digər tətbiqlərin üzərində göstərməyə icazə ver</string>
<string name="restore_defaults_confirmation">İlkin tənzimləmələri qaytarmaq istəyirsiniz\?</string>
<string name="enqueue_next_stream">Növbəti sıraya sal</string>
<string name="enqueue_next_stream">Növbətini növbələ</string>
<string name="retry">Təkrar Cəhd Et</string>
<string name="settings_category_player_notification_summary">Cari oynatma yayımı bildirişini konfiqurasiya et</string>
<string name="notifications">Bildirişlər</string>
@@ -284,12 +294,12 @@
<string name="streams_notification_channel_description">Abunəliklər üçün yeni yayımlar haqqında bildirişlər</string>
<string name="error_report_channel_description">Xəta hesabatları üçün bildirişlər</string>
<string name="file_name_empty_error">Fayl adı boş ola bilməz</string>
<string name="saved_tabs_invalid_json">Saxlanmış səhifələri oxumaq alınmadı, ona görə standart səhifələr istifadə et</string>
<string name="saved_tabs_invalid_json">Saxlanmış tabları oxumaq mümkün olmadı, buna görə standart tabları istifadə et</string>
<string name="error_report_notification_title">NewPipe xəta ilə qarşılaşdı, bildirmək üçün toxun</string>
<string name="sorry_string">Bağışla, bu baş verməməli idi.</string>
<string name="sorry_string">Bağışla, o baş verməməli idi.</string>
<string name="error_report_button_text">E-poçt- dan məlumat ver</string>
<string name="error_report_open_issue_button_text">GitHub\'da Məlumat Ver</string>
<string name="error_report_open_github_notice">Xahiş edirik, xətanızı müzakirə edən məsələnin mövcud olub-olmadığını yoxlayın. Dublikat biletləri yaradarkən, bizdən faktiki səhvi düzəltməyə sərf edəcəyimiz vaxtı alırsınız.</string>
<string name="error_report_open_github_notice">Zəhmət olmasa, xətanızı müzakirə edən məsələnin mövcud olub-olmadığını yoxlayın. Dublikat biletləri yaradarkən, bizdən faktiki səhvi düzəltməyə sərf edəcəyimiz vaxt alırsınız.</string>
<string name="error_snackbar_action">Məlumat Ver</string>
<string name="what_device_headline">Məlumat:</string>
<string name="what_happened_headline">Nə baş verdi:</string>
@@ -319,7 +329,7 @@
<string name="recaptcha_request_toast">reCAPTCHA sorğusu göndərildi</string>
<string name="done">Bitdi</string>
<string name="settings_file_replacement_character_summary">Etibarsız simvollar bu dəyərlə əvəz olunur</string>
<string name="settings_file_replacement_character_title">Əvəzedici simvol</string>
<string name="settings_file_replacement_character_title">Əvəzedici xarakter</string>
<string name="charset_most_special_characters">Ən xüsusi simvollar</string>
<string name="title_licenses">Üçüncü Tərəf Lisenziyaları</string>
<string name="tab_about">Haqqında &amp; T-TSS</string>
@@ -337,7 +347,7 @@
<string name="delete_item_search_history">Bu elementi axtarış tarixçəsindən silmək istəyirsiniz\?</string>
<string name="title_last_played">Son Oynadılan</string>
<string name="title_most_played">Ən Çox Oynadılan</string>
<string name="select_a_kiosk">Bölmə seç</string>
<string name="select_a_kiosk">Köşk seç</string>
<string name="import_complete_toast">İdxal edildi</string>
<string name="no_valid_zip_file">Etibarlı ZIP faylı yoxdur</string>
<string name="could_not_import_all_files">Xəbərdarlıq: Bütün faylları idxal etmək mümkün olmadı.</string>
@@ -368,13 +378,13 @@
<string name="playlist_thumbnail_change_success">Pleylist miniatürü dəyişdirildi.</string>
<string name="playlist_no_uploader">Avtomatik yaradıldı (heç bir yükləyici tapılmadı)</string>
<string name="caption_auto_generated">Avtomatik yaradıldı</string>
<string name="caption_setting_title">Titrlər</string>
<string name="caption_setting_title">Altyazılar</string>
<string name="leak_canary_not_available">LeakCanary yoxdur</string>
<string name="enable_leak_canary_summary">Yaddaş sızma monitorinqi yığın boşaltma zamanı tətbiqin cavab verməməsinə səbəb ola bilər</string>
<string name="show_memory_leaks">Yaddaş sızmalarını göstər</string>
<string name="enable_disposed_exceptions_summary">Utilizasiyadan sonra fraqment və ya fəaliyyətin yaşam dövründən kənarda, çatdırıla bilməyən Rx istisnaları barədə məlumat verməsinə məcbur et</string>
<string name="enable_disposed_exceptions_summary">Utilizasiyadan sonra fraqment və ya fəaliyyətin yaşam dövründən kənarda çatdırıla bilməyən Rx istisnaları barədə hesabat verməyə məcbur et</string>
<string name="show_original_time_ago_summary">Xidmətlərdən alınmış orijinal mətnlər yayım elementlərində görünəcək</string>
<string name="check_new_streams">Yeni yayımları yoxlamanı işə sal</string>
<string name="check_new_streams">Yeni yayımları yoxla</string>
<string name="import_soundcloud_instructions">URL və ya ID\'nizi daxil etməklə SoundCloud profilini idxal edin:
\n
\n1. Veb-brauzerdə \"iş masası rejimini\" aktiv edin (sayt mobil cihazlar üçün mövcud deyil)
@@ -401,8 +411,8 @@
<string name="error_http_unsupported_range">Server çox iş parçalı endirmələri qəbul etmir, @string/msg_threads = 1 ilə yenidən cəhd edin</string>
<string name="delete_downloaded_files_confirm">Bütün endirilmiş fayllar diskdən silinsin\?</string>
<string name="max_retry_msg">Maksimum təkrar cəhdlər</string>
<string name="remove_watched_popup_warning">Pleylistə əlavə olunandan əvvəl və sonrakı baxılmış videolar silinəcək.
\nSiz əminsiniz? Bu geri qaytarıla bilməz!</string>
<string name="remove_watched_popup_warning">Pleylistə əlavə olunandan əvvəl və sonra baxılmış videolar silinəcək.
\nSiz əminsiniz\? Bu geri qaytarıla bilməz!</string>
<string name="feed_groups_header_title">Kanal qrupları</string>
<string name="feed_new_items">Yeni axın elementləri</string>
<string name="feed_update_threshold_summary">Abunəlik köhnəlmiş hesab edilənə qədərki son yeniləmədən sonrakı vaxt — %s</string>
@@ -446,13 +456,13 @@
<string name="error_http_no_content">Server məlumat göndərmir</string>
<string name="error_download_resource_gone">Bu endirməni bərpa etmək mümkün deyil</string>
<string name="downloads_storage_ask_summary_no_saf_notice">Sizdən hər endirmənin harada saxlanılacağı soruşulacaq</string>
<string name="downloads_storage_use_saf_summary_api_29">\"Yaddaş Giriş Quruluşu\"yalnız Android 10\'dan başlayaraq dəstəklənir</string>
<string name="downloads_storage_use_saf_summary_api_29">\"Yaddaş Giriş Çərçivəsi\"yalnız Android 10\'dan başlayaraq dəstəklənir</string>
<string name="detail_sub_channel_thumbnail_view_description">Kanalın avatar miniatürü</string>
<string name="select_night_theme_toast">Sevdiyiniz gecə temasını aşağıda seçə bilərsiniz</string>
<string name="notification_colorize_summary">Android\'in bildiriş rəngini miniatürdəki əsas rəngə uyğun fərdiləşdirməsini təmin et (qeyd edək ki, bu, bütün cihazlarda mövcud deyil)</string>
<string name="view_on_github">GitHub\'da Bax</string>
<string name="donation_title">İanə Et</string>
<string name="donation_encouragement">NewPipe, sizə ən yaxşı istifadəçi təcrübəsi göstərmək üçün boş vaxtını sərf edən könüllülər tərəfindən hazırlanmışdır. Tərtibatçıların bir fincan qəhvə içərkən NewPipe-ı daha da yaxşılaşdırmasına ianə edərək kömək edin.</string>
<string name="donation_encouragement">NewPipe, sizə ən yaxşı istifadəçi təcrübəsi göstərmək üçün boş vaxtlarını sərf edən könüllülər tərəfindən hazırlanmışdır. Tərtibatçılara bir fincan qəhvə içərkən NewPipe-ı daha da yaxşılaşdırmağa ianə etməklə kömək edin.</string>
<string name="most_liked">Ən çox bəyənildi</string>
<string name="enqueued">Növbəyə salındı</string>
<string name="preferred_open_action_settings_summary">Məzmunu açarkən standart hərəkət — %s</string>
@@ -482,7 +492,7 @@
<string name="show_meta_info_summary">Yayım yaradıcısı, məzmunu və ya axtarış sorğusu haqqında əlavə məlumat olan üst məlumat qutularını gizlətmək üçün söndür</string>
<string name="auto_queue_summary">Əlaqəli yayımı əlavə etməklə (təkrarlanmayan) sonlanacaq oynatma növbəsini davam etdir</string>
<string name="remote_search_suggestions">Kənar axtarış təklifləri</string>
<string name="peertube_instance_add_exists">Server artıq mövcuddur</string>
<string name="peertube_instance_add_exists">Nümunə artıq mövcuddur</string>
<string name="start_main_player_fullscreen_summary">Videoları kiçik oynadıcıda başlatma, avtomatik fırlatma kilidlidirsə, birbaşa tam ekran rejiminə keçid. Siz hələ də tam ekrandan çıxmaqla mini oynadıcıya daxil ola bilərsiniz</string>
<string name="more_than_100_videos">100+ video</string>
<string name="infinite_videos">∞ video</string>
@@ -493,7 +503,7 @@
<string name="trending">Trenddə olan</string>
<string name="play_queue_audio_settings">Səs Tənzimləmələri</string>
<string name="preferred_player_fetcher_notification_title">Məlumat əldə edilir…</string>
<string name="show_original_time_ago_title">Elementlərdə orijinal, əvvəlki vaxtı göstər</string>
<string name="show_original_time_ago_title">Elementlərdə orijinal əvvəlki vaxtı göstər</string>
<string name="enable_disposed_exceptions_title">Yaşam dövrəsi xaricindəki xətaları bildir</string>
<string name="pause_downloads_on_mobile_desc">Bəzi endirmələri dayandırmaq mümkün olmasa da, mobil dataya keçərkən faydalıdır</string>
<string name="close">Bağla</string>
@@ -536,7 +546,7 @@
<string name="remove_watched">İzləniləni sil</string>
<string name="downloads_storage_use_saf_title">Sistem qovluğu seçicisini (SAF) istifadə et</string>
<string name="error_timeout">Bağlantı fasiləsi</string>
<string name="error_insufficient_storage_left">Cihazda yer yoxdur</string>
<string name="error_insufficient_storage_left">Cihazda yer qalmayıb</string>
<string name="error_postprocessing_stopped">Fayl üzərində işləyərkən NewPipe bağlandı</string>
<string name="error_postprocessing_failed">Emaldan sonra uğursuz oldu</string>
<string name="error_connect_host">Serverə qoşulmaq mümkün deyil</string>
@@ -566,13 +576,13 @@
<string name="drawer_open">Çəkməcəni Aç</string>
<string name="hold_to_append">Növbələşdirmək üçün basılı tut</string>
<string name="play_queue_remove">Sil</string>
<string name="app_description">Android\'də pulsuz, zərif yayımlayıcı.</string>
<string name="app_description">Android\'də pulsuz yüngül yayımlayıcı.</string>
<string name="copyright">© %1$s, %2$s tərəfindən %3$s altında</string>
<string name="settings_category_downloads_title">Endirmə</string>
<string name="msg_popup_permission">Bu icazə, ani görüntü rejimində
\naçmaq üçün lazımdır</string>
<string name="msg_copied">Buferə köçürüldü</string>
<string name="msg_threads">Kadrlar</string>
<string name="msg_copied">Buferə kopyalandı</string>
<string name="msg_threads">Parçalar</string>
<string name="rename">Adını dəyişdir</string>
<string name="create">Yarat</string>
<plurals name="subscribers">
@@ -581,14 +591,14 @@
</plurals>
<string name="audio">Səs</string>
<string name="error_details_headline">Təfərrüatlar:</string>
<string name="info_labels">Nə:\\nSorğu:\\nMəzmun Dili:\\nMəzmun Ölkəsi:\\nTətbiq Dili:\\nXidmət:\\nGMT Saatı:\\nPaket:\\nVersiya:\\nƏS versiyası:</string>
<string name="error_snackbar_message">Bağışlayın, nəsə səhv oldu.</string>
<string name="copy_for_github">Formatlanmış hesabatı köçür</string>
<string name="peertube_instance_add_help">Server URL\'sini daxil et</string>
<string name="peertube_instance_add_fail">Serveri təsdiqləmək mümkün olmadı</string>
<string name="peertube_instance_url_help">%s-də bəyəndiyiniz serverləri tapın</string>
<string name="show_hold_to_append_summary">Video \"Təfsilatlar\" səhifəsində fon və ya ani görüntü düyməsin basarkən ipucu göstər</string>
<string name="caption_setting_description">Oynadıcı titr mətn miqyasını və arxa fon üslublarını dəyişdir. Effektiv olması üçün tətbiqi yenidən başlatmaq tələb olunur</string>
<string name="info_labels">Nə:\\nTələb:\\nMəzmun Dili:\\nMəzmun Ölkəsi:\\nTətbiq Dili:\\nXidmət:\\nGMT Saatı:\\nPaket:\\nVersiya:\\nƏS versiyası:</string>
<string name="error_snackbar_message">Bağışlayın, nəsə xəta baş verdi.</string>
<string name="copy_for_github">Formatlanmış hesabatı kopyala</string>
<string name="peertube_instance_add_help">Nümunə URL\'sini daxil et</string>
<string name="peertube_instance_add_fail">Nümunəni doğrulamaq mümkün olmadı</string>
<string name="peertube_instance_url_help">%s-də bəyəndiyiniz nümunələri tapın</string>
<string name="show_hold_to_append_summary">Video \"Təfsilatlar:\"səhifəsində fon və ya ani görüntü düyməsin basarkən ipucu göstər</string>
<string name="caption_setting_description">Oynadıcı altyazı mətn miqyasını və arxa fon üslublarını dəyişdir. Effektiv olması üçün tətbiqi yenidən başlatmaq tələb olunur</string>
<string name="error_occurred_detail">Xəta baş verdi: %1$s</string>
<string name="invalid_file">Fayl mövcud deyil, yaxud oxumaq və ya yazmaq icazəsi yoxdur</string>
<string name="parsing_error">Veb saytı təhlil etmək alınmadı</string>
@@ -602,7 +612,7 @@
<string name="export_to">Bura ixrac et</string>
<string name="import_file_title">Faylı idxal et</string>
<string name="subscriptions_import_unsuccessful">Abunəlikləri idxal etmək mümkün olmadı</string>
<string name="start_accept_privacy_policy">Avropa Ümumi Məlumat Mühafizəsi Qaydasına (GDPR) riayət etmək üçün diqqətinizi NewPipe məxfilik siyasətinə cəlb edirik. Xahiş edirik, diqqətlə oxuyun.\nXəta məlumatın bizə göndərmək üçün qəbul etməlisiniz.</string>
<string name="start_accept_privacy_policy">Avropa Ümumi Məlumat Mühafizəsi Qaydasına (GDPR) riayət etmək üçün diqqətinizi NewPipe məxfilik siyasətinə cəlb edirik. Zəhmət olmasa, diqqətlə oxuyun. Xəta məlumatın bizə göndərmək üçün qəbul etməlisiniz.</string>
<string name="overwrite_unrelated_warning">Bu adda fayl artıq mövcuddur</string>
<string name="download_already_pending">Bu adla gözlənilən bir endirmə var</string>
<string name="error_path_creation">Təyinat qovluğu yaradıla bilməz</string>
@@ -645,13 +655,22 @@
<string name="no_playlist_bookmarked_yet">Hələ,əlfəcinlənmiş pleylistlər yoxdur</string>
<string name="override_current_data">Bu, cari quraşdırmanızı ləğv edəcək.</string>
<string name="enqueue_stream">Növbəyə qoy</string>
<string name="disable_media_tunneling_summary">Qara ekranla qarşılaşsanız və ya videonu oynatdıqda donarsa, media girişin qeyri-aktiv edin.</string>
<string name="disable_media_tunneling_summary">Qara ekranla qarşılaşsanız və ya videonu oynatdıqda səs pozularsa, media tunelin qeyri-aktiv edin</string>
<string name="enqueued_next">Növbəti sıraya salındı</string>
<string name="start_here_on_background">Fonda oynatmağa başla</string>
<string name="loading_stream_details">Yayım təfərrüatları yüklənir…</string>
<string name="disable_media_tunneling_title">Media girişin qeyri-aktiv et</string>
<string name="disable_media_tunneling_title">Media tunelini deaktiv et</string>
<string name="crash_the_app">Tətbiq çökdü</string>
<string name="import_youtube_instructions">YouTube abunəliklərin Google Takeout-dan idxal et: \n \n1. %1$s URL\'ə keçin: \n2. Soruşulduqda daxil ol \n3. \"Bütün Məlumatlar Daxildir\",sonra \"Hamısın Seçmə\", yalnız \"abunəlikləri\" seç və \"Oldu\" kliklə \n4. \"Növbəti addım\"üzərinə kliklə, sonra isə \"İxrac Yarat\" üzərinə kliklə \n5. Görünəndən sonra, \"Endirin\"düyməsin bas \n6. Aşağıda FAYLI İDXAL ET düyməsin kliklə və yüklənilmiş (.zip) faylın seç \n7. [Əgər .zip faylı idxalı uğursuz olsa] .csv faylın çıxar(adətən\"YouTubeandYouTubeMusic/subscriptions/subscriptions.csv\" altında),aşağıda FAYLI İDXAL ET-ə kliklə və çıxarılan csv faylın seç</string>
<string name="import_youtube_instructions">YouTube abunəliklərin Google Takeout-dan
\nidxal et:
\n
\n1. %1$s URL\'ə keçin:
\n2. Soruşulduqda daxil ol
\n3. \"Bütün Məlumatlar Daxildir\",sonra \"Hamısın Seçmə\", yalnız \"abunəlikləri\" seç və \"Oldu\" kliklə
\n4. \"Növbəti addım\"üzərinə kliklə, sonra isə \"İxrac Yarat\" üzərinə kliklə
\n5. Görünəndən sonra, \"Endirin\"düyməsin bas
\n6. Aşağıda FAYLI İDXAL ET düyməsin kliklə və yüklənilmiş (.zip) faylın seç
\n7. [Əgər .zip faylı idxalı uğursuz olsa] .csv faylın çıxar(adətən\"YouTubeandYouTubeMusic/subscriptions/subscriptions.csv\" altında),aşağıda FAYLI İDXAL ET-ə kliklə və çıxarılan csv faylın seç</string>
<string name="playback_speed_control">Oynatma Sürəti Nizamlamaları</string>
<string name="unhook_checkbox">Ayır (pozuntuya səbəb ola bilər)</string>
<string name="show_error">Xətanı göstər</string>
@@ -661,17 +680,19 @@
<item quantity="other">%1$s endirmə silindi</item>
</plurals>
<string name="stop">Dayandır</string>
<string name="choose_instance_prompt">Server seç</string>
<string name="choose_instance_prompt">Nümunə seç</string>
<string name="feed_load_error_fast_unknown">Sürətli axın rejimi bu barədə əlavə məlumat vermir.</string>
<string name="new_seek_duration_toast">ExoPlayer məhdudiyyətlərinə görə axtarış müddəti %d saniyəyə təyin edildi</string>
<string name="feed_use_dedicated_fetch_method_summary">Bəzi xidmətlərdə mövcuddur, adətən daha sürətli olur, lakin məhdud sayda elementləri və çox vaxt natamam məlumatı qaytara bilər (məsələn, müddət, element növü, canlı status yoxdur)</string>
<string name="no_appropriate_file_manager_message">Bu əməliyyat üçün uyğun fayl meneceri tapılmadı. Zəhmət olmasa, fayl menecerini quraşdır və ya endirmə tənzimləmələrində \'%s\'-i qeyri-aktiv etməyə çalış</string>
<string name="no_appropriate_file_manager_message">Bu əməliyyat üçün uyğun fayl meneceri tapılmadı.
\nZəhmət olmasa, fayl menecerini quraşdırın və ya endirmə tənzimləmələrində \'%s\'-i deaktiv etməyə çalışın</string>
<string name="feed_load_error_account_info">\'%s\' üçün axın yükləmək mümkün olmadı.</string>
<string name="no_appropriate_file_manager_message_android_10">Bu fəaliyyət üçün uyğun fayl meneceri tapılmadı.\nXahiş olunur, Yaddaş Giriş Quruluşuna uyğun fayl meneceri quraşdırın</string>
<string name="no_appropriate_file_manager_message_android_10">Bu əməliyyat üçün uyğun fayl meneceri tapılmadı.
\nZəhmət olmasa ,Yaddaş Giriş Çərçivəsinə uyğun fayl menecerini quraşdırın</string>
<string name="youtube_music_premium_content">Bu video yalnız YouTube Music Premium üzvləri üçün əlçatandır, ona görə də NewPipe tərəfindən yayımlamaq və ya endirmək mümkün deyil.</string>
<string name="description_select_note">İndi açıqlamadakı mətni seçə bilərsiniz. Nəzərə alın ki, seçim rejimində səhifə titrəyə və linklər kliklənməyə bilər.</string>
<string name="notification_scale_to_square_image_summary">Bildirişdə göstərilən video miniatürünü 16:9-dan 1:1 görünüş nisbətinə qədər kəs</string>
<string name="notification_actions_summary">Aşağıdakı hər bir bildiriş fəaliyyətini üzərinə toxunaraq redaktə edin. Sağdakı təsdiq qutuların istifadə edərək yığcam bildirişdə göstərmək üçün onların üçünü seçin.</string>
<string name="notification_actions_summary">Aşağıdakı hər bir bildiriş fəaliyyətini üzərinə toxunaraq düzəliş edin. Sağdakı təsdiq qutularından istifadə edərək yığcam bildirişdə göstərmək üçün onların üçünü seçin.</string>
<string name="invalid_source">Belə fayl/məzmun mənbəyi yoxdur</string>
<string name="selected_stream_external_player_not_supported">Seçilən yayım xarici oynadıcılar tərəfindən dəstəklənmir</string>
<string name="streams_not_yet_supported_removed">Yükləyici tərəfindən hələ dəstəklənməyən yayımlar göstərilmir</string>
@@ -687,15 +708,15 @@
<string name="sort">Çeşidlə</string>
<string name="app_update_unavailable_toast">NewPipe\'ın ən son versiyasın işlədirsiniz</string>
<string name="fast_mode">Sürətli rejim</string>
<string name="import_subscriptions_hint">3 nöqtə menyudan abunəlikləri idxal və ya ixrac et</string>
<string name="import_subscriptions_hint">3 nöqtə menyudan abunələri idxal və ya ixrac et</string>
<string name="app_update_available_notification_text">%s endirmək üçün toxun</string>
<string name="night_theme_available">Bu seçim yalnız tema üçün %s seçildikdə əlçatandır</string>
<string name="unset_playlist_thumbnail">Daimi miniatürü ləğv et</string>
<string name="card">Kart</string>
<string name="msg_failed_to_copy">Buferə köçürmək alınmadı</string>
<string name="duplicate_in_playlist">Boz rəngdəki pleylistlərdə artıq bu element var.</string>
<string name="msg_failed_to_copy">Buferə kopyalamaq alınmadı</string>
<string name="duplicate_in_playlist">Boz rəngdə olan pleylistlərdə artıq bu element var.</string>
<string name="playlist_add_stream_success_duplicate">Dublikat %d dəfə əlavə edildi</string>
<string name="ignore_hardware_media_buttons_title">Cihaz daxili media düyməsi problemlərinə məhəl qoyma</string>
<string name="ignore_hardware_media_buttons_title">Aparat mühiti media düyməsi hadisələrinə məhəl qoyma</string>
<string name="ignore_hardware_media_buttons_summary">Məsələn, fiziki düymələri qırılan qulaqlıq işlədirsinizsə faydalıdır</string>
<string name="remove_duplicates">Dublikatları sil</string>
<string name="remove_duplicates_title">Dublikatlar silinsin\?</string>
@@ -705,9 +726,9 @@
<string name="feed_show_partially_watched">Qismən baxılıb</string>
<string name="remove_duplicates_message">Bu pleylistdəki bütün dublikat yayımları silmək istəyirsiniz\?</string>
<string name="feed_show_upcoming">Yaxınlaşan</string>
<string name="left_gesture_control_title">Sol jest fəaliyyəti</string>
<string name="left_gesture_control_title">Sol jest hərəkəti</string>
<string name="right_gesture_control_summary">Oynadıcı ekranının sağ yarısı üçün jest seç</string>
<string name="right_gesture_control_title">Sağ jest fəaliyyəti</string>
<string name="right_gesture_control_title">Sağ jest hərəkəti</string>
<string name="brightness">Parlaqlıq</string>
<string name="volume">Səs səviyyəsi</string>
<string name="none">Heç biri</string>
@@ -718,14 +739,16 @@
<string name="prefer_descriptive_audio_summary">Varsa, görmə qabiliyyəti zəifləyən insanlar üçün təsviri olan səs axını seçin</string>
<string name="play_queue_audio_track">Səs: %s</string>
<string name="audio_track">Səs axını</string>
<string name="audio_track_present_in_video">Səs axını artıq bu yayımda olmalıdır</string>
<string name="audio_track_present_in_video">Səs axını bu yayımda olmalıdır</string>
<string name="select_audio_track_external_players">Xarici oynadıcılar üçün səs axını seç</string>
<string name="unknown_audio_track">Naməlum</string>
<string name="settings_category_exoplayer_title">ExoPlayer tənzimləmələri</string>
<string name="settings_category_exoplayer_summary">Bəzi ExoPlayer tənzimləmələrin idarə et. Bu dəyişiklikləri təsirli etmək üçün oynadıcını yenidən başlatmaq tələb olunur</string>
<string name="use_exoplayer_decoder_fallback_title">ExoPlayer-in çözücü xüsusiyyətin istifadə et</string>
<string name="use_exoplayer_decoder_fallback_summary">Əsas çözücüləri işlətmə uğursuz olarsa, çözücü işlətmək probleminiz varsa (daha aşağı prioritetli çözücülərə düşür), bu seçimi aktiv edin. Bu, əsas çözücülərdən istifadə ilə müqayisədə zəif oynatma performansı ilə nəticələnə bilər</string>
<string name="always_use_exoplayer_set_output_surface_workaround_summary">Bu həll yolu səthi kodlayıcıya birbaşa tənzimləmək əvəzinə, səth dəyişikliyi olarsa video kodlayıcıları buraxır və yenidən işlədir. Artıq ExoPlayer vasitəsilə bu problemli bəzi cihazlarda istifadə olunur, bu tənzimləmə təsiri yalnız Android 6 və daha yüksəkdə var.\n\nBu seçimi işlətmə cari video oynadıcı dəyişəndə və ya tam ekrana keçəndə oynatma xətaların düzəldə bilər</string>
<string name="always_use_exoplayer_set_output_surface_workaround_summary">Bu həll yolu səthi kodlayıcıya birbaşa tənzimləmək əvəzinə, səth dəyişikliyi olarsa video kodlayıcıları buraxır və yenidən işlədir. Artıq ExoPlayer tərəfindən bu problemi olan bəzi cihazlarda istifadə olunur, bu tənzimləmənin təsiri yalnız Android 6 və daha yüksəkdə var.
\n
\nBu seçimi aktivləşdirmə cari video oynadıcı dəyişdiriləndə və ya tam ekrana keçəndə oynatma xətalarının qarşısını ala bilər</string>
<string name="audio_track_type_original">orijinal</string>
<string name="audio_track_type_dubbed">dublyaj edilib</string>
<string name="audio_track_type_descriptive">təsviri</string>
@@ -733,7 +756,7 @@
<string name="progressive_load_interval_summary">Qabaqcıl məzmunda yükləmə aralığı həcmin dəyişdir (hazırda %s). Daha aşağı dəyər onların ilkin yüklənilməsin sürətləndirə bilər</string>
<string name="no_streams">Yayım yoxdur</string>
<string name="no_live_streams">Canlı yayım yoxdur</string>
<string name="disable_media_tunneling_automatic_info">Media dövrələmə cihazınızda standart olaraq qeyri-aktiv edilib, çünki cihazınızın modelinin bunu dəstəkləmədiyi məlumdur.</string>
<string name="disable_media_tunneling_automatic_info">Media tunelləmə cihazınızda standart olaraq qeyri-aktiv edilib, çünki cihazınızın modelinin bunu dəstəkləmədiyi məlumdur.</string>
<string name="audio_track_name">%1$s %2$s</string>
<string name="main_tabs_position_title">Əsas səhifə mövqeyi</string>
<string name="main_tabs_position_summary">Əsas səhifə seçicini aşağıya köçür</string>
@@ -741,64 +764,8 @@
<string name="feed_fetch_channel_tabs">Kanal səhifələrin əldə et</string>
<string name="metadata_avatars">Avatarlar</string>
<string name="metadata_subchannel_avatars">Alt kanal avatarları</string>
<string name="feed_fetch_channel_tabs_summary">Axın yenilənərkən əldə edilən səhifələr. Kanal sürətli rejim istifadə edərək yenilənirsə, bu seçimin heç bir təsiri yoxdur.</string>
<string name="feed_fetch_channel_tabs_summary">Axın yenilənərkən əldə edilən səhifələr.Kanal sürətli rejim istifadə edərək yenilənirsə, bu seçimin heç bir təsiri yoxdur.</string>
<string name="metadata_uploader_avatars">Yükləyici avatarları</string>
<string name="metadata_thumbnails">Miniatürlər</string>
<string name="notification_actions_summary_android13">Aşağıdakı hər bildiriş fəaliyyətin ona toxunub redaktə edin. İlk üç fəaliyyət (oynatma/fasilə, əvvəlki və növbəti) sistem tərəfindən tənzimlənib və dəyişdirilə bilməz.</string>
<string name="no">Xeyr</string>
<string name="settings_category_backup_restore_title">Nüsxələ və bərpa et</string>
<string name="auto_update_check_description">NewPipe arabir avtomatik şəkildə yeni versiyaları yoxlaya və onlar mövcud olduqda sizə bildirə bilər.
\nBunu aktivləşdirmək istəyirsiniz?</string>
<string name="yes">Bəli</string>
<string name="reset_settings_title">Tənzimləmələri sıfırla</string>
<string name="reset_settings_summary">Bütün tənzimləmələri standart dəyərlərinə sıfırla</string>
<string name="error_insufficient_storage">Cihazda yetərlik boş yer yoxdur</string>
<string name="metadata_subscribers">Abunəliklər</string>
<string name="rewind">Geri çevir</string>
<string name="toggle_screen_orientation">Ekran səmtini dəyişin</string>
<string name="import_settings_vulnerable_format">İdxal edilən ixracdakı tənzimləmələr NewPipe 0.27.0-dan bəri köhnəlmiş zəif formatı işlədir. İdxal edilən ixracın etibarlı mənbədən olduğuna əmin olun və gələcəkdə yalnız NewPipe 0.27.0 və ya daha yeni versiyadan əldə edilmiş ixracları istifadə etməyə üstünlük verin. Bu zəif formatda tənzimləmələrin idxalına dəstək tezliklə tamamilə silinəcək və sonra NewPipe-in köhnə versiyaları daha yeni versiyalardan ixrac tənzimləmələrin idxal edə bilməyəcək.</string>
<string name="previous_stream">Əvvəlki yayım</string>
<string name="next_stream">Növbəti yayım</string>
<string name="image_quality_none">Şəkilləri yükləmə</string>
<string name="metadata_banners">Lövhələr</string>
<string name="channel_tab_videos">Videolar</string>
<string name="channel_tab_tracks">Axınlar</string>
<string name="channel_tab_channels">Kanallar</string>
<string name="channel_tab_about">Haqqında</string>
<string name="show_channel_tabs">Kanal səhifələri</string>
<string name="channel_tab_playlists">Pleylistlər</string>
<string name="channel_tab_shorts">Shorts</string>
<plurals name="replies">
<item quantity="one">%s cavab</item>
<item quantity="other">%s cavab</item>
</plurals>
<string name="show_channel_tabs_summary">Kanal səhifələrində hansı səhifələr göstərilir</string>
<string name="open_play_queue">Oynatma növbəsin aç</string>
<string name="toggle_fullscreen">Tam ekrana keçid</string>
<string name="play">Oynat</string>
<string name="replay">Təkrar oynat</string>
<string name="more_options">Daha çox seçim</string>
<string name="duration">Müddət</string>
<string name="forward">İrəli çevir</string>
<string name="image_quality_low">Aşağı keyfiyyət</string>
<string name="image_quality_medium">Orta keyfiyyət</string>
<string name="image_quality_high">Yüksək keyfiyyət</string>
<string name="question_mark">\?</string>
<string name="share_playlist">Oynatma siyahısın paylaş</string>
<string name="share_playlist_with_titles">Başlıqlarla paylaşın</string>
<string name="video_details_list_item">- %1$s: %2$s</string>
<string name="share_playlist_content_details">%1$s
\n%2$s</string>
<string name="show_more">Daha çox göstər</string>
<string name="show_less">Daha qısa göstər</string>
<string name="reset_all_settings">Bütün tənzimləmələri sıfırlama üstünlük verdiyiniz seçimlərin hamısın ləğv edəcək və tətbiq yenidən açılacaq.
\n
\n
\nDavam etmək istədiyinizə əminsiniz?</string>
<string name="channel_tab_livestreams">Canlı</string>
<string name="channel_tab_albums">Albomlar</string>
<string name="image_quality_title">Şəkil keyfiyyəti</string>
<string name="image_quality_summary">Məlumat və yaddaş istifadəsini azaltmaq üçün şəkillərin keyfiyyətini və ya şəkillərin əsla yüklənib-yüklənilməməsini seçin. Dəyişikliklər həm yaddaşdaxili, həm də diskdə olan təsvir qalığın təmizləyir — %s</string>
<string name="share_playlist_with_list">URL siyahısını paylaşın</string>
<string name="audio_track_type_secondary">ikinci dərəcəli</string>
</resources>
<string name="notification_actions_summary_android13">Aşağıdakı hər bildirişə vuraraq ona düzəliş edin. İlk üç əməl (oynatma/fasilə, əvvəlki və sonrakı) sistem tərəfindən təyin olunub və dəyişdirilə bilməz.</string>
</resources>

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