mirror of
https://github.com/TeamNewPipe/NewPipe
synced 2025-10-25 12:27:38 +00:00
Merge pull request #12435 from TeamNewPipe/release-0.28.0
This commit is contained in:
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -111,6 +111,7 @@ jobs:
|
||||
path: app/build/reports/androidTests/connected/**
|
||||
|
||||
sonar:
|
||||
if: ${{ false }} # the key has expired and needs to be regenerated by the sonar admins
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
permissions:
|
||||
|
||||
6
.github/workflows/image-minimizer.js
vendored
6
.github/workflows/image-minimizer.js
vendored
@@ -33,11 +33,11 @@ module.exports = async ({github, context}) => {
|
||||
|
||||
// Regex for finding images (simple variant) 
|
||||
const REGEX_USER_CONTENT_IMAGE_LOOKUP = /\!\[([^\]]*)\]\((https:\/\/[-a-z0-9]+\.githubusercontent\.com\/\d+\/[-0-9a-f]{32,512}\.(jpg|gif|png))\)/gm;
|
||||
const REGEX_ASSETS_IMAGE_LOCKUP = /\!\[([^\]]*)\]\((https:\/\/github\.com\/[-\w\d]+\/[-\w\d]+\/assets\/\d+\/[\-0-9a-f]{32,512})\)/gm;
|
||||
const REGEX_ASSETS_IMAGE_LOOKUP = /\!\[([^\]]*)\]\((https:\/\/github\.com\/(?:user-attachments\/assets|[-\w\d]+\/[-\w\d]+\/assets\/\d+)\/[\-0-9a-f]{32,512})\)/gm;
|
||||
|
||||
// Check if we found something
|
||||
let foundSimpleImages = REGEX_USER_CONTENT_IMAGE_LOOKUP.test(initialBody)
|
||||
|| REGEX_ASSETS_IMAGE_LOCKUP.test(initialBody);
|
||||
|| REGEX_ASSETS_IMAGE_LOOKUP.test(initialBody);
|
||||
if (!foundSimpleImages) {
|
||||
console.log('Found no simple images to process');
|
||||
return;
|
||||
@@ -52,7 +52,7 @@ module.exports = async ({github, context}) => {
|
||||
|
||||
// Try to find and replace the images with minimized ones
|
||||
let newBody = await replaceAsync(initialBody, REGEX_USER_CONTENT_IMAGE_LOOKUP, minimizeAsync);
|
||||
newBody = await replaceAsync(newBody, REGEX_ASSETS_IMAGE_LOCKUP, minimizeAsync);
|
||||
newBody = await replaceAsync(newBody, REGEX_ASSETS_IMAGE_LOOKUP, minimizeAsync);
|
||||
|
||||
if (!wasMatchModified) {
|
||||
console.log('Nothing was modified. Skipping update');
|
||||
|
||||
16
README.md
16
README.md
@@ -1,20 +1,26 @@
|
||||
<h3 align="center">We are planning to <i>rewrite</i> large chunks of the codebase, to bring about <a href="https://github.com/TeamNewPipe/NewPipe/discussions/10118">a new, modern and stable NewPipe</a>!</h3>
|
||||
<h4 align="center">Please do <b>not</b> open pull requests for <i>new features</i> now, only bugfix PRs will be accepted.</h4>
|
||||
<h3 align="center">We are <i>rewriting</i> large chunks of the codebase, to bring about <a href="https://newpipe.net/blog/pinned/announcement/newpipe-0.27.6-rewrite-team-states/#the-refactor">a modern and stable NewPipe</a>! You can download nightly builds <a href="https://github.com/TeamNewPipe/NewPipe-refactor-nightly/releases">here</a>.</h3>
|
||||
<h4 align="center">Please work on the <code>refactor</code> branch if you want to contribute <i>new features</i>. The current codebase is in maintenance mode and will only receive <i>bugfixes</i>.</h4>
|
||||
|
||||
<p align="center"><a href="https://newpipe.net"><img src="assets/new_pipe_icon_5.png" width="150"></a></p>
|
||||
<h2 align="center"><b>NewPipe</b></h2>
|
||||
<h4 align="center">A libre lightweight streaming front-end for Android.</h4>
|
||||
|
||||
<p align="center"><a href="https://f-droid.org/packages/org.schabi.newpipe/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on-en.svg" alt="Get it on F-Droid" height=80/></a></p>
|
||||
<p align="center"><a href="https://f-droid.org/packages/org.schabi.newpipe/"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on-en.svg" alt="Get it on F-Droid" width=206/></a></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe/releases" alt="GitHub release"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe.svg" ></a>
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe/releases" alt="GitHub NewPipe releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe.svg" ></a>
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe-nightly/releases" alt="GitHub NewPipe nightly releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe-nightly.svg?labelColor=purple&label=dev%20nightly"></a>
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe-refactor-nightly/releases" alt="GitHub NewPipe refactor nightly releases"><img src="https://img.shields.io/github/release/TeamNewPipe/NewPipe-refactor-nightly.svg?labelColor=purple&label=refactor%20nightly"></a>
|
||||
<a href="https://www.gnu.org/licenses/gpl-3.0" alt="License: GPLv3"><img src="https://img.shields.io/badge/License-GPL%20v3-blue.svg"></a>
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe/actions" alt="Build Status"><img src="https://github.com/TeamNewPipe/NewPipe/workflows/CI/badge.svg?branch=dev&event=push"></a>
|
||||
<a href="https://github.com/TeamNewPipe/NewPipe/actions" alt="Build Status"><img src="https://github.com/TeamNewPipe/NewPipe/actions/workflows/ci.yml/badge.svg?branch=dev&event=push"></a>
|
||||
<a href="https://hosted.weblate.org/engage/newpipe/" alt="Translation Status"><img src="https://hosted.weblate.org/widgets/newpipe/-/svg-badge.svg"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://web.libera.chat/#newpipe" alt="IRC channel: #newpipe"><img src="https://img.shields.io/badge/IRC%20chat-%23newpipe-brightgreen.svg"></a>
|
||||
<a href="https://matrix.to/#/#newpipe:matrix.newpipe-ev.de" alt="Matrix channel: #newpipe"><img src="https://img.shields.io/badge/Matrix%20chat-%23newpipe-blue"></a>
|
||||
</p>
|
||||
|
||||
<hr>
|
||||
<p align="center"><a href="#screenshots">Screenshots</a> • <a href="#supported-services">Supported Services</a> • <a href="#description">Description</a> • <a href="#features">Features</a> • <a href="#installation-and-updates">Installation and updates</a> • <a href="#contribution">Contribution</a> • <a href="#donate">Donate</a> • <a href="#license">License</a></p>
|
||||
<p align="center"><a href="https://newpipe.net">Website</a> • <a href="https://newpipe.net/blog/">Blog</a> • <a href="https://newpipe.net/FAQ/">FAQ</a> • <a href="https://newpipe.net/press/">Press</a></p>
|
||||
|
||||
@@ -23,9 +23,9 @@ android {
|
||||
if (System.properties.containsKey('versionCodeOverride')) {
|
||||
versionCode System.getProperty('versionCodeOverride') as Integer
|
||||
} else {
|
||||
versionCode 1004
|
||||
versionCode 1005
|
||||
}
|
||||
versionName "0.27.7"
|
||||
versionName "0.28.0"
|
||||
if (System.properties.containsKey('versionNameSuffix')) {
|
||||
versionNameSuffix System.getProperty('versionNameSuffix')
|
||||
}
|
||||
@@ -97,6 +97,10 @@ android {
|
||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
buildConfig true
|
||||
@@ -205,10 +209,12 @@ dependencies {
|
||||
// Or you can use a commit you pushed to GitHub by just replacing TeamNewPipe with your GitHub
|
||||
// name and the commit hash with the commit hash of the (pushed) commit you want to test
|
||||
// This works thanks to JitPack: https://jitpack.io/
|
||||
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
|
||||
implementation 'com.github.TeamNewPipe:nanojson:e9d656ddb49a412a5a0a5d5ef20ca7ef09549996'
|
||||
// WORKAROUND: if you get errors with the NewPipeExtractor dependency, replace `v0.24.3` with
|
||||
// the corresponding commit hash, since JitPack is sometimes buggy
|
||||
implementation 'com.github.TeamNewPipe.NewPipeExtractor:NewPipeExtractor:v0.24.6'
|
||||
// the corresponding commit hash, since JitPack sometimes deletes artifacts.
|
||||
// If there’s already a git hash, just add more of it to the end (or remove a letter)
|
||||
// to cause jitpack to regenerate the artifact.
|
||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.24.8'
|
||||
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
|
||||
|
||||
/** Checkstyle **/
|
||||
@@ -219,7 +225,7 @@ dependencies {
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib:${kotlin_version}"
|
||||
|
||||
/** AndroidX **/
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'androidx.appcompat:appcompat:1.7.1'
|
||||
implementation 'androidx.cardview:cardview:1.0.0'
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
|
||||
implementation 'androidx.core:core-ktx:1.12.0'
|
||||
|
||||
@@ -57,6 +57,15 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
|
||||
android:enabled="false"
|
||||
android:exported="false">
|
||||
<meta-data
|
||||
android:name="autoStoreLocales"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".player.PlayerService"
|
||||
android:exported="true"
|
||||
@@ -64,6 +73,9 @@
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<activity
|
||||
@@ -424,5 +436,10 @@
|
||||
<meta-data
|
||||
android:name="com.samsung.android.multidisplay.keep_process_alive"
|
||||
android:value="true" />
|
||||
<!-- Android Auto -->
|
||||
<meta-data android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc" />
|
||||
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
|
||||
android:resource="@mipmap/ic_launcher" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -102,7 +102,7 @@ public class App extends Application {
|
||||
NewPipe.init(getDownloader(),
|
||||
Localization.getPreferredLocalization(this),
|
||||
Localization.getPreferredContentCountry(this));
|
||||
Localization.initPrettyTime(Localization.resolvePrettyTime(getApplicationContext()));
|
||||
Localization.initPrettyTime(Localization.resolvePrettyTime());
|
||||
|
||||
BridgeStateSaverInitializer.init(this);
|
||||
StateSaver.init(this);
|
||||
|
||||
@@ -29,7 +29,7 @@ import okhttp3.ResponseBody;
|
||||
|
||||
public final class DownloaderImpl extends Downloader {
|
||||
public static final String USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:128.0) Gecko/20100101 Firefox/128.0";
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:140.0) Gecko/20100101 Firefox/140.0";
|
||||
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE_KEY =
|
||||
"youtube_restricted_mode_key";
|
||||
public static final String YOUTUBE_RESTRICTED_MODE_COOKIE = "PREF=f2=8000000";
|
||||
|
||||
@@ -20,8 +20,6 @@
|
||||
|
||||
package org.schabi.newpipe;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
@@ -38,6 +36,7 @@ import android.view.Menu;
|
||||
import android.view.MenuItem;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.webkit.WebView;
|
||||
import android.widget.AdapterView;
|
||||
import android.widget.ArrayAdapter;
|
||||
import android.widget.FrameLayout;
|
||||
@@ -80,6 +79,7 @@ import org.schabi.newpipe.player.event.OnKeyDownListener;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.settings.UpdateSettingsFragment;
|
||||
import org.schabi.newpipe.settings.migration.MigrationManager;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.KioskTranslator;
|
||||
@@ -125,7 +125,10 @@ public class MainActivity extends AppCompatActivity {
|
||||
private static final int ITEM_ID_ABOUT = 2;
|
||||
|
||||
private static final int ORDER = 0;
|
||||
public static final String KEY_IS_IN_BACKGROUND = "is_in_background";
|
||||
|
||||
private SharedPreferences sharedPreferences;
|
||||
private SharedPreferences.Editor sharedPrefEditor;
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Activity's LifeCycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@@ -137,11 +140,26 @@ public class MainActivity extends AppCompatActivity {
|
||||
+ "savedInstanceState = [" + savedInstanceState + "]");
|
||||
}
|
||||
|
||||
Localization.migrateAppLanguageSettingIfNecessary(getApplicationContext());
|
||||
ThemeHelper.setDayNightMode(this);
|
||||
ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this));
|
||||
|
||||
assureCorrectAppLanguage(this);
|
||||
// Fixes text color turning black in dark/black mode:
|
||||
// https://github.com/TeamNewPipe/NewPipe/issues/12016
|
||||
// For further reference see: https://issuetracker.google.com/issues/37124582
|
||||
if (DeviceUtils.supportsWebView()) {
|
||||
try {
|
||||
new WebView(this);
|
||||
} catch (final Throwable e) {
|
||||
if (DEBUG) {
|
||||
Log.e(TAG, "Failed to create WebView", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this);
|
||||
sharedPrefEditor = sharedPreferences.edit();
|
||||
|
||||
mainBinding = ActivityMainBinding.inflate(getLayoutInflater());
|
||||
drawerLayoutBinding = mainBinding.drawerLayout;
|
||||
@@ -176,6 +194,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
&& ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
|
||||
UpdateSettingsFragment.askForConsentToUpdateChecks(this);
|
||||
}
|
||||
|
||||
MigrationManager.showUserInfoIfPresent(this);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -183,16 +203,29 @@ public class MainActivity extends AppCompatActivity {
|
||||
super.onPostCreate(savedInstanceState);
|
||||
|
||||
final App app = App.getApp();
|
||||
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
|
||||
|
||||
if (prefs.getBoolean(app.getString(R.string.update_app_key), false)
|
||||
&& prefs.getBoolean(app.getString(R.string.update_check_consent_key), false)) {
|
||||
if (sharedPreferences.getBoolean(app.getString(R.string.update_app_key), false)
|
||||
&& sharedPreferences
|
||||
.getBoolean(app.getString(R.string.update_check_consent_key), false)) {
|
||||
// Start the worker which is checking all conditions
|
||||
// and eventually searching for a new version.
|
||||
NewVersionWorker.enqueueNewVersionCheckingWork(app, false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStart() {
|
||||
super.onStart();
|
||||
sharedPrefEditor.putBoolean(KEY_IS_IN_BACKGROUND, false).apply();
|
||||
Log.d(TAG, "App moved to foreground");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onStop() {
|
||||
super.onStop();
|
||||
sharedPrefEditor.putBoolean(KEY_IS_IN_BACKGROUND, true).apply();
|
||||
Log.d(TAG, "App moved to background");
|
||||
}
|
||||
private void setupDrawer() throws ExtractionException {
|
||||
addDrawerMenuForCurrentService();
|
||||
|
||||
@@ -230,19 +263,6 @@ public class MainActivity extends AppCompatActivity {
|
||||
*/
|
||||
private void addDrawerMenuForCurrentService() throws ExtractionException {
|
||||
//Tabs
|
||||
final int currentServiceId = ServiceHelper.getSelectedServiceId(this);
|
||||
final StreamingService service = NewPipe.getService(currentServiceId);
|
||||
|
||||
int kioskMenuItemId = 0;
|
||||
|
||||
for (final String ks : service.getKioskList().getAvailableKiosks()) {
|
||||
drawerLayoutBinding.navigation.getMenu()
|
||||
.add(R.id.menu_tabs_group, kioskMenuItemId, 0, KioskTranslator
|
||||
.getTranslatedKioskName(ks, this))
|
||||
.setIcon(KioskTranslator.getKioskIcon(ks));
|
||||
kioskMenuItemId++;
|
||||
}
|
||||
|
||||
drawerLayoutBinding.navigation.getMenu()
|
||||
.add(R.id.menu_tabs_group, ITEM_ID_SUBSCRIPTIONS, ORDER,
|
||||
R.string.tab_subscriptions)
|
||||
@@ -260,6 +280,20 @@ public class MainActivity extends AppCompatActivity {
|
||||
.add(R.id.menu_tabs_group, ITEM_ID_HISTORY, ORDER, R.string.action_history)
|
||||
.setIcon(R.drawable.ic_history);
|
||||
|
||||
//Kiosks
|
||||
final int currentServiceId = ServiceHelper.getSelectedServiceId(this);
|
||||
final StreamingService service = NewPipe.getService(currentServiceId);
|
||||
|
||||
int kioskMenuItemId = 0;
|
||||
|
||||
for (final String ks : service.getKioskList().getAvailableKiosks()) {
|
||||
drawerLayoutBinding.navigation.getMenu()
|
||||
.add(R.id.menu_kiosks_group, kioskMenuItemId, 0, KioskTranslator
|
||||
.getTranslatedKioskName(ks, this))
|
||||
.setIcon(KioskTranslator.getKioskIcon(ks));
|
||||
kioskMenuItemId++;
|
||||
}
|
||||
|
||||
//Settings and About
|
||||
drawerLayoutBinding.navigation.getMenu()
|
||||
.add(R.id.menu_options_about_group, ITEM_ID_SETTINGS, ORDER, R.string.settings)
|
||||
@@ -279,10 +313,13 @@ public class MainActivity extends AppCompatActivity {
|
||||
changeService(item);
|
||||
break;
|
||||
case R.id.menu_tabs_group:
|
||||
tabSelected(item);
|
||||
break;
|
||||
case R.id.menu_kiosks_group:
|
||||
try {
|
||||
tabSelected(item);
|
||||
kioskSelected(item);
|
||||
} catch (final Exception e) {
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Selecting main page tab", e);
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Selecting drawer kiosk", e);
|
||||
}
|
||||
break;
|
||||
case R.id.menu_options_about_group:
|
||||
@@ -306,7 +343,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
.setChecked(true);
|
||||
}
|
||||
|
||||
private void tabSelected(final MenuItem item) throws ExtractionException {
|
||||
private void tabSelected(final MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case ITEM_ID_SUBSCRIPTIONS:
|
||||
NavigationHelper.openSubscriptionFragment(getSupportFragmentManager());
|
||||
@@ -323,18 +360,19 @@ public class MainActivity extends AppCompatActivity {
|
||||
case ITEM_ID_HISTORY:
|
||||
NavigationHelper.openStatisticFragment(getSupportFragmentManager());
|
||||
break;
|
||||
default:
|
||||
final StreamingService currentService = ServiceHelper.getSelectedService(this);
|
||||
int kioskMenuItemId = 0;
|
||||
for (final String kioskId : currentService.getKioskList().getAvailableKiosks()) {
|
||||
if (kioskMenuItemId == item.getItemId()) {
|
||||
NavigationHelper.openKioskFragment(getSupportFragmentManager(),
|
||||
currentService.getServiceId(), kioskId);
|
||||
break;
|
||||
}
|
||||
kioskMenuItemId++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void kioskSelected(final MenuItem item) throws ExtractionException {
|
||||
final StreamingService currentService = ServiceHelper.getSelectedService(this);
|
||||
int kioskMenuItemId = 0;
|
||||
for (final String kioskId : currentService.getKioskList().getAvailableKiosks()) {
|
||||
if (kioskMenuItemId == item.getItemId()) {
|
||||
NavigationHelper.openKioskFragment(getSupportFragmentManager(),
|
||||
currentService.getServiceId(), kioskId);
|
||||
break;
|
||||
}
|
||||
kioskMenuItemId++;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,6 +413,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_services_group);
|
||||
drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_tabs_group);
|
||||
drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_kiosks_group);
|
||||
drawerLayoutBinding.navigation.getMenu().removeGroup(R.id.menu_options_about_group);
|
||||
|
||||
// Show up or down arrow
|
||||
@@ -468,9 +507,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
protected void onResume() {
|
||||
assureCorrectAppLanguage(this);
|
||||
// Change the date format to match the selected language on resume
|
||||
Localization.initPrettyTime(Localization.resolvePrettyTime(getApplicationContext()));
|
||||
Localization.initPrettyTime(Localization.resolvePrettyTime());
|
||||
super.onResume();
|
||||
|
||||
// Close drawer on return, and don't show animation,
|
||||
@@ -492,13 +530,11 @@ public class MainActivity extends AppCompatActivity {
|
||||
ErrorUtil.showUiErrorSnackbar(this, "Setting up service toggle", e);
|
||||
}
|
||||
|
||||
final SharedPreferences sharedPreferences =
|
||||
PreferenceManager.getDefaultSharedPreferences(this);
|
||||
if (sharedPreferences.getBoolean(Constants.KEY_THEME_CHANGE, false)) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "Theme has changed, recreating activity...");
|
||||
}
|
||||
sharedPreferences.edit().putBoolean(Constants.KEY_THEME_CHANGE, false).apply();
|
||||
sharedPrefEditor.putBoolean(Constants.KEY_THEME_CHANGE, false).apply();
|
||||
ActivityCompat.recreate(this);
|
||||
}
|
||||
|
||||
@@ -506,7 +542,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "main page has changed, recreating main fragment...");
|
||||
}
|
||||
sharedPreferences.edit().putBoolean(Constants.KEY_MAIN_PAGE_CHANGE, false).apply();
|
||||
sharedPrefEditor.putBoolean(Constants.KEY_MAIN_PAGE_CHANGE, false).apply();
|
||||
NavigationHelper.openMainActivity(this);
|
||||
}
|
||||
|
||||
@@ -848,7 +884,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
@Override
|
||||
public void onReceive(final Context context, final Intent intent) {
|
||||
if (Objects.equals(intent.getAction(),
|
||||
VideoDetailFragment.ACTION_PLAYER_STARTED)) {
|
||||
VideoDetailFragment.ACTION_PLAYER_STARTED)
|
||||
&& PlayerHolder.getInstance().isPlayerOpen()) {
|
||||
openMiniPlayerIfMissing();
|
||||
// At this point the player is added 100%, we can unregister. Other actions
|
||||
// are useless since the fragment will not be removed after that.
|
||||
@@ -860,6 +897,10 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,6 @@ import org.schabi.newpipe.util.ChannelTabHelper;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.PermissionHelper;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
@@ -132,7 +131,6 @@ public class RouterActivity extends AppCompatActivity {
|
||||
ThemeHelper.setDayNightMode(this);
|
||||
setTheme(ThemeHelper.isLightThemeSelected(this)
|
||||
? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark);
|
||||
Localization.assureCorrectAppLanguage(this);
|
||||
|
||||
// Pass-through touch events to background activities
|
||||
// so that our transparent window won't lock UI in the mean time
|
||||
|
||||
@@ -16,14 +16,12 @@ import org.schabi.newpipe.BuildConfig
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.databinding.ActivityAboutBinding
|
||||
import org.schabi.newpipe.databinding.FragmentAboutBinding
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.ThemeHelper
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
|
||||
class AboutActivity : AppCompatActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
Localization.assureCorrectAppLanguage(this)
|
||||
super.onCreate(savedInstanceState)
|
||||
ThemeHelper.setTheme(this)
|
||||
title = getString(R.string.title_activity_about)
|
||||
|
||||
@@ -19,7 +19,6 @@ import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.databinding.FragmentLicensesBinding
|
||||
import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding
|
||||
import org.schabi.newpipe.ktx.parcelableArrayList
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||
|
||||
/**
|
||||
@@ -100,7 +99,6 @@ class LicenseFragment : Fragment() {
|
||||
val webView = WebView(context)
|
||||
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
|
||||
|
||||
Localization.assureCorrectAppLanguage(context)
|
||||
val builder = AlertDialog.Builder(requireContext())
|
||||
.setTitle(softwareComponent.name)
|
||||
.setView(webView)
|
||||
|
||||
@@ -3,6 +3,8 @@ package org.schabi.newpipe.database.history.model
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Embedded
|
||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.util.image.ImageStrategy
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
data class StreamHistoryEntry(
|
||||
@@ -27,4 +29,17 @@ data class StreamHistoryEntry(
|
||||
return this.streamEntity.uid == other.streamEntity.uid && streamId == other.streamId &&
|
||||
accessDate.isEqual(other.accessDate)
|
||||
}
|
||||
|
||||
fun toStreamInfoItem(): StreamInfoItem =
|
||||
StreamInfoItem(
|
||||
streamEntity.serviceId,
|
||||
streamEntity.url,
|
||||
streamEntity.title,
|
||||
streamEntity.streamType,
|
||||
).apply {
|
||||
duration = streamEntity.duration
|
||||
uploaderName = streamEntity.uploader
|
||||
uploaderUrl = streamEntity.uploaderUrl
|
||||
thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package org.schabi.newpipe.database.playlist;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.schabi.newpipe.database.LocalItem;
|
||||
|
||||
public interface PlaylistLocalItem extends LocalItem {
|
||||
@@ -10,4 +12,7 @@ public interface PlaylistLocalItem extends LocalItem {
|
||||
long getUid();
|
||||
|
||||
void setDisplayIndex(long displayIndex);
|
||||
|
||||
@Nullable
|
||||
String getThumbnailUrl();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID;
|
||||
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
public class PlaylistMetadataEntry implements PlaylistLocalItem {
|
||||
public static final String PLAYLIST_STREAM_COUNT = "streamCount";
|
||||
|
||||
@@ -71,4 +73,10 @@ public class PlaylistMetadataEntry implements PlaylistLocalItem {
|
||||
public void setDisplayIndex(final long displayIndex) {
|
||||
this.displayIndex = displayIndex;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public String getThumbnailUrl() {
|
||||
return thumbnailUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ public interface PlaylistRemoteDAO extends BasicDAO<PlaylistRemoteEntity> {
|
||||
|
||||
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
|
||||
+ REMOTE_PLAYLIST_ID + " = :playlistId")
|
||||
Flowable<List<PlaylistRemoteEntity>> getPlaylist(long playlistId);
|
||||
Flowable<PlaylistRemoteEntity> getPlaylist(long playlistId);
|
||||
|
||||
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
|
||||
+ REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
|
||||
|
||||
@@ -2,6 +2,7 @@ package org.schabi.newpipe.database.playlist.model;
|
||||
|
||||
import android.text.TextUtils;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.room.ColumnInfo;
|
||||
import androidx.room.Entity;
|
||||
import androidx.room.Ignore;
|
||||
@@ -134,6 +135,8 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public String getThumbnailUrl() {
|
||||
return thumbnailUrl;
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ import org.schabi.newpipe.views.FocusOverlayView;
|
||||
import us.shandian.giga.service.DownloadManagerService;
|
||||
import us.shandian.giga.ui.fragment.MissionsFragment;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
public class DownloadActivity extends AppCompatActivity {
|
||||
|
||||
private static final String MISSIONS_FRAGMENT_TAG = "fragment_tag";
|
||||
@@ -33,7 +31,6 @@ public class DownloadActivity extends AppCompatActivity {
|
||||
i.setClass(this, DownloadManagerService.class);
|
||||
startService(i);
|
||||
|
||||
assureCorrectAppLanguage(this);
|
||||
ThemeHelper.setTheme(this);
|
||||
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.schabi.newpipe.download;
|
||||
|
||||
import static org.schabi.newpipe.extractor.stream.DeliveryMethod.PROGRESSIVE_HTTP;
|
||||
import static org.schabi.newpipe.util.ListHelper.getStreamsOfSpecifiedDelivery;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ComponentName;
|
||||
@@ -751,7 +750,6 @@ public class DownloadDialog extends DialogFragment
|
||||
}
|
||||
|
||||
private void showFailedDialog(@StringRes final int msg) {
|
||||
assureCorrectAppLanguage(requireContext());
|
||||
new AlertDialog.Builder(context)
|
||||
.setTitle(R.string.general_error)
|
||||
.setMessage(msg)
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package org.schabi.newpipe.error;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
@@ -79,7 +77,6 @@ public class ErrorActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
protected void onCreate(final Bundle savedInstanceState) {
|
||||
assureCorrectAppLanguage(this);
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
ThemeHelper.setDayNightMode(this);
|
||||
@@ -306,7 +303,7 @@ public class ErrorActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
private String getAppLanguage() {
|
||||
return Localization.getAppLocale(getApplicationContext()).toString();
|
||||
return Localization.getAppLocale().toString();
|
||||
}
|
||||
|
||||
private String getOsString() {
|
||||
|
||||
@@ -35,7 +35,7 @@ import java.util.concurrent.TimeUnit
|
||||
class ErrorPanelHelper(
|
||||
private val fragment: Fragment,
|
||||
rootView: View,
|
||||
onRetry: Runnable
|
||||
onRetry: Runnable?,
|
||||
) {
|
||||
private val context: Context = rootView.context!!
|
||||
|
||||
@@ -56,12 +56,15 @@ class ErrorPanelHelper(
|
||||
errorPanelRoot.findViewById(R.id.error_open_in_browser)
|
||||
|
||||
private var errorDisposable: Disposable? = null
|
||||
private var retryShouldBeShown: Boolean = (onRetry != null)
|
||||
|
||||
init {
|
||||
errorDisposable = errorRetryButton.clicks()
|
||||
.debounce(300, TimeUnit.MILLISECONDS)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { onRetry.run() }
|
||||
if (onRetry != null) {
|
||||
errorDisposable = errorRetryButton.clicks()
|
||||
.debounce(300, TimeUnit.MILLISECONDS)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe { onRetry.run() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureDefaultVisibility() {
|
||||
@@ -101,7 +104,7 @@ class ErrorPanelHelper(
|
||||
errorActionButton.setOnClickListener(null)
|
||||
}
|
||||
|
||||
errorRetryButton.isVisible = true
|
||||
errorRetryButton.isVisible = retryShouldBeShown
|
||||
showAndSetOpenInBrowserButtonAction(errorInfo)
|
||||
} else if (errorInfo.throwable is AccountTerminatedException) {
|
||||
errorTextView.setText(R.string.account_terminated)
|
||||
@@ -130,7 +133,7 @@ class ErrorPanelHelper(
|
||||
errorInfo.throwable !is ContentNotSupportedException
|
||||
) {
|
||||
// show retry button only for content which is not unavailable or unsupported
|
||||
errorRetryButton.isVisible = true
|
||||
errorRetryButton.isVisible = retryShouldBeShown
|
||||
}
|
||||
showAndSetOpenInBrowserButtonAction(errorInfo)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.preference.PreferenceManager
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import org.schabi.newpipe.MainActivity
|
||||
import org.schabi.newpipe.R
|
||||
|
||||
/**
|
||||
@@ -35,12 +37,20 @@ class ErrorUtil {
|
||||
* activity (since the workflow would be interrupted anyway in that case). So never use this
|
||||
* for background services.
|
||||
*
|
||||
* If the crashed occurred while the app was in the background open a notification instead
|
||||
*
|
||||
* @param context the context to use to start the new activity
|
||||
* @param errorInfo the error info to be reported
|
||||
*/
|
||||
@JvmStatic
|
||||
fun openActivity(context: Context, errorInfo: ErrorInfo) {
|
||||
context.startActivity(getErrorActivityIntent(context, errorInfo))
|
||||
if (PreferenceManager.getDefaultSharedPreferences(context)
|
||||
.getBoolean(MainActivity.KEY_IS_IN_BACKGROUND, true)
|
||||
) {
|
||||
createNotification(context, errorInfo)
|
||||
} else {
|
||||
context.startActivity(getErrorActivityIntent(context, errorInfo))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -32,7 +32,8 @@ public enum UserAction {
|
||||
PREFERENCES_MIGRATION("migration of preferences"),
|
||||
SHARE_TO_NEWPIPE("share to newpipe"),
|
||||
CHECK_FOR_NEW_APP_VERSION("check for new app version"),
|
||||
OPEN_INFO_ITEM_DIALOG("open info item dialog");
|
||||
OPEN_INFO_ITEM_DIALOG("open info item dialog"),
|
||||
GETTING_MAIN_SCREEN_TAB("getting main screen tab");
|
||||
|
||||
private final String message;
|
||||
|
||||
|
||||
@@ -7,16 +7,57 @@ import android.view.ViewGroup;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.evernote.android.state.State;
|
||||
|
||||
import org.schabi.newpipe.BaseFragment;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorPanelHelper;
|
||||
|
||||
public class BlankFragment extends BaseFragment {
|
||||
|
||||
@State
|
||||
@Nullable
|
||||
ErrorInfo errorInfo;
|
||||
@Nullable
|
||||
ErrorPanelHelper errorPanel = null;
|
||||
|
||||
/**
|
||||
* Builds a blank fragment that just says the app name and suggests clicking on search.
|
||||
*/
|
||||
public BlankFragment() {
|
||||
this(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param errorInfo if null acts like {@link BlankFragment}, else shows an error panel.
|
||||
*/
|
||||
public BlankFragment(@Nullable final ErrorInfo errorInfo) {
|
||||
this.errorInfo = errorInfo;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public View onCreateView(final LayoutInflater inflater, @Nullable final ViewGroup container,
|
||||
final Bundle savedInstanceState) {
|
||||
setTitle("NewPipe");
|
||||
return inflater.inflate(R.layout.fragment_blank, container, false);
|
||||
final View view = inflater.inflate(R.layout.fragment_blank, container, false);
|
||||
if (errorInfo != null) {
|
||||
errorPanel = new ErrorPanelHelper(this, view, null);
|
||||
errorPanel.showError(errorInfo);
|
||||
view.findViewById(R.id.blank_page_content).setVisibility(View.GONE);
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
|
||||
if (errorPanel != null) {
|
||||
errorPanel.dispose();
|
||||
errorPanel = null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -36,8 +36,9 @@ import com.google.android.material.tabs.TabLayout;
|
||||
import org.schabi.newpipe.BaseFragment;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.databinding.FragmentMainBinding;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
|
||||
import org.schabi.newpipe.settings.tabs.Tab;
|
||||
import org.schabi.newpipe.settings.tabs.TabsManager;
|
||||
@@ -303,9 +304,9 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
|
||||
final Fragment fragment;
|
||||
try {
|
||||
fragment = tab.getFragment(context);
|
||||
} catch (final ExtractionException e) {
|
||||
ErrorUtil.showUiErrorSnackbar(context, "Getting fragment item", e);
|
||||
return new BlankFragment();
|
||||
} catch (final Throwable t) {
|
||||
return new BlankFragment(new ErrorInfo(t, UserAction.GETTING_MAIN_SCREEN_TAB,
|
||||
"Tab " + tab.getClass().getSimpleName() + ":" + tab.getTabName(context)));
|
||||
}
|
||||
|
||||
if (fragment instanceof BaseFragment) {
|
||||
|
||||
@@ -93,7 +93,7 @@ public class DescriptionFragment extends BaseDescriptionFragment {
|
||||
|
||||
if (streamInfo.getLanguageInfo() != null) {
|
||||
addMetadataItem(inflater, layout, false, R.string.metadata_language,
|
||||
streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale(getContext())));
|
||||
streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale()));
|
||||
}
|
||||
|
||||
addMetadataItem(inflater, layout, true, R.string.metadata_support,
|
||||
|
||||
@@ -236,11 +236,14 @@ public final class VideoDetailFragment
|
||||
// Service management
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@Override
|
||||
public void onServiceConnected(final Player connectedPlayer,
|
||||
final PlayerService connectedPlayerService,
|
||||
final boolean playAfterConnect) {
|
||||
player = connectedPlayer;
|
||||
public void onServiceConnected(@NonNull final PlayerService connectedPlayerService) {
|
||||
playerService = connectedPlayerService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerConnected(@NonNull final Player connectedPlayer,
|
||||
final boolean playAfterConnect) {
|
||||
player = connectedPlayer;
|
||||
|
||||
// It will do nothing if the player is not in fullscreen mode
|
||||
hideSystemUiIfNeeded();
|
||||
@@ -272,11 +275,18 @@ public final class VideoDetailFragment
|
||||
updateOverlayPlayQueueButtonVisibility();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPlayerDisconnected() {
|
||||
player = null;
|
||||
// the binding could be null at this point, if the app is finishing
|
||||
if (binding != null) {
|
||||
restoreDefaultBrightness();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected() {
|
||||
playerService = null;
|
||||
player = null;
|
||||
restoreDefaultBrightness();
|
||||
}
|
||||
|
||||
|
||||
@@ -1848,13 +1858,16 @@ public final class VideoDetailFragment
|
||||
|
||||
@Override
|
||||
public void onServiceStopped() {
|
||||
setOverlayPlayPauseImage(false);
|
||||
if (currentInfo != null) {
|
||||
updateOverlayData(currentInfo.getName(),
|
||||
currentInfo.getUploaderName(),
|
||||
currentInfo.getThumbnails());
|
||||
// the binding could be null at this point, if the app is finishing
|
||||
if (binding != null) {
|
||||
setOverlayPlayPauseImage(false);
|
||||
if (currentInfo != null) {
|
||||
updateOverlayData(currentInfo.getName(),
|
||||
currentInfo.getUploaderName(),
|
||||
currentInfo.getThumbnails());
|
||||
}
|
||||
updateOverlayPlayQueueButtonVisibility();
|
||||
}
|
||||
updateOverlayPlayQueueButtonVisibility();
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -8,6 +8,7 @@ import android.util.Log;
|
||||
import android.view.View;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.evernote.android.state.State;
|
||||
|
||||
@@ -42,6 +43,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
|
||||
|
||||
private final UserAction errorUserAction;
|
||||
protected L currentInfo;
|
||||
@Nullable
|
||||
protected Page currentNextPage;
|
||||
protected Disposable currentWorker;
|
||||
|
||||
|
||||
@@ -81,9 +81,7 @@ public class ChannelAboutFragment extends BaseDescriptionFragment {
|
||||
|
||||
if (channelInfo.getSubscriberCount() != UNKNOWN_SUBSCRIBER_COUNT) {
|
||||
addMetadataItem(inflater, layout, false, R.string.metadata_subscribers,
|
||||
Localization.localizeNumber(
|
||||
requireContext(),
|
||||
channelInfo.getSubscriberCount()));
|
||||
Localization.localizeNumber(channelInfo.getSubscriberCount()));
|
||||
}
|
||||
|
||||
addImagesMetadataItem(inflater, layout, R.string.metadata_avatars,
|
||||
|
||||
@@ -120,67 +120,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
||||
// LifeCycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCreate(final Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
menuProvider = new MenuProvider() {
|
||||
@Override
|
||||
public void onCreateMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) {
|
||||
inflater.inflate(R.menu.menu_channel, menu);
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreateOptionsMenu() called with: "
|
||||
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPrepareMenu(@NonNull final Menu menu) {
|
||||
menuRssButton = menu.findItem(R.id.menu_item_rss);
|
||||
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
|
||||
updateRssButton();
|
||||
updateNotifyButton(channelSubscription);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onMenuItemSelected(@NonNull final MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case R.id.menu_item_notify:
|
||||
final boolean value = !item.isChecked();
|
||||
item.setEnabled(false);
|
||||
setNotify(value);
|
||||
break;
|
||||
case R.id.action_settings:
|
||||
NavigationHelper.openSettings(requireContext());
|
||||
break;
|
||||
case R.id.menu_item_rss:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl());
|
||||
}
|
||||
break;
|
||||
case R.id.menu_item_openInBrowser:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.openUrlInBrowser(requireContext(),
|
||||
currentInfo.getOriginalUrl());
|
||||
}
|
||||
break;
|
||||
case R.id.menu_item_share:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.shareText(requireContext(), name,
|
||||
currentInfo.getOriginalUrl(), currentInfo.getAvatars());
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
activity.addMenuProvider(menuProvider);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAttach(@NonNull final Context context) {
|
||||
super.onAttach(context);
|
||||
@@ -195,6 +134,67 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
||||
return binding.getRoot();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
|
||||
super.onViewCreated(rootView, savedInstanceState);
|
||||
menuProvider = new MenuProvider() {
|
||||
@Override
|
||||
public void onCreateMenu(@NonNull final Menu menu,
|
||||
@NonNull final MenuInflater inflater) {
|
||||
inflater.inflate(R.menu.menu_channel, menu);
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreateOptionsMenu() called with: "
|
||||
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPrepareMenu(@NonNull final Menu menu) {
|
||||
menuRssButton = menu.findItem(R.id.menu_item_rss);
|
||||
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
|
||||
updateRssButton();
|
||||
updateNotifyButton(channelSubscription);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean onMenuItemSelected(@NonNull final MenuItem item) {
|
||||
switch (item.getItemId()) {
|
||||
case R.id.menu_item_notify:
|
||||
final boolean value = !item.isChecked();
|
||||
item.setEnabled(false);
|
||||
setNotify(value);
|
||||
break;
|
||||
case R.id.action_settings:
|
||||
NavigationHelper.openSettings(requireContext());
|
||||
break;
|
||||
case R.id.menu_item_rss:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl());
|
||||
}
|
||||
break;
|
||||
case R.id.menu_item_openInBrowser:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.openUrlInBrowser(requireContext(),
|
||||
currentInfo.getOriginalUrl());
|
||||
}
|
||||
break;
|
||||
case R.id.menu_item_share:
|
||||
if (currentInfo != null) {
|
||||
ShareUtils.shareText(requireContext(), name,
|
||||
currentInfo.getOriginalUrl(), currentInfo.getAvatars());
|
||||
}
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
};
|
||||
activity.addMenuProvider(menuProvider);
|
||||
}
|
||||
|
||||
@Override // called from onViewCreated in BaseFragment.onViewCreated
|
||||
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
||||
super.initViews(rootView, savedInstanceState);
|
||||
@@ -232,6 +232,14 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
||||
binding.subChannelTitleView.setOnClickListener(openSubChannel);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroyView() {
|
||||
super.onDestroyView();
|
||||
if (menuProvider != null) {
|
||||
activity.removeMenuProvider(menuProvider);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
@@ -240,7 +248,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
||||
}
|
||||
disposables.clear();
|
||||
binding = null;
|
||||
activity.removeMenuProvider(menuProvider);
|
||||
menuProvider = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.text.TextLinkifier;
|
||||
import org.schabi.newpipe.util.text.LongPressLinkMovementMethod;
|
||||
|
||||
import java.util.Queue;
|
||||
import java.util.function.Supplier;
|
||||
@@ -110,7 +111,7 @@ public final class CommentRepliesFragment
|
||||
TextLinkifier.fromDescription(binding.commentContent, item.getCommentText(),
|
||||
HtmlCompat.FROM_HTML_MODE_LEGACY, getServiceById(item.getServiceId()),
|
||||
item.getUrl(), disposables, null);
|
||||
|
||||
binding.commentContent.setMovementMethod(LongPressLinkMovementMethod.getInstance());
|
||||
return binding.getRoot();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,6 +144,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
|
||||
private final SparseArrayCompat<String> menuItemToFilterName = new SparseArrayCompat<>();
|
||||
private StreamingService service;
|
||||
@Nullable
|
||||
private Page nextPage;
|
||||
private boolean showLocalSuggestions = true;
|
||||
private boolean showRemoteSuggestions = true;
|
||||
@@ -219,6 +220,15 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
|
||||
searchBinding = FragmentSearchBinding.bind(rootView);
|
||||
super.onViewCreated(rootView, savedInstanceState);
|
||||
|
||||
updateService();
|
||||
// Add the service name to search string hint
|
||||
// to make it more obvious which platform is being searched.
|
||||
if (service != null) {
|
||||
searchEditText.setHint(
|
||||
getString(R.string.search_with_service_name,
|
||||
service.getServiceInfo().getName()));
|
||||
}
|
||||
showSearchOnStart();
|
||||
initSearchListeners();
|
||||
}
|
||||
@@ -936,6 +946,20 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
filterItemCheckedId = item.getItemId();
|
||||
item.setChecked(true);
|
||||
|
||||
if (service != null) {
|
||||
final boolean isNotFiltered = theContentFilter.isEmpty()
|
||||
|| "all".equals(theContentFilter.get(0));
|
||||
if (isNotFiltered) {
|
||||
searchEditText.setHint(
|
||||
getString(R.string.search_with_service_name,
|
||||
service.getServiceInfo().getName()));
|
||||
} else {
|
||||
searchEditText.setHint(getString(R.string.search_with_service_name_and_filter,
|
||||
service.getServiceInfo().getName(),
|
||||
item.getTitle()));
|
||||
}
|
||||
}
|
||||
|
||||
contentFilter = theContentFilter.toArray(new String[0]);
|
||||
|
||||
if (!TextUtils.isEmpty(searchString)) {
|
||||
@@ -1065,15 +1089,25 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
||||
public void handleNextItems(final ListExtractor.InfoItemsPage<?> result) {
|
||||
showListFooter(false);
|
||||
infoListAdapter.addInfoItemList(result.getItems());
|
||||
nextPage = result.getNextPage();
|
||||
|
||||
if (!result.getErrors().isEmpty()) {
|
||||
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
|
||||
"\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", "
|
||||
+ "pageIds: " + nextPage.getIds() + ", "
|
||||
+ "pageCookies: " + nextPage.getCookies(),
|
||||
serviceId));
|
||||
// nextPage should be non-null at this point, because it refers to the page
|
||||
// whose results are handled here, but let's check it anyway
|
||||
if (nextPage == null) {
|
||||
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
|
||||
"\"" + searchString + "\" → nextPage == null", serviceId));
|
||||
} else {
|
||||
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
|
||||
"\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", "
|
||||
+ "pageIds: " + nextPage.getIds() + ", "
|
||||
+ "pageCookies: " + nextPage.getCookies(),
|
||||
serviceId));
|
||||
}
|
||||
}
|
||||
|
||||
// keep the reassignment of nextPage after the error handling to ensure that nextPage
|
||||
// still holds the correct value during the error handling
|
||||
nextPage = result.getNextPage();
|
||||
super.handleNextItems(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -101,14 +101,16 @@ public class CommentInfoItemHolder extends InfoItemHolder {
|
||||
}
|
||||
itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item));
|
||||
|
||||
|
||||
// setup the top row, with pinned icon, author name and comment date
|
||||
itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
|
||||
itemTitleView.setText(Localization.concatenateStrings(item.getUploaderName(),
|
||||
Localization.relativeTimeOrTextual(itemBuilder.getContext(), item.getUploadDate(),
|
||||
final String uploaderName = Localization.localizeUserName(item.getUploaderName());
|
||||
itemTitleView.setText(Localization.concatenateStrings(
|
||||
uploaderName,
|
||||
Localization.relativeTimeOrTextual(
|
||||
itemBuilder.getContext(),
|
||||
item.getUploadDate(),
|
||||
item.getTextualUploadDate())));
|
||||
|
||||
|
||||
// setup bottom row, with likes, heart and replies button
|
||||
itemLikesCountView.setText(
|
||||
Localization.likeCount(itemBuilder.getContext(), item.getLikeCount()));
|
||||
|
||||
@@ -7,3 +7,16 @@ import androidx.core.os.BundleCompat
|
||||
inline fun <reified T : Parcelable> Bundle.parcelableArrayList(key: String?): ArrayList<T>? {
|
||||
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
|
||||
}
|
||||
|
||||
fun Bundle?.toDebugString(): String {
|
||||
if (this == null) {
|
||||
return "null"
|
||||
}
|
||||
val string = StringBuilder("Bundle{")
|
||||
for (key in this.keySet()) {
|
||||
@Suppress("DEPRECATION") // we want this[key] to return items of any type
|
||||
string.append(" ").append(key).append(" => ").append(this[key]).append(";")
|
||||
}
|
||||
string.append(" }")
|
||||
return string.toString()
|
||||
}
|
||||
|
||||
@@ -17,8 +17,10 @@ import androidx.core.view.isGone
|
||||
import androidx.core.view.isInvisible
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
|
||||
import org.schabi.newpipe.MainActivity
|
||||
|
||||
// logs in this class are disabled by default since it's usually not useful,
|
||||
// you can enable them by setting this flag to MainActivity.DEBUG
|
||||
private const val DEBUG = false
|
||||
private const val TAG = "ViewUtils"
|
||||
|
||||
/**
|
||||
@@ -38,7 +40,7 @@ fun View.animate(
|
||||
delay: Long = 0,
|
||||
execOnEnd: Runnable? = null
|
||||
) {
|
||||
if (MainActivity.DEBUG) {
|
||||
if (DEBUG) {
|
||||
val id = try {
|
||||
resources.getResourceEntryName(id)
|
||||
} catch (e: Exception) {
|
||||
@@ -51,7 +53,7 @@ fun View.animate(
|
||||
Log.d(TAG, "animate(): $msg")
|
||||
}
|
||||
if (isVisible && enterOrExit) {
|
||||
if (MainActivity.DEBUG) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "animate(): view was already visible > view = [$this]")
|
||||
}
|
||||
animate().setListener(null).cancel()
|
||||
@@ -60,7 +62,7 @@ fun View.animate(
|
||||
execOnEnd?.run()
|
||||
return
|
||||
} else if ((isGone || isInvisible) && !enterOrExit) {
|
||||
if (MainActivity.DEBUG) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "animate(): view was already gone > view = [$this]")
|
||||
}
|
||||
animate().setListener(null).cancel()
|
||||
@@ -89,7 +91,7 @@ fun View.animate(
|
||||
* @param colorEnd the background color to end with
|
||||
*/
|
||||
fun View.animateBackgroundColor(duration: Long, @ColorInt colorStart: Int, @ColorInt colorEnd: Int) {
|
||||
if (MainActivity.DEBUG) {
|
||||
if (DEBUG) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"animateBackgroundColor() called with: view = [$this], duration = [$duration], " +
|
||||
@@ -109,7 +111,7 @@ fun View.animateBackgroundColor(duration: Long, @ColorInt colorStart: Int, @Colo
|
||||
}
|
||||
|
||||
fun View.animateHeight(duration: Long, targetHeight: Int): ValueAnimator {
|
||||
if (MainActivity.DEBUG) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "animateHeight: duration = [$duration], from $height to → $targetHeight in: $this")
|
||||
}
|
||||
val animator = ValueAnimator.ofFloat(height.toFloat(), targetHeight.toFloat())
|
||||
@@ -127,7 +129,7 @@ fun View.animateHeight(duration: Long, targetHeight: Int): ValueAnimator {
|
||||
}
|
||||
|
||||
fun View.animateRotation(duration: Long, targetRotation: Int) {
|
||||
if (MainActivity.DEBUG) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "animateRotation: duration = [$duration], from $rotation to → $targetRotation in: $this")
|
||||
}
|
||||
animate().setListener(null).cancel()
|
||||
|
||||
@@ -194,9 +194,6 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
|
||||
if (itemsList != null) {
|
||||
animateHideRecyclerViewAllowingScrolling(itemsList);
|
||||
}
|
||||
if (headerRootBinding != null) {
|
||||
animate(headerRootBinding.getRoot(), false, 200);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -205,9 +202,6 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
|
||||
if (itemsList != null) {
|
||||
animate(itemsList, true, 200);
|
||||
}
|
||||
if (headerRootBinding != null) {
|
||||
animate(headerRootBinding.getRoot(), true, 200);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -253,9 +247,6 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
|
||||
if (itemsList != null) {
|
||||
animateHideRecyclerViewAllowingScrolling(itemsList);
|
||||
}
|
||||
if (headerRootBinding != null) {
|
||||
animate(headerRootBinding.getRoot(), false, 200);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -269,7 +269,12 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
|
||||
override fun onDestroyOptionsMenu() {
|
||||
super.onDestroyOptionsMenu()
|
||||
activity?.supportActionBar?.subtitle = null
|
||||
if (
|
||||
(groupName != "") &&
|
||||
(activity?.supportActionBar?.subtitle == groupName)
|
||||
) {
|
||||
activity?.supportActionBar?.subtitle = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
@@ -281,7 +286,13 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
||||
}
|
||||
|
||||
super.onDestroy()
|
||||
activity?.supportActionBar?.subtitle = null
|
||||
|
||||
if (
|
||||
(groupName != "") &&
|
||||
(activity?.supportActionBar?.subtitle == groupName)
|
||||
) {
|
||||
activity?.supportActionBar?.subtitle = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
|
||||
@@ -88,7 +88,7 @@ class NotificationHelper(val context: Context) {
|
||||
|
||||
// Show individual stream notifications, set channel icon only if there is actually
|
||||
// one
|
||||
showStreamNotifications(newStreams, data.serviceId, bitmap)
|
||||
showStreamNotifications(newStreams, data.serviceId, data.url, bitmap)
|
||||
// Show summary notification
|
||||
manager.notify(data.pseudoId, summaryBuilder.build())
|
||||
|
||||
@@ -97,7 +97,7 @@ class NotificationHelper(val context: Context) {
|
||||
|
||||
override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) {
|
||||
// Show individual stream notifications
|
||||
showStreamNotifications(newStreams, data.serviceId, null)
|
||||
showStreamNotifications(newStreams, data.serviceId, data.url, null)
|
||||
// Show summary notification
|
||||
manager.notify(data.pseudoId, summaryBuilder.build())
|
||||
iconLoadingTargets.remove(this) // allow it to be garbage-collected
|
||||
@@ -118,10 +118,11 @@ class NotificationHelper(val context: Context) {
|
||||
private fun showStreamNotifications(
|
||||
newStreams: List<StreamInfoItem>,
|
||||
serviceId: Int,
|
||||
channelUrl: String,
|
||||
channelIcon: Bitmap?
|
||||
) {
|
||||
for (stream in newStreams) {
|
||||
val notification = createStreamNotification(stream, serviceId, channelIcon)
|
||||
val notification = createStreamNotification(stream, serviceId, channelUrl, channelIcon)
|
||||
manager.notify(stream.url.hashCode(), notification)
|
||||
}
|
||||
}
|
||||
@@ -129,6 +130,7 @@ class NotificationHelper(val context: Context) {
|
||||
private fun createStreamNotification(
|
||||
item: StreamInfoItem,
|
||||
serviceId: Int,
|
||||
channelUrl: String,
|
||||
channelIcon: Bitmap?
|
||||
): Notification {
|
||||
return NotificationCompat.Builder(
|
||||
@@ -139,7 +141,7 @@ class NotificationHelper(val context: Context) {
|
||||
.setLargeIcon(channelIcon)
|
||||
.setContentTitle(item.name)
|
||||
.setContentText(item.uploaderName)
|
||||
.setGroup(item.uploaderUrl)
|
||||
.setGroup(channelUrl)
|
||||
.setColor(ContextCompat.getColor(context, R.color.ic_launcher_background))
|
||||
.setColorized(true)
|
||||
.setAutoCancel(true)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package org.schabi.newpipe.local.playlist
|
||||
|
||||
import android.content.Context
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry
|
||||
import org.schabi.newpipe.extractor.exceptions.ParsingException
|
||||
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeStreamLinkHandlerFactory
|
||||
import org.schabi.newpipe.local.playlist.PlayListShareMode.JUST_URLS
|
||||
import org.schabi.newpipe.local.playlist.PlayListShareMode.WITH_TITLES
|
||||
import org.schabi.newpipe.local.playlist.PlayListShareMode.YOUTUBE_TEMP_PLAYLIST
|
||||
|
||||
fun export(
|
||||
shareMode: PlayListShareMode,
|
||||
playlist: List<PlaylistStreamEntry>,
|
||||
context: Context
|
||||
): String {
|
||||
return when (shareMode) {
|
||||
WITH_TITLES -> exportWithTitles(playlist, context)
|
||||
JUST_URLS -> exportJustUrls(playlist)
|
||||
YOUTUBE_TEMP_PLAYLIST -> exportAsYoutubeTempPlaylist(playlist)
|
||||
}
|
||||
}
|
||||
|
||||
fun exportWithTitles(
|
||||
playlist: List<PlaylistStreamEntry>,
|
||||
context: Context
|
||||
): String {
|
||||
|
||||
return playlist.asSequence()
|
||||
.map { it.streamEntity }
|
||||
.map { entity ->
|
||||
context.getString(
|
||||
R.string.video_details_list_item,
|
||||
entity.title,
|
||||
entity.url
|
||||
)
|
||||
}
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
fun exportJustUrls(playlist: List<PlaylistStreamEntry>): String {
|
||||
|
||||
return playlist.asSequence()
|
||||
.map { it.streamEntity.url }
|
||||
.joinToString(separator = "\n")
|
||||
}
|
||||
|
||||
fun exportAsYoutubeTempPlaylist(playlist: List<PlaylistStreamEntry>): String {
|
||||
|
||||
val videoIDs = playlist.asReversed().asSequence()
|
||||
.map { it.streamEntity.url }
|
||||
.mapNotNull(::getYouTubeId)
|
||||
.take(50) // YouTube limitation: temp playlists can't have more than 50 items
|
||||
.toList()
|
||||
.asReversed()
|
||||
.joinToString(separator = ",")
|
||||
|
||||
return "https://www.youtube.com/watch_videos?video_ids=$videoIDs"
|
||||
}
|
||||
|
||||
val linkHandler: YoutubeStreamLinkHandlerFactory = YoutubeStreamLinkHandlerFactory.getInstance()
|
||||
|
||||
/**
|
||||
* Gets the video id from a YouTube URL.
|
||||
*
|
||||
* @param url YouTube URL
|
||||
* @return the video id
|
||||
*/
|
||||
fun getYouTubeId(url: String): String? {
|
||||
|
||||
return try { linkHandler.getId(url) } catch (e: ParsingException) { null }
|
||||
}
|
||||
@@ -2,8 +2,13 @@ package org.schabi.newpipe.local.playlist;
|
||||
|
||||
import static org.schabi.newpipe.error.ErrorUtil.showUiErrorSnackbar;
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||
import static org.schabi.newpipe.local.playlist.ExportPlaylistKt.export;
|
||||
import static org.schabi.newpipe.local.playlist.PlayListShareMode.JUST_URLS;
|
||||
import static org.schabi.newpipe.local.playlist.PlayListShareMode.WITH_TITLES;
|
||||
import static org.schabi.newpipe.local.playlist.PlayListShareMode.YOUTUBE_TEMP_PLAYLIST;
|
||||
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.os.Parcelable;
|
||||
@@ -27,7 +32,6 @@ import androidx.recyclerview.widget.RecyclerView;
|
||||
import androidx.viewbinding.ViewBinding;
|
||||
|
||||
import com.evernote.android.state.State;
|
||||
|
||||
import org.reactivestreams.Subscriber;
|
||||
import org.reactivestreams.Subscription;
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
@@ -385,34 +389,41 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares the playlist as a list of stream URLs if {@code shouldSharePlaylistDetails} is
|
||||
* set to {@code false}. Shares the playlist name along with a list of video titles and URLs
|
||||
* if {@code shouldSharePlaylistDetails} is set to {@code true}.
|
||||
* Shares the playlist in one of 3 ways, depending on the value of {@code shareMode}:
|
||||
* <ul>
|
||||
* <li>{@code JUST_URLS}: shares the URLs only.</li>
|
||||
* <li>{@code WITH_TITLES}: each entry in the list is accompanied by its title.</li>
|
||||
* <li>{@code YOUTUBE_TEMP_PLAYLIST}: shares as a YouTube temporary playlist.</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param shouldSharePlaylistDetails Whether the playlist details should be included in the
|
||||
* shared content.
|
||||
* @param shareMode The way the playlist should be shared.
|
||||
*/
|
||||
private void sharePlaylist(final boolean shouldSharePlaylistDetails) {
|
||||
private void sharePlaylist(final PlayListShareMode shareMode) {
|
||||
final Context context = requireContext();
|
||||
|
||||
disposables.add(playlistManager.getPlaylistStreams(playlistId)
|
||||
.flatMapSingle(playlist -> Single.just(playlist.stream()
|
||||
.map(PlaylistStreamEntry::getStreamEntity)
|
||||
.map(streamEntity -> {
|
||||
if (shouldSharePlaylistDetails) {
|
||||
return context.getString(R.string.video_details_list_item,
|
||||
streamEntity.getTitle(), streamEntity.getUrl());
|
||||
} else {
|
||||
return streamEntity.getUrl();
|
||||
}
|
||||
})
|
||||
.collect(Collectors.joining("\n"))))
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(urlsText -> ShareUtils.shareText(
|
||||
context, name, shouldSharePlaylistDetails
|
||||
? context.getString(R.string.share_playlist_content_details,
|
||||
name, urlsText) : urlsText),
|
||||
throwable -> showUiErrorSnackbar(this, "Sharing playlist", throwable)));
|
||||
.flatMapSingle(playlist -> Single.just(export(
|
||||
|
||||
shareMode,
|
||||
playlist,
|
||||
context
|
||||
)))
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
urlsText -> {
|
||||
|
||||
final String content = shareMode == WITH_TITLES
|
||||
? context.getString(R.string.share_playlist_content_details,
|
||||
name,
|
||||
urlsText
|
||||
)
|
||||
: urlsText;
|
||||
|
||||
ShareUtils.shareText(context, name, content);
|
||||
},
|
||||
throwable -> showUiErrorSnackbar(this, "Sharing playlist", throwable)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public void removeWatchedStreams(final boolean removePartiallyWatched) {
|
||||
@@ -872,13 +883,15 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
||||
private void createShareConfirmationDialog() {
|
||||
new AlertDialog.Builder(requireContext())
|
||||
.setTitle(R.string.share_playlist)
|
||||
.setMessage(R.string.share_playlist_with_titles_message)
|
||||
.setCancelable(true)
|
||||
.setPositiveButton(R.string.share_playlist_with_titles, (dialog, which) ->
|
||||
sharePlaylist(/* shouldSharePlaylistDetails= */ true)
|
||||
sharePlaylist(WITH_TITLES)
|
||||
)
|
||||
.setNeutralButton(R.string.share_playlist_as_youtube_temporary_playlist,
|
||||
(dialog, which) -> sharePlaylist(YOUTUBE_TEMP_PLAYLIST)
|
||||
)
|
||||
.setNegativeButton(R.string.share_playlist_with_list, (dialog, which) ->
|
||||
sharePlaylist(/* shouldSharePlaylistDetails= */ false)
|
||||
sharePlaylist(JUST_URLS)
|
||||
)
|
||||
.show();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.schabi.newpipe.local.playlist;
|
||||
|
||||
public enum PlayListShareMode {
|
||||
|
||||
JUST_URLS,
|
||||
WITH_TITLES,
|
||||
YOUTUBE_TEMP_PLAYLIST
|
||||
}
|
||||
@@ -26,6 +26,10 @@ public class RemotePlaylistManager {
|
||||
return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Flowable<PlaylistRemoteEntity> getPlaylist(final long playlistId) {
|
||||
return playlistRemoteTable.getPlaylist(playlistId).subscribeOn(Schedulers.io());
|
||||
}
|
||||
|
||||
public Flowable<List<PlaylistRemoteEntity>> getPlaylist(final PlaylistInfo info) {
|
||||
return playlistRemoteTable.getPlaylist(info.getServiceId(), info.getUrl())
|
||||
.subscribeOn(Schedulers.io());
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package org.schabi.newpipe.local.subscription;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.app.Dialog;
|
||||
import android.content.Intent;
|
||||
import android.os.Bundle;
|
||||
@@ -35,7 +33,6 @@ public class ImportConfirmationDialog extends DialogFragment {
|
||||
@NonNull
|
||||
@Override
|
||||
public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
|
||||
assureCorrectAppLanguage(getContext());
|
||||
return new AlertDialog.Builder(requireContext())
|
||||
.setMessage(R.string.import_network_expensive_warning)
|
||||
.setCancelable(true)
|
||||
|
||||
@@ -13,6 +13,7 @@ import android.view.MenuItem
|
||||
import android.view.SubMenu
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.ActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult
|
||||
@@ -460,6 +461,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val JSON_MIME_TYPE = "application/json"
|
||||
val JSON_MIME_TYPE = MimeTypeMap.getSingleton()
|
||||
.getMimeTypeFromExtension("json") ?: "application/octet-stream"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.schabi.newpipe.player;
|
||||
|
||||
import static org.schabi.newpipe.QueueItemMenuUtil.openPopupMenu;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.formatSpeed;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.content.ComponentName;
|
||||
import android.content.Intent;
|
||||
@@ -84,7 +83,6 @@ public final class PlayQueueActivity extends AppCompatActivity
|
||||
|
||||
@Override
|
||||
protected void onCreate(final Bundle savedInstanceState) {
|
||||
assureCorrectAppLanguage(this);
|
||||
super.onCreate(savedInstanceState);
|
||||
ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this));
|
||||
|
||||
@@ -183,7 +181,10 @@ public final class PlayQueueActivity extends AppCompatActivity
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
private void bind() {
|
||||
// Note: this code should not really exist, and PlayerHolder should be used instead, but
|
||||
// it will be rewritten when NewPlayer will replace the current player.
|
||||
final Intent bindIntent = new Intent(this, PlayerService.class);
|
||||
bindIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION);
|
||||
final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE);
|
||||
if (!success) {
|
||||
unbindService(serviceConnection);
|
||||
@@ -221,7 +222,7 @@ public final class PlayQueueActivity extends AppCompatActivity
|
||||
Log.d(TAG, "Player service is connected");
|
||||
|
||||
if (service instanceof PlayerService.LocalBinder) {
|
||||
player = ((PlayerService.LocalBinder) service).getPlayer();
|
||||
player = ((PlayerService.LocalBinder) service).getService().getPlayer();
|
||||
}
|
||||
|
||||
if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) {
|
||||
|
||||
@@ -44,7 +44,6 @@ import static org.schabi.newpipe.player.notification.NotificationConstants.ACTIO
|
||||
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE;
|
||||
import static org.schabi.newpipe.util.ListHelper.getPopupResolutionIndex;
|
||||
import static org.schabi.newpipe.util.ListHelper.getResolutionIndex;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
import static java.util.concurrent.TimeUnit.MILLISECONDS;
|
||||
|
||||
import android.content.BroadcastReceiver;
|
||||
@@ -55,6 +54,7 @@ import android.content.SharedPreferences;
|
||||
import android.graphics.Bitmap;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.media.AudioManager;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
|
||||
@@ -71,6 +71,7 @@ import com.google.android.exoplayer2.PlaybackParameters;
|
||||
import com.google.android.exoplayer2.Player.PositionInfo;
|
||||
import com.google.android.exoplayer2.Timeline;
|
||||
import com.google.android.exoplayer2.Tracks;
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||
import com.google.android.exoplayer2.source.MediaSource;
|
||||
import com.google.android.exoplayer2.text.CueGroup;
|
||||
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
|
||||
@@ -86,8 +87,8 @@ import org.schabi.newpipe.databinding.PlayerBinding;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.stream.AudioStream;
|
||||
import org.schabi.newpipe.extractor.Image;
|
||||
import org.schabi.newpipe.extractor.stream.AudioStream;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||
import org.schabi.newpipe.extractor.stream.VideoStream;
|
||||
@@ -118,9 +119,9 @@ import org.schabi.newpipe.player.ui.VideoPlayerUi;
|
||||
import org.schabi.newpipe.util.DependentPreferenceHelper;
|
||||
import org.schabi.newpipe.util.ListHelper;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.SerializedCache;
|
||||
import org.schabi.newpipe.util.StreamTypeUtil;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -269,7 +270,16 @@ public final class Player implements PlaybackListener, Listener {
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
//region Constructor
|
||||
|
||||
public Player(@NonNull final PlayerService service) {
|
||||
/**
|
||||
* @param service the service this player resides in
|
||||
* @param mediaSession used to build the {@link MediaSessionPlayerUi}, lives in the service and
|
||||
* could possibly be reused with multiple player instances
|
||||
* @param sessionConnector used to build the {@link MediaSessionPlayerUi}, lives in the service
|
||||
* and could possibly be reused with multiple player instances
|
||||
*/
|
||||
public Player(@NonNull final PlayerService service,
|
||||
@NonNull final MediaSessionCompat mediaSession,
|
||||
@NonNull final MediaSessionConnector sessionConnector) {
|
||||
this.service = service;
|
||||
context = service;
|
||||
prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
@@ -302,7 +312,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
// notification ui in the UIs list, since the notification depends on the media session in
|
||||
// PlayerUi#initPlayer(), and UIs.call() guarantees UI order is preserved.
|
||||
UIs = new PlayerUiList(
|
||||
new MediaSessionPlayerUi(this),
|
||||
new MediaSessionPlayerUi(this, mediaSession, sessionConnector),
|
||||
new NotificationPlayerUi(this)
|
||||
);
|
||||
}
|
||||
@@ -646,7 +656,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
Log.d(TAG, "onPlaybackShutdown() called");
|
||||
}
|
||||
// destroys the service, which in turn will destroy the player
|
||||
service.stopService();
|
||||
service.destroyPlayerAndStopService();
|
||||
}
|
||||
|
||||
public void smoothStopForImmediateReusing() {
|
||||
@@ -718,7 +728,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
pause();
|
||||
break;
|
||||
case ACTION_CLOSE:
|
||||
service.stopService();
|
||||
service.destroyPlayerAndStopService();
|
||||
break;
|
||||
case ACTION_PLAY_PAUSE:
|
||||
playPause();
|
||||
@@ -742,7 +752,6 @@ public final class Player implements PlaybackListener, Listener {
|
||||
toggleShuffleModeEnabled();
|
||||
break;
|
||||
case Intent.ACTION_CONFIGURATION_CHANGED:
|
||||
assureCorrectAppLanguage(service);
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "ACTION_CONFIGURATION_CHANGED received");
|
||||
}
|
||||
@@ -1375,6 +1384,19 @@ public final class Player implements PlaybackListener, Listener {
|
||||
public void onCues(@NonNull final CueGroup cueGroup) {
|
||||
UIs.call(playerUi -> playerUi.onCues(cueGroup.cues));
|
||||
}
|
||||
|
||||
/**
|
||||
* To be called when the {@code PlaybackPreparer} set in the {@link MediaSessionConnector}
|
||||
* receives an {@code onPrepare()} call. This function allows restoring the default behavior
|
||||
* that would happen if there was no playback preparer set, i.e. to just call
|
||||
* {@code player.prepare()}. You can find the default behavior in `onPlay()` inside the
|
||||
* {@link MediaSessionConnector} file.
|
||||
*/
|
||||
public void onPrepare() {
|
||||
if (!exoPlayerIsNull()) {
|
||||
simpleExoPlayer.prepare();
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
|
||||
|
||||
@@ -19,77 +19,141 @@
|
||||
|
||||
package org.schabi.newpipe.player;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Binder;
|
||||
import android.os.Bundle;
|
||||
import android.os.IBinder;
|
||||
import android.support.v4.media.MediaBrowserCompat;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.app.ServiceCompat;
|
||||
import androidx.media.MediaBrowserServiceCompat;
|
||||
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||
|
||||
import org.schabi.newpipe.ktx.BundleKt;
|
||||
import org.schabi.newpipe.player.mediabrowser.MediaBrowserImpl;
|
||||
import org.schabi.newpipe.player.mediabrowser.MediaBrowserPlaybackPreparer;
|
||||
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
|
||||
import org.schabi.newpipe.player.notification.NotificationPlayerUi;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
|
||||
/**
|
||||
* One service for all players.
|
||||
*/
|
||||
public final class PlayerService extends Service {
|
||||
public final class PlayerService extends MediaBrowserServiceCompat {
|
||||
private static final String TAG = PlayerService.class.getSimpleName();
|
||||
private static final boolean DEBUG = Player.DEBUG;
|
||||
|
||||
public static final String SHOULD_START_FOREGROUND_EXTRA = "should_start_foreground_extra";
|
||||
public static final String BIND_PLAYER_HOLDER_ACTION = "bind_player_holder_action";
|
||||
|
||||
// These objects are used to cleanly separate the Service implementation (in this file) and the
|
||||
// media browser and playback preparer implementations. At the moment the playback preparer is
|
||||
// only used in conjunction with the media browser.
|
||||
private MediaBrowserImpl mediaBrowserImpl;
|
||||
private MediaBrowserPlaybackPreparer mediaBrowserPlaybackPreparer;
|
||||
|
||||
// these are instantiated in onCreate() as per
|
||||
// https://developer.android.com/training/cars/media#browser_workflow
|
||||
private MediaSessionCompat mediaSession;
|
||||
private MediaSessionConnector sessionConnector;
|
||||
|
||||
@Nullable
|
||||
private Player player;
|
||||
|
||||
private final IBinder mBinder = new PlayerService.LocalBinder(this);
|
||||
|
||||
/**
|
||||
* The parameter taken by this {@link Consumer} can be null to indicate the player is being
|
||||
* stopped.
|
||||
*/
|
||||
@Nullable
|
||||
private Consumer<Player> onPlayerStartedOrStopped = null;
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Service's LifeCycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
//region Service lifecycle
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreate() called");
|
||||
}
|
||||
assureCorrectAppLanguage(this);
|
||||
ThemeHelper.setTheme(this);
|
||||
|
||||
player = new Player(this);
|
||||
/*
|
||||
Create the player notification and start immediately the service in foreground,
|
||||
otherwise if nothing is played or initializing the player and its components (especially
|
||||
loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the
|
||||
service would never be put in the foreground while we said to the system we would do so
|
||||
*/
|
||||
player.UIs().get(NotificationPlayerUi.class)
|
||||
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
|
||||
mediaBrowserImpl = new MediaBrowserImpl(this, this::notifyChildrenChanged);
|
||||
|
||||
// see https://developer.android.com/training/cars/media#browser_workflow
|
||||
mediaSession = new MediaSessionCompat(this, "MediaSessionPlayerServ");
|
||||
setSessionToken(mediaSession.getSessionToken());
|
||||
sessionConnector = new MediaSessionConnector(mediaSession);
|
||||
sessionConnector.setMetadataDeduplicationEnabled(true);
|
||||
|
||||
mediaBrowserPlaybackPreparer = new MediaBrowserPlaybackPreparer(
|
||||
this,
|
||||
sessionConnector::setCustomErrorMessage,
|
||||
() -> sessionConnector.setCustomErrorMessage(null),
|
||||
(playWhenReady) -> {
|
||||
if (player != null) {
|
||||
player.onPrepare();
|
||||
}
|
||||
}
|
||||
);
|
||||
sessionConnector.setPlaybackPreparer(mediaBrowserPlaybackPreparer);
|
||||
|
||||
// Note: you might be tempted to create the player instance and call startForeground here,
|
||||
// but be aware that the Android system might start the service just to perform media
|
||||
// queries. In those cases creating a player instance is a waste of resources, and calling
|
||||
// startForeground means creating a useless empty notification. In case it's really needed
|
||||
// the player instance can be created here, but startForeground() should definitely not be
|
||||
// called here unless the service is actually starting in the foreground, to avoid the
|
||||
// useless notification.
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(final Intent intent, final int flags, final int startId) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onStartCommand() called with: intent = [" + intent
|
||||
+ "], extras = [" + BundleKt.toDebugString(intent.getExtras())
|
||||
+ "], flags = [" + flags + "], startId = [" + startId + "]");
|
||||
}
|
||||
|
||||
/*
|
||||
Be sure that the player notification is set and the service is started in foreground,
|
||||
otherwise, the app may crash on Android 8+ as the service would never be put in the
|
||||
foreground while we said to the system we would do so
|
||||
The service is always requested to be started in foreground, so always creating a
|
||||
notification if there is no one already and starting the service in foreground should
|
||||
not create any issues
|
||||
If the service is already started in foreground, requesting it to be started shouldn't
|
||||
do anything
|
||||
*/
|
||||
if (player != null) {
|
||||
// All internal NewPipe intents used to interact with the player, that are sent to the
|
||||
// PlayerService using startForegroundService(), will have SHOULD_START_FOREGROUND_EXTRA,
|
||||
// to ensure startForeground() is called (otherwise Android will force-crash the app).
|
||||
if (intent.getBooleanExtra(SHOULD_START_FOREGROUND_EXTRA, false)) {
|
||||
final boolean playerWasNull = (player == null);
|
||||
if (playerWasNull) {
|
||||
// make sure the player exists, in case the service was resumed
|
||||
player = new Player(this, mediaSession, sessionConnector);
|
||||
}
|
||||
|
||||
// Be sure that the player notification is set and the service is started in foreground,
|
||||
// otherwise, the app may crash on Android 8+ as the service would never be put in the
|
||||
// foreground while we said to the system we would do so. The service is always
|
||||
// requested to be started in foreground, so always creating a notification if there is
|
||||
// no one already and starting the service in foreground should not create any issues.
|
||||
// If the service is already started in foreground, requesting it to be started
|
||||
// shouldn't do anything.
|
||||
player.UIs().get(NotificationPlayerUi.class)
|
||||
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
|
||||
|
||||
if (playerWasNull && onPlayerStartedOrStopped != null) {
|
||||
// notify that a new player was created (but do it after creating the foreground
|
||||
// notification just to make sure we don't incur, due to slowness, in
|
||||
// "Context.startForegroundService() did not then call Service.startForeground()")
|
||||
onPlayerStartedOrStopped.accept(player);
|
||||
}
|
||||
}
|
||||
|
||||
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
|
||||
@@ -100,7 +164,7 @@ public final class PlayerService extends Service {
|
||||
Stop the service in this case, which will be removed from the foreground and its
|
||||
notification cancelled in its destruction
|
||||
*/
|
||||
stopSelf();
|
||||
destroyPlayerAndStopService();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
@@ -142,29 +206,84 @@ public final class PlayerService extends Service {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "destroy() called");
|
||||
}
|
||||
super.onDestroy();
|
||||
|
||||
cleanup();
|
||||
|
||||
mediaBrowserPlaybackPreparer.dispose();
|
||||
mediaSession.release();
|
||||
mediaBrowserImpl.dispose();
|
||||
}
|
||||
|
||||
private void cleanup() {
|
||||
if (player != null) {
|
||||
if (onPlayerStartedOrStopped != null) {
|
||||
// notify that the player is being destroyed
|
||||
onPlayerStartedOrStopped.accept(null);
|
||||
}
|
||||
player.destroy();
|
||||
player = null;
|
||||
}
|
||||
|
||||
// Should already be handled by MediaSessionPlayerUi, but just to be sure.
|
||||
mediaSession.setActive(false);
|
||||
|
||||
// Should already be handled by NotificationUtil.cancelNotificationAndStopForeground() in
|
||||
// NotificationPlayerUi, but let's make sure that the foreground service is stopped.
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE);
|
||||
}
|
||||
|
||||
public void stopService() {
|
||||
/**
|
||||
* Destroys the player and allows the player instance to be garbage collected. Sets the media
|
||||
* session to inactive. Stops the foreground service and removes the player notification
|
||||
* associated with it. Tries to stop the {@link PlayerService} completely, but this step will
|
||||
* have no effect in case some service connection still uses the service (e.g. the Android Auto
|
||||
* system accesses the media browser even when no player is running).
|
||||
*/
|
||||
public void destroyPlayerAndStopService() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "destroyPlayerAndStopService() called");
|
||||
}
|
||||
|
||||
cleanup();
|
||||
stopSelf();
|
||||
|
||||
// This only really stops the service if there are no other service connections (see docs):
|
||||
// for example the (Android Auto) media browser binder will block stopService().
|
||||
// This is why we also stopForeground() above, to make sure the notification is removed.
|
||||
// If we were to call stopSelf(), then the service would be surely stopped (regardless of
|
||||
// other service connections), but this would be a waste of resources since the service
|
||||
// would be immediately restarted by those same connections to perform the queries.
|
||||
stopService(new Intent(this, PlayerService.class));
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(final Context base) {
|
||||
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base));
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Bind
|
||||
@Override
|
||||
public IBinder onBind(final Intent intent) {
|
||||
return mBinder;
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onBind() called with: intent = [" + intent
|
||||
+ "], extras = [" + BundleKt.toDebugString(intent.getExtras()) + "]");
|
||||
}
|
||||
|
||||
if (BIND_PLAYER_HOLDER_ACTION.equals(intent.getAction())) {
|
||||
// Note that this binder might be reused multiple times while the service is alive, even
|
||||
// after unbind() has been called: https://stackoverflow.com/a/8794930 .
|
||||
return mBinder;
|
||||
|
||||
} else if (MediaBrowserServiceCompat.SERVICE_INTERFACE.equals(intent.getAction())) {
|
||||
// MediaBrowserService also uses its own binder, so for actions related to the media
|
||||
// browser service, pass the onBind to the superclass.
|
||||
return super.onBind(intent);
|
||||
|
||||
} else {
|
||||
// This is an unknown request, avoid returning any binder to not leak objects.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static class LocalBinder extends Binder {
|
||||
@@ -177,9 +296,51 @@ public final class PlayerService extends Service {
|
||||
public PlayerService getService() {
|
||||
return playerService.get();
|
||||
}
|
||||
}
|
||||
|
||||
public Player getPlayer() {
|
||||
return playerService.get().player;
|
||||
/**
|
||||
* @return the current active player instance. May be null, since the player service can outlive
|
||||
* the player e.g. to respond to Android Auto media browser queries.
|
||||
*/
|
||||
@Nullable
|
||||
public Player getPlayer() {
|
||||
return player;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the listener that will be called when the player is started or stopped. If a
|
||||
* {@code null} listener is passed, then the current listener will be unset. The parameter taken
|
||||
* by the {@link Consumer} can be null to indicate that the player is stopping.
|
||||
* @param listener the listener to set or unset
|
||||
*/
|
||||
public void setPlayerListener(@Nullable final Consumer<Player> listener) {
|
||||
this.onPlayerStartedOrStopped = listener;
|
||||
if (listener != null) {
|
||||
// if there is no player, then `null` will be sent here, to ensure the state is synced
|
||||
listener.accept(player);
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Media browser
|
||||
@Override
|
||||
public BrowserRoot onGetRoot(@NonNull final String clientPackageName,
|
||||
final int clientUid,
|
||||
@Nullable final Bundle rootHints) {
|
||||
return mediaBrowserImpl.onGetRoot(clientPackageName, clientUid, rootHints);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoadChildren(@NonNull final String parentId,
|
||||
@NonNull final Result<List<MediaBrowserCompat.MediaItem>> result) {
|
||||
mediaBrowserImpl.onLoadChildren(parentId, result);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSearch(@NonNull final String query,
|
||||
final Bundle extras,
|
||||
@NonNull final Result<List<MediaBrowserCompat.MediaItem>> result) {
|
||||
mediaBrowserImpl.onSearch(query, result);
|
||||
}
|
||||
//endregion
|
||||
}
|
||||
|
||||
@@ -1,11 +1,48 @@
|
||||
package org.schabi.newpipe.player.event;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import org.schabi.newpipe.player.PlayerService;
|
||||
import org.schabi.newpipe.player.Player;
|
||||
|
||||
/**
|
||||
* In addition to {@link PlayerServiceEventListener}, provides callbacks for service and player
|
||||
* connections and disconnections. "Connected" here means that the service (resp. the
|
||||
* player) is running and is bound to {@link org.schabi.newpipe.player.helper.PlayerHolder}.
|
||||
* "Disconnected" means that either the service (resp. the player) was stopped completely, or that
|
||||
* {@link org.schabi.newpipe.player.helper.PlayerHolder} is not bound.
|
||||
*/
|
||||
public interface PlayerServiceExtendedEventListener extends PlayerServiceEventListener {
|
||||
void onServiceConnected(Player player,
|
||||
PlayerService playerService,
|
||||
boolean playAfterConnect);
|
||||
/**
|
||||
* The player service just connected to {@link org.schabi.newpipe.player.helper.PlayerHolder},
|
||||
* but the player may not be active at this moment, e.g. in case the service is running to
|
||||
* respond to Android Auto media browser queries without playing anything.
|
||||
* {@link #onPlayerConnected(Player, boolean)} will be called right after this function if there
|
||||
* is a player.
|
||||
*
|
||||
* @param playerService the newly connected player service
|
||||
*/
|
||||
void onServiceConnected(@NonNull PlayerService playerService);
|
||||
|
||||
/**
|
||||
* The player service is already connected and the player was just started.
|
||||
*
|
||||
* @param player the newly connected or started player
|
||||
* @param playAfterConnect whether to open the video player in the video details fragment
|
||||
*/
|
||||
void onPlayerConnected(@NonNull Player player, boolean playAfterConnect);
|
||||
|
||||
/**
|
||||
* The player got disconnected, for one of these reasons: the player is getting closed while
|
||||
* leaving the service open for future media browser queries, the service is stopping
|
||||
* completely, or {@link org.schabi.newpipe.player.helper.PlayerHolder} is unbinding.
|
||||
*/
|
||||
void onPlayerDisconnected();
|
||||
|
||||
/**
|
||||
* The service got disconnected from {@link org.schabi.newpipe.player.helper.PlayerHolder},
|
||||
* either because {@link org.schabi.newpipe.player.helper.PlayerHolder} is unbinding or because
|
||||
* the service is stopping completely.
|
||||
*/
|
||||
void onServiceDisconnected();
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package org.schabi.newpipe.player.helper;
|
||||
|
||||
import static org.schabi.newpipe.ktx.ViewUtils.animateRotation;
|
||||
import static org.schabi.newpipe.player.Player.DEBUG;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
import static org.schabi.newpipe.util.ThemeHelper.resolveDrawable;
|
||||
|
||||
import android.app.Dialog;
|
||||
@@ -145,7 +144,6 @@ public class PlaybackParameterDialog extends DialogFragment {
|
||||
@NonNull
|
||||
@Override
|
||||
public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
|
||||
assureCorrectAppLanguage(getContext());
|
||||
Bridge.restoreInstanceState(this, savedInstanceState);
|
||||
|
||||
binding = DialogPlaybackParameterBinding.inflate(getLayoutInflater());
|
||||
|
||||
@@ -33,11 +33,9 @@ import com.google.android.exoplayer2.trackselection.ExoTrackSelection;
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout;
|
||||
import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.ResizeMode;
|
||||
import com.google.android.exoplayer2.ui.CaptionStyleCompat;
|
||||
import com.google.android.exoplayer2.util.MimeTypes;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.MediaFormat;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||
import org.schabi.newpipe.extractor.stream.SubtitlesStream;
|
||||
@@ -47,13 +45,14 @@ import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
|
||||
import org.schabi.newpipe.util.ListHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
|
||||
import java.lang.annotation.Retention;
|
||||
import java.text.DecimalFormat;
|
||||
import java.text.DecimalFormatSymbols;
|
||||
import java.text.NumberFormat;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Formatter;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
@@ -62,11 +61,7 @@ import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public final class PlayerHelper {
|
||||
private static final StringBuilder STRING_BUILDER = new StringBuilder();
|
||||
private static final Formatter STRING_FORMATTER =
|
||||
new Formatter(STRING_BUILDER, Locale.getDefault());
|
||||
private static final NumberFormat SPEED_FORMATTER = new DecimalFormat("0.##x");
|
||||
private static final NumberFormat PITCH_FORMATTER = new DecimalFormat("##%");
|
||||
private static final FormattersProvider FORMATTERS_PROVIDER = new FormattersProvider();
|
||||
|
||||
@Retention(SOURCE)
|
||||
@IntDef({AUTOPLAY_TYPE_ALWAYS, AUTOPLAY_TYPE_WIFI,
|
||||
@@ -89,9 +84,11 @@ public final class PlayerHelper {
|
||||
private PlayerHelper() {
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Exposed helpers
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// region Exposed helpers
|
||||
|
||||
public static void resetFormat() {
|
||||
FORMATTERS_PROVIDER.reset();
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static String getTimeString(final int milliSeconds) {
|
||||
@@ -100,35 +97,24 @@ public final class PlayerHelper {
|
||||
final int hours = (milliSeconds % 86400000) / 3600000;
|
||||
final int days = (milliSeconds % (86400000 * 7)) / 86400000;
|
||||
|
||||
STRING_BUILDER.setLength(0);
|
||||
return (days > 0
|
||||
? STRING_FORMATTER.format("%d:%02d:%02d:%02d", days, hours, minutes, seconds)
|
||||
: hours > 0
|
||||
? STRING_FORMATTER.format("%d:%02d:%02d", hours, minutes, seconds)
|
||||
: STRING_FORMATTER.format("%02d:%02d", minutes, seconds)
|
||||
).toString();
|
||||
final Formatters formatters = FORMATTERS_PROVIDER.formatters();
|
||||
if (days > 0) {
|
||||
return formatters.stringFormat("%d:%02d:%02d:%02d", days, hours, minutes, seconds);
|
||||
}
|
||||
|
||||
return hours > 0
|
||||
? formatters.stringFormat("%d:%02d:%02d", hours, minutes, seconds)
|
||||
: formatters.stringFormat("%02d:%02d", minutes, seconds);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static String formatSpeed(final double speed) {
|
||||
return SPEED_FORMATTER.format(speed);
|
||||
return FORMATTERS_PROVIDER.formatters().speed().format(speed);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static String formatPitch(final double pitch) {
|
||||
return PITCH_FORMATTER.format(pitch);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public static String subtitleMimeTypesOf(@NonNull final MediaFormat format) {
|
||||
switch (format) {
|
||||
case VTT:
|
||||
return MimeTypes.TEXT_VTT;
|
||||
case TTML:
|
||||
return MimeTypes.APPLICATION_TTML;
|
||||
default:
|
||||
throw new IllegalArgumentException("Unrecognized mime type: " + format.name());
|
||||
}
|
||||
return FORMATTERS_PROVIDER.formatters().pitch().format(pitch);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@@ -219,9 +205,8 @@ public final class PlayerHelper {
|
||||
? null : getAutoQueuedSinglePlayQueue(autoQueueItems.get(0));
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Settings Resolution
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// endregion
|
||||
// region Resolution
|
||||
|
||||
public static boolean isResumeAfterAudioFocusGain(@NonNull final Context context) {
|
||||
return getPreferences(context)
|
||||
@@ -405,9 +390,8 @@ public final class PlayerHelper {
|
||||
return Integer.parseInt(preferredIntervalBytes) * 1024;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Private helpers
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// endregion
|
||||
// region Private helpers
|
||||
|
||||
@NonNull
|
||||
private static SharedPreferences getPreferences(@NonNull final Context context) {
|
||||
@@ -427,9 +411,8 @@ public final class PlayerHelper {
|
||||
}
|
||||
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// Utils used by player
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
// endregion
|
||||
// region Utils used by player
|
||||
|
||||
@RepeatMode
|
||||
public static int nextRepeatMode(@RepeatMode final int repeatMode) {
|
||||
@@ -503,4 +486,43 @@ public final class PlayerHelper {
|
||||
player.getContext().getString(R.string.seek_duration_key),
|
||||
player.getContext().getString(R.string.seek_duration_default_value))));
|
||||
}
|
||||
|
||||
// endregion
|
||||
// region Format
|
||||
|
||||
static class FormattersProvider {
|
||||
private Formatters formatters;
|
||||
|
||||
public Formatters formatters() {
|
||||
if (formatters == null) {
|
||||
formatters = Formatters.create();
|
||||
}
|
||||
return formatters;
|
||||
}
|
||||
|
||||
public void reset() {
|
||||
formatters = null;
|
||||
}
|
||||
}
|
||||
|
||||
record Formatters(
|
||||
Locale locale,
|
||||
NumberFormat speed,
|
||||
NumberFormat pitch) {
|
||||
|
||||
static Formatters create() {
|
||||
final Locale locale = Localization.getAppLocale();
|
||||
final DecimalFormatSymbols dfs = DecimalFormatSymbols.getInstance(locale);
|
||||
return new Formatters(
|
||||
locale,
|
||||
new DecimalFormat("0.##x", dfs),
|
||||
new DecimalFormat("##%", dfs));
|
||||
}
|
||||
|
||||
String stringFormat(final String format, final Object... args) {
|
||||
return String.format(locale, format, args);
|
||||
}
|
||||
}
|
||||
|
||||
// endregion
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ import org.schabi.newpipe.player.PlayerType;
|
||||
import org.schabi.newpipe.player.event.PlayerServiceEventListener;
|
||||
import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
import org.schabi.newpipe.util.NavigationHelper;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class PlayerHolder {
|
||||
|
||||
@@ -44,7 +48,16 @@ public final class PlayerHolder {
|
||||
private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection();
|
||||
private boolean bound;
|
||||
@Nullable private PlayerService playerService;
|
||||
@Nullable private Player player;
|
||||
|
||||
private Optional<Player> getPlayer() {
|
||||
return Optional.ofNullable(playerService)
|
||||
.flatMap(s -> Optional.ofNullable(s.getPlayer()));
|
||||
}
|
||||
|
||||
private Optional<PlayQueue> getPlayQueue() {
|
||||
// player play queue might be null e.g. while player is starting
|
||||
return getPlayer().flatMap(p -> Optional.ofNullable(p.getPlayQueue()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current {@link PlayerType} of the {@link PlayerService} service,
|
||||
@@ -54,21 +67,15 @@ public final class PlayerHolder {
|
||||
*/
|
||||
@Nullable
|
||||
public PlayerType getType() {
|
||||
if (player == null) {
|
||||
return null;
|
||||
}
|
||||
return player.getPlayerType();
|
||||
return getPlayer().map(Player::getPlayerType).orElse(null);
|
||||
}
|
||||
|
||||
public boolean isPlaying() {
|
||||
if (player == null) {
|
||||
return false;
|
||||
}
|
||||
return player.isPlaying();
|
||||
return getPlayer().map(Player::isPlaying).orElse(false);
|
||||
}
|
||||
|
||||
public boolean isPlayerOpen() {
|
||||
return player != null;
|
||||
return getPlayer().isPresent();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,7 +84,7 @@ public final class PlayerHolder {
|
||||
* @return true only if the player is open and its play queue is ready (i.e. it is not null)
|
||||
*/
|
||||
public boolean isPlayQueueReady() {
|
||||
return player != null && player.getPlayQueue() != null;
|
||||
return getPlayQueue().isPresent();
|
||||
}
|
||||
|
||||
public boolean isBound() {
|
||||
@@ -85,18 +92,11 @@ public final class PlayerHolder {
|
||||
}
|
||||
|
||||
public int getQueueSize() {
|
||||
if (player == null || player.getPlayQueue() == null) {
|
||||
// player play queue might be null e.g. while player is starting
|
||||
return 0;
|
||||
}
|
||||
return player.getPlayQueue().size();
|
||||
return getPlayQueue().map(PlayQueue::size).orElse(0);
|
||||
}
|
||||
|
||||
public int getQueuePosition() {
|
||||
if (player == null || player.getPlayQueue() == null) {
|
||||
return 0;
|
||||
}
|
||||
return player.getPlayQueue().getIndex();
|
||||
return getPlayQueue().map(PlayQueue::getIndex).orElse(0);
|
||||
}
|
||||
|
||||
public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) {
|
||||
@@ -107,9 +107,10 @@ public final class PlayerHolder {
|
||||
}
|
||||
|
||||
// Force reload data from service
|
||||
if (player != null) {
|
||||
listener.onServiceConnected(player, playerService, false);
|
||||
if (playerService != null) {
|
||||
listener.onServiceConnected(playerService);
|
||||
startPlayerListener();
|
||||
// ^ will call listener.onPlayerConnected() down the line if there is an active player
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +122,9 @@ public final class PlayerHolder {
|
||||
|
||||
public void startService(final boolean playAfterConnect,
|
||||
final PlayerServiceExtendedEventListener newListener) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "startService() called with playAfterConnect=" + playAfterConnect);
|
||||
}
|
||||
final Context context = getCommonContext();
|
||||
setListener(newListener);
|
||||
if (bound) {
|
||||
@@ -130,14 +134,24 @@ public final class PlayerHolder {
|
||||
// and NullPointerExceptions inside the service because the service will be
|
||||
// bound twice. Prevent it with unbinding first
|
||||
unbind(context);
|
||||
ContextCompat.startForegroundService(context, new Intent(context, PlayerService.class));
|
||||
final Intent intent = new Intent(context, PlayerService.class);
|
||||
intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true);
|
||||
ContextCompat.startForegroundService(context, intent);
|
||||
serviceConnection.doPlayAfterConnect(playAfterConnect);
|
||||
bind(context);
|
||||
}
|
||||
|
||||
public void stopService() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "stopService() called");
|
||||
}
|
||||
if (playerService != null) {
|
||||
playerService.destroyPlayerAndStopService();
|
||||
}
|
||||
final Context context = getCommonContext();
|
||||
unbind(context);
|
||||
// destroyPlayerAndStopService() already runs the next line of code, but run it again just
|
||||
// to make sure to stop the service even if playerService is null by any chance.
|
||||
context.stopService(new Intent(context, PlayerService.class));
|
||||
}
|
||||
|
||||
@@ -145,6 +159,11 @@ public final class PlayerHolder {
|
||||
|
||||
private boolean playAfterConnect = false;
|
||||
|
||||
/**
|
||||
* @param playAfterConnection Sets the value of `playAfterConnect` to pass to the {@link
|
||||
* PlayerServiceExtendedEventListener#onPlayerConnected(Player, boolean)} the next time it
|
||||
* is called. The value of `playAfterConnect` will be reset to false after that.
|
||||
*/
|
||||
public void doPlayAfterConnect(final boolean playAfterConnection) {
|
||||
this.playAfterConnect = playAfterConnection;
|
||||
}
|
||||
@@ -167,11 +186,15 @@ public final class PlayerHolder {
|
||||
final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service;
|
||||
|
||||
playerService = localBinder.getService();
|
||||
player = localBinder.getPlayer();
|
||||
if (listener != null) {
|
||||
listener.onServiceConnected(player, playerService, playAfterConnect);
|
||||
listener.onServiceConnected(playerService);
|
||||
}
|
||||
startPlayerListener();
|
||||
// ^ will call listener.onPlayerConnected() down the line if there is an active player
|
||||
|
||||
// notify the main activity that binding the service has completed, so that it can
|
||||
// open the bottom mini-player
|
||||
NavigationHelper.sendPlayerStartedEvent(localBinder.getService());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,15 +202,28 @@ public final class PlayerHolder {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "bind() called");
|
||||
}
|
||||
|
||||
final Intent serviceIntent = new Intent(context, PlayerService.class);
|
||||
bound = context.bindService(serviceIntent, serviceConnection,
|
||||
Context.BIND_AUTO_CREATE);
|
||||
// BIND_AUTO_CREATE starts the service if it's not already running
|
||||
bound = bind(context, Context.BIND_AUTO_CREATE);
|
||||
if (!bound) {
|
||||
context.unbindService(serviceConnection);
|
||||
}
|
||||
}
|
||||
|
||||
public void tryBindIfNeeded(final Context context) {
|
||||
if (!bound) {
|
||||
// flags=0 means the service will not be started if it does not already exist. In this
|
||||
// case the return value is not useful, as a value of "true" does not really indicate
|
||||
// that the service is going to be bound.
|
||||
bind(context, 0);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean bind(final Context context, final int flags) {
|
||||
final Intent serviceIntent = new Intent(context, PlayerService.class);
|
||||
serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION);
|
||||
return context.bindService(serviceIntent, serviceConnection, flags);
|
||||
}
|
||||
|
||||
private void unbind(final Context context) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "unbind() called");
|
||||
@@ -198,25 +234,32 @@ public final class PlayerHolder {
|
||||
bound = false;
|
||||
stopPlayerListener();
|
||||
playerService = null;
|
||||
player = null;
|
||||
if (listener != null) {
|
||||
listener.onPlayerDisconnected();
|
||||
listener.onServiceDisconnected();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void startPlayerListener() {
|
||||
if (player != null) {
|
||||
player.setFragmentListener(internalListener);
|
||||
if (playerService != null) {
|
||||
// setting the player listener will take care of calling relevant callbacks if the
|
||||
// player in the service is (not) already active, also see playerStateListener below
|
||||
playerService.setPlayerListener(playerStateListener);
|
||||
}
|
||||
getPlayer().ifPresent(p -> p.setFragmentListener(internalListener));
|
||||
}
|
||||
|
||||
private void stopPlayerListener() {
|
||||
if (player != null) {
|
||||
player.removeFragmentListener(internalListener);
|
||||
if (playerService != null) {
|
||||
playerService.setPlayerListener(null);
|
||||
}
|
||||
getPlayer().ifPresent(p -> p.removeFragmentListener(internalListener));
|
||||
}
|
||||
|
||||
/**
|
||||
* This listener will be held by the players created by {@link PlayerService}.
|
||||
*/
|
||||
private final PlayerServiceEventListener internalListener =
|
||||
new PlayerServiceEventListener() {
|
||||
@Override
|
||||
@@ -303,4 +346,25 @@ public final class PlayerHolder {
|
||||
unbind(getCommonContext());
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This listener will be held by bound {@link PlayerService}s to notify of the player starting
|
||||
* or stopping. This is necessary since the service outlives the player e.g. to answer Android
|
||||
* Auto media browser queries.
|
||||
*/
|
||||
private final Consumer<Player> playerStateListener = (@Nullable final Player player) -> {
|
||||
if (listener != null) {
|
||||
if (player == null) {
|
||||
// player.fragmentListener=null is already done by player.stopActivityBinding(),
|
||||
// which is called by player.destroy(), which is in turn called by PlayerService
|
||||
// before setting its player to null
|
||||
listener.onPlayerDisconnected();
|
||||
} else {
|
||||
listener.onPlayerConnected(player, serviceConnection.playAfterConnect);
|
||||
// reset the value of playAfterConnect: if it was true before, it is now "consumed"
|
||||
serviceConnection.playAfterConnect = false;
|
||||
player.setFragmentListener(internalListener);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
package org.schabi.newpipe.player.mediabrowser
|
||||
|
||||
import org.schabi.newpipe.BuildConfig
|
||||
import org.schabi.newpipe.extractor.InfoItem.InfoType
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
|
||||
internal const val ID_AUTHORITY = BuildConfig.APPLICATION_ID
|
||||
internal const val ID_ROOT = "//$ID_AUTHORITY"
|
||||
internal const val ID_BOOKMARKS = "playlists"
|
||||
internal const val ID_HISTORY = "history"
|
||||
internal const val ID_INFO_ITEM = "item"
|
||||
|
||||
internal const val ID_LOCAL = "local"
|
||||
internal const val ID_REMOTE = "remote"
|
||||
internal const val ID_URL = "url"
|
||||
internal const val ID_STREAM = "stream"
|
||||
internal const val ID_PLAYLIST = "playlist"
|
||||
internal const val ID_CHANNEL = "channel"
|
||||
|
||||
internal fun infoItemTypeToString(type: InfoType): String {
|
||||
return when (type) {
|
||||
InfoType.STREAM -> ID_STREAM
|
||||
InfoType.PLAYLIST -> ID_PLAYLIST
|
||||
InfoType.CHANNEL -> ID_CHANNEL
|
||||
else -> throw IllegalStateException("Unexpected value: $type")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun infoItemTypeFromString(type: String): InfoType {
|
||||
return when (type) {
|
||||
ID_STREAM -> InfoType.STREAM
|
||||
ID_PLAYLIST -> InfoType.PLAYLIST
|
||||
ID_CHANNEL -> InfoType.CHANNEL
|
||||
else -> throw IllegalStateException("Unexpected value: $type")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun parseError(mediaId: String): ContentNotAvailableException {
|
||||
return ContentNotAvailableException("Failed to parse media ID $mediaId")
|
||||
}
|
||||
@@ -0,0 +1,421 @@
|
||||
package org.schabi.newpipe.player.mediabrowser
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.util.Log
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT
|
||||
import androidx.media.MediaBrowserServiceCompat.Result
|
||||
import androidx.media.utils.MediaConstants
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.MainActivity.DEBUG
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntry
|
||||
import org.schabi.newpipe.database.playlist.PlaylistLocalItem
|
||||
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
|
||||
import org.schabi.newpipe.extractor.InfoItem
|
||||
import org.schabi.newpipe.extractor.InfoItem.InfoType
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
|
||||
import org.schabi.newpipe.extractor.search.SearchInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.bookmark.MergedPlaylistManager
|
||||
import org.schabi.newpipe.local.playlist.LocalPlaylistManager
|
||||
import org.schabi.newpipe.local.playlist.RemotePlaylistManager
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
import org.schabi.newpipe.util.ServiceHelper
|
||||
import org.schabi.newpipe.util.image.ImageStrategy
|
||||
import java.util.function.Consumer
|
||||
|
||||
/**
|
||||
* This class is used to cleanly separate the Service implementation (in
|
||||
* [org.schabi.newpipe.player.PlayerService]) and the media browser implementation (in this file).
|
||||
*
|
||||
* @param notifyChildrenChanged takes the parent id of the children that changed
|
||||
*/
|
||||
class MediaBrowserImpl(
|
||||
private val context: Context,
|
||||
notifyChildrenChanged: Consumer<String>, // parentId
|
||||
) {
|
||||
private val packageValidator = PackageValidator(context)
|
||||
private val database = NewPipeDatabase.getInstance(context)
|
||||
private var disposables = CompositeDisposable()
|
||||
|
||||
init {
|
||||
// this will listen to changes in the bookmarks until this MediaBrowserImpl is dispose()d
|
||||
disposables.add(
|
||||
getMergedPlaylists().subscribe { notifyChildrenChanged.accept(ID_BOOKMARKS) }
|
||||
)
|
||||
}
|
||||
|
||||
//region Cleanup
|
||||
fun dispose() {
|
||||
disposables.dispose()
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region onGetRoot
|
||||
fun onGetRoot(
|
||||
clientPackageName: String,
|
||||
clientUid: Int,
|
||||
rootHints: Bundle?
|
||||
): MediaBrowserServiceCompat.BrowserRoot? {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onGetRoot($clientPackageName, $clientUid, $rootHints)")
|
||||
}
|
||||
|
||||
if (!packageValidator.isKnownCaller(clientPackageName, clientUid)) {
|
||||
// this is a caller we can't trust (see PackageValidator's rules taken from uamp)
|
||||
return null
|
||||
}
|
||||
|
||||
if (rootHints?.getBoolean(EXTRA_RECENT, false) == true) {
|
||||
// the system is asking for a root to do media resumption, but we can't handle that yet,
|
||||
// see https://developer.android.com/media/implement/surfaces/mobile#mediabrowserservice_implementation
|
||||
return null
|
||||
}
|
||||
|
||||
val extras = Bundle()
|
||||
extras.putBoolean(
|
||||
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true
|
||||
)
|
||||
return MediaBrowserServiceCompat.BrowserRoot(ID_ROOT, extras)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region onLoadChildren
|
||||
fun onLoadChildren(parentId: String, result: Result<List<MediaBrowserCompat.MediaItem>>) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onLoadChildren($parentId)")
|
||||
}
|
||||
|
||||
result.detach() // allows sendResult() to happen later
|
||||
disposables.add(
|
||||
onLoadChildren(parentId)
|
||||
.subscribe(
|
||||
{ result.sendResult(it) },
|
||||
{ throwable ->
|
||||
// null indicates an error, see the docs of MediaSessionCompat.onSearch()
|
||||
result.sendResult(null)
|
||||
Log.e(TAG, "onLoadChildren error for parentId=$parentId: $throwable")
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun onLoadChildren(parentId: String): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
try {
|
||||
val parentIdUri = parentId.toUri()
|
||||
val path = ArrayList(parentIdUri.pathSegments)
|
||||
|
||||
if (path.isEmpty()) {
|
||||
return Single.just(
|
||||
listOf(
|
||||
createRootMediaItem(
|
||||
ID_BOOKMARKS,
|
||||
context.resources.getString(R.string.tab_bookmarks_short),
|
||||
R.drawable.ic_bookmark_white
|
||||
),
|
||||
createRootMediaItem(
|
||||
ID_HISTORY,
|
||||
context.resources.getString(R.string.action_history),
|
||||
R.drawable.ic_history_white
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
when (/*val uriType = */path.removeAt(0)) {
|
||||
ID_BOOKMARKS -> {
|
||||
if (path.isEmpty()) {
|
||||
return populateBookmarks()
|
||||
}
|
||||
if (path.size == 2) {
|
||||
val localOrRemote = path[0]
|
||||
val playlistId = path[1].toLong()
|
||||
if (localOrRemote == ID_LOCAL) {
|
||||
return populateLocalPlaylist(playlistId)
|
||||
} else if (localOrRemote == ID_REMOTE) {
|
||||
return populateRemotePlaylist(playlistId)
|
||||
}
|
||||
}
|
||||
Log.w(TAG, "Unknown playlist URI: $parentId")
|
||||
throw parseError(parentId)
|
||||
}
|
||||
|
||||
ID_HISTORY -> return populateHistory()
|
||||
|
||||
else -> throw parseError(parentId)
|
||||
}
|
||||
} catch (e: ContentNotAvailableException) {
|
||||
return Single.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRootMediaItem(
|
||||
mediaId: String?,
|
||||
folderName: String?,
|
||||
@DrawableRes iconResId: Int
|
||||
): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder.setMediaId(mediaId)
|
||||
builder.setTitle(folderName)
|
||||
val resources = context.resources
|
||||
builder.setIconUri(
|
||||
Uri.Builder()
|
||||
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
|
||||
.authority(resources.getResourcePackageName(iconResId))
|
||||
.appendPath(resources.getResourceTypeName(iconResId))
|
||||
.appendPath(resources.getResourceEntryName(iconResId))
|
||||
.build()
|
||||
)
|
||||
|
||||
val extras = Bundle()
|
||||
extras.putString(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
|
||||
context.getString(R.string.app_name)
|
||||
)
|
||||
builder.setExtras(extras)
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun createPlaylistMediaItem(playlist: PlaylistLocalItem): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder
|
||||
.setMediaId(createMediaIdForInfoItem(playlist is PlaylistRemoteEntity, playlist.uid))
|
||||
.setTitle(playlist.orderingName)
|
||||
.setIconUri(imageUriOrNullIfDisabled(playlist.thumbnailUrl))
|
||||
|
||||
val extras = Bundle()
|
||||
extras.putString(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
|
||||
context.resources.getString(R.string.tab_bookmarks),
|
||||
)
|
||||
builder.setExtras(extras)
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createInfoItemMediaItem(item: InfoItem): MediaBrowserCompat.MediaItem? {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder.setMediaId(createMediaIdForInfoItem(item))
|
||||
.setTitle(item.name)
|
||||
|
||||
when (item.infoType) {
|
||||
InfoType.STREAM -> builder.setSubtitle((item as StreamInfoItem).uploaderName)
|
||||
InfoType.PLAYLIST -> builder.setSubtitle((item as PlaylistInfoItem).uploaderName)
|
||||
InfoType.CHANNEL -> builder.setSubtitle((item as ChannelInfoItem).description)
|
||||
else -> return null
|
||||
}
|
||||
|
||||
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
|
||||
builder.setIconUri(imageUriOrNullIfDisabled(it))
|
||||
}
|
||||
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildMediaId(): Uri.Builder {
|
||||
return Uri.Builder().authority(ID_AUTHORITY)
|
||||
}
|
||||
|
||||
private fun buildPlaylistMediaId(playlistType: String?): Uri.Builder {
|
||||
return buildMediaId()
|
||||
.appendPath(ID_BOOKMARKS)
|
||||
.appendPath(playlistType)
|
||||
}
|
||||
|
||||
private fun buildLocalPlaylistItemMediaId(isRemote: Boolean, playlistId: Long): Uri.Builder {
|
||||
return buildPlaylistMediaId(if (isRemote) ID_REMOTE else ID_LOCAL)
|
||||
.appendPath(playlistId.toString())
|
||||
}
|
||||
|
||||
private fun buildInfoItemMediaId(item: InfoItem): Uri.Builder {
|
||||
return buildMediaId()
|
||||
.appendPath(ID_INFO_ITEM)
|
||||
.appendPath(infoItemTypeToString(item.infoType))
|
||||
.appendPath(item.serviceId.toString())
|
||||
.appendQueryParameter(ID_URL, item.url)
|
||||
}
|
||||
|
||||
private fun createMediaIdForInfoItem(isRemote: Boolean, playlistId: Long): String {
|
||||
return buildLocalPlaylistItemMediaId(isRemote, playlistId)
|
||||
.build().toString()
|
||||
}
|
||||
|
||||
private fun createLocalPlaylistStreamMediaItem(
|
||||
playlistId: Long,
|
||||
item: PlaylistStreamEntry,
|
||||
index: Int,
|
||||
): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index))
|
||||
.setTitle(item.streamEntity.title)
|
||||
.setSubtitle(item.streamEntity.uploader)
|
||||
.setIconUri(imageUriOrNullIfDisabled(item.streamEntity.thumbnailUrl))
|
||||
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun createRemotePlaylistStreamMediaItem(
|
||||
playlistId: Long,
|
||||
item: StreamInfoItem,
|
||||
index: Int,
|
||||
): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index))
|
||||
.setTitle(item.name)
|
||||
.setSubtitle(item.uploaderName)
|
||||
|
||||
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
|
||||
builder.setIconUri(imageUriOrNullIfDisabled(it))
|
||||
}
|
||||
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun createMediaIdForPlaylistIndex(
|
||||
isRemote: Boolean,
|
||||
playlistId: Long,
|
||||
index: Int,
|
||||
): String {
|
||||
return buildLocalPlaylistItemMediaId(isRemote, playlistId)
|
||||
.appendPath(index.toString())
|
||||
.build().toString()
|
||||
}
|
||||
|
||||
private fun createMediaIdForInfoItem(item: InfoItem): String {
|
||||
return buildInfoItemMediaId(item).build().toString()
|
||||
}
|
||||
|
||||
private fun populateHistory(): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
val history = database.streamHistoryDAO().getHistory().firstOrError()
|
||||
return history.map { items ->
|
||||
items.map { this.createHistoryMediaItem(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun createHistoryMediaItem(streamHistoryEntry: StreamHistoryEntry): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
val mediaId = buildMediaId()
|
||||
.appendPath(ID_HISTORY)
|
||||
.appendPath(streamHistoryEntry.streamId.toString())
|
||||
.build().toString()
|
||||
builder.setMediaId(mediaId)
|
||||
.setTitle(streamHistoryEntry.streamEntity.title)
|
||||
.setSubtitle(streamHistoryEntry.streamEntity.uploader)
|
||||
.setIconUri(imageUriOrNullIfDisabled(streamHistoryEntry.streamEntity.thumbnailUrl))
|
||||
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun getMergedPlaylists(): Flowable<MutableList<PlaylistLocalItem>> {
|
||||
return MergedPlaylistManager.getMergedOrderedPlaylists(
|
||||
LocalPlaylistManager(database),
|
||||
RemotePlaylistManager(database)
|
||||
)
|
||||
}
|
||||
|
||||
private fun populateBookmarks(): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
val playlists = getMergedPlaylists().firstOrError()
|
||||
return playlists.map { playlist ->
|
||||
playlist.map { this.createPlaylistMediaItem(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun populateLocalPlaylist(playlistId: Long): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
val playlist = LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError()
|
||||
return playlist.map { items ->
|
||||
items.mapIndexed { index, item ->
|
||||
createLocalPlaylistStreamMediaItem(playlistId, item, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun populateRemotePlaylist(playlistId: Long): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
return RemotePlaylistManager(database).getPlaylist(playlistId).firstOrError()
|
||||
.flatMap { ExtractorHelper.getPlaylistInfo(it.serviceId, it.url, false) }
|
||||
.map {
|
||||
// ignore it.errors, i.e. ignore errors about specific items, since there would
|
||||
// be no way to show the error properly in Android Auto anyway
|
||||
it.relatedItems.mapIndexed { index, item ->
|
||||
createRemotePlaylistStreamMediaItem(playlistId, item, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Search
|
||||
fun onSearch(
|
||||
query: String,
|
||||
result: Result<List<MediaBrowserCompat.MediaItem>>
|
||||
) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onSearch($query)")
|
||||
}
|
||||
|
||||
result.detach() // allows sendResult() to happen later
|
||||
disposables.add(
|
||||
searchMusicBySongTitle(query)
|
||||
// ignore it.errors, i.e. ignore errors about specific items, since there would
|
||||
// be no way to show the error properly in Android Auto anyway
|
||||
.map { it.relatedItems.mapNotNull(this::createInfoItemMediaItem) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe(
|
||||
{ result.sendResult(it) },
|
||||
{ throwable ->
|
||||
// null indicates an error, see the docs of MediaSessionCompat.onSearch()
|
||||
result.sendResult(null)
|
||||
Log.e(TAG, "Search error for query=\"$query\": $throwable")
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun searchMusicBySongTitle(query: String?): Single<SearchInfo> {
|
||||
val serviceId = ServiceHelper.getSelectedServiceId(context)
|
||||
return ExtractorHelper.searchFor(serviceId, query, listOf(), "")
|
||||
}
|
||||
//endregion
|
||||
|
||||
companion object {
|
||||
private val TAG: String = MediaBrowserImpl::class.java.getSimpleName()
|
||||
|
||||
fun imageUriOrNullIfDisabled(url: String?): Uri? {
|
||||
return if (ImageStrategy.shouldLoadImages()) {
|
||||
url?.toUri()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package org.schabi.newpipe.player.mediabrowser
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.ResultReceiver
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toUri
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.PlaybackPreparer
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.MainActivity
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.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(
|
||||
ContextCompat.getString(context, R.string.content_not_supported),
|
||||
PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED
|
||||
)
|
||||
}
|
||||
|
||||
private fun onPrepareError() {
|
||||
setMediaSessionError.accept(
|
||||
ContextCompat.getString(context, 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 = mediaId.toUri()
|
||||
val path = ArrayList(mediaIdUri.pathSegments)
|
||||
if (path.isEmpty()) {
|
||||
throw parseError(mediaId)
|
||||
}
|
||||
|
||||
return when (/*val uriType = */path.removeAt(0)) {
|
||||
ID_BOOKMARKS -> extractPlayQueueFromPlaylistMediaId(
|
||||
mediaId,
|
||||
path,
|
||||
mediaIdUri.getQueryParameter(ID_URL)
|
||||
)
|
||||
|
||||
ID_HISTORY -> extractPlayQueueFromHistoryMediaId(mediaId, path)
|
||||
|
||||
ID_INFO_ITEM -> extractPlayQueueFromInfoItemMediaId(
|
||||
mediaId,
|
||||
path,
|
||||
mediaIdUri.getQueryParameter(ID_URL) ?: throw parseError(mediaId)
|
||||
)
|
||||
|
||||
else -> throw parseError(mediaId)
|
||||
}
|
||||
} catch (e: ContentNotAvailableException) {
|
||||
return Single.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContentNotAvailableException::class)
|
||||
private fun extractPlayQueueFromPlaylistMediaId(
|
||||
mediaId: String,
|
||||
path: MutableList<String>,
|
||||
url: String?,
|
||||
): Single<PlayQueue> {
|
||||
if (path.isEmpty()) {
|
||||
throw parseError(mediaId)
|
||||
}
|
||||
|
||||
when (val playlistType = path.removeAt(0)) {
|
||||
ID_LOCAL, ID_REMOTE -> {
|
||||
if (path.size != 2) {
|
||||
throw parseError(mediaId)
|
||||
}
|
||||
val playlistId = path[0].toLong()
|
||||
val index = path[1].toInt()
|
||||
return if (playlistType == ID_LOCAL)
|
||||
extractLocalPlayQueue(playlistId, index)
|
||||
else
|
||||
extractRemotePlayQueue(playlistId, index)
|
||||
}
|
||||
|
||||
ID_URL -> {
|
||||
if (path.size != 1 || url == null) {
|
||||
throw parseError(mediaId)
|
||||
}
|
||||
|
||||
val serviceId = path[0].toInt()
|
||||
return ExtractorHelper.getPlaylistInfo(serviceId, url, false)
|
||||
.map { PlaylistPlayQueue(it) }
|
||||
}
|
||||
|
||||
else -> throw parseError(mediaId)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContentNotAvailableException::class)
|
||||
private fun extractPlayQueueFromHistoryMediaId(
|
||||
mediaId: String,
|
||||
path: List<String>,
|
||||
): Single<PlayQueue> {
|
||||
if (path.size != 1) {
|
||||
throw parseError(mediaId)
|
||||
}
|
||||
|
||||
val streamId = path[0].toLong()
|
||||
return database.streamHistoryDAO().getHistory()
|
||||
.firstOrError()
|
||||
.map { items ->
|
||||
val infoItems = items
|
||||
.filter { it.streamId == streamId }
|
||||
.map { it.toStreamInfoItem() }
|
||||
SinglePlayQueue(infoItems, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContentNotAvailableException::class)
|
||||
private fun extractPlayQueueFromInfoItemMediaId(
|
||||
mediaId: String,
|
||||
path: List<String>,
|
||||
url: String,
|
||||
): Single<PlayQueue> {
|
||||
if (path.size != 2) {
|
||||
throw parseError(mediaId)
|
||||
}
|
||||
|
||||
val serviceId = path[1].toInt()
|
||||
return when (/*val infoItemType = */infoItemTypeFromString(path[0])) {
|
||||
InfoType.STREAM -> ExtractorHelper.getStreamInfo(serviceId, url, false)
|
||||
.map { SinglePlayQueue(it) }
|
||||
|
||||
InfoType.PLAYLIST -> ExtractorHelper.getPlaylistInfo(serviceId, url, false)
|
||||
.map { PlaylistPlayQueue(it) }
|
||||
|
||||
InfoType.CHANNEL -> ExtractorHelper.getChannelInfo(serviceId, url, false)
|
||||
.map { info ->
|
||||
val playableTab = info.tabs
|
||||
.firstOrNull { ChannelTabHelper.isStreamsTab(it) }
|
||||
?: throw ContentNotAvailableException("No streams tab found")
|
||||
return@map ChannelTabPlayQueue(serviceId, ListLinkHandler(playableTab))
|
||||
}
|
||||
|
||||
else -> throw parseError(mediaId)
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
companion object {
|
||||
private val TAG = MediaBrowserPlaybackPreparer::class.simpleName
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/*
|
||||
* Copyright 2018 Google Inc. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// THIS FILE WAS TAKEN FROM UAMP, EXCEPT FOR THINGS RELATED TO THE WHITELIST. UPDATE IT WHEN NEEDED.
|
||||
// https://github.com/android/uamp/blob/329a21b63c247e9bd35f6858d4fc0e448fa38603/common/src/main/java/com/example/android/uamp/media/PackageValidator.kt
|
||||
|
||||
package org.schabi.newpipe.player.mediabrowser
|
||||
|
||||
import android.Manifest.permission.MEDIA_CONTENT_CONTROL
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
||||
import android.content.pm.PackageInfo.REQUESTED_PERMISSION_GRANTED
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Process
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import org.schabi.newpipe.BuildConfig
|
||||
import java.security.MessageDigest
|
||||
import java.security.NoSuchAlgorithmException
|
||||
|
||||
/**
|
||||
* Validates that the calling package is authorized to browse a [MediaBrowserServiceCompat].
|
||||
*
|
||||
* The list of allowed signing certificates and their corresponding package names is defined in
|
||||
* res/xml/allowed_media_browser_callers.xml.
|
||||
*
|
||||
* If you want to add a new caller to allowed_media_browser_callers.xml and you don't know
|
||||
* its signature, this class will print to logcat (INFO level) a message with the proper
|
||||
* xml tags to add to allow the caller.
|
||||
*
|
||||
* For more information, see res/xml/allowed_media_browser_callers.xml.
|
||||
*/
|
||||
internal class PackageValidator(context: Context) {
|
||||
private val context: Context = context.applicationContext
|
||||
private val packageManager: PackageManager = this.context.packageManager
|
||||
private val platformSignature: String = getSystemSignature()
|
||||
private val callerChecked = mutableMapOf<String, Pair<Int, Boolean>>()
|
||||
|
||||
/**
|
||||
* Checks whether the caller attempting to connect to a [MediaBrowserServiceCompat] is known.
|
||||
* See [MusicService.onGetRoot] for where this is utilized.
|
||||
*
|
||||
* @param callingPackage The package name of the caller.
|
||||
* @param callingUid The user id of the caller.
|
||||
* @return `true` if the caller is known, `false` otherwise.
|
||||
*/
|
||||
fun isKnownCaller(callingPackage: String, callingUid: Int): Boolean {
|
||||
// If the caller has already been checked, return the previous result here.
|
||||
val (checkedUid, checkResult) = callerChecked[callingPackage] ?: Pair(0, false)
|
||||
if (checkedUid == callingUid) {
|
||||
return checkResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Because some of these checks can be slow, we save the results in [callerChecked] after
|
||||
* this code is run.
|
||||
*
|
||||
* In particular, there's little reason to recompute the calling package's certificate
|
||||
* signature (SHA-256) each call.
|
||||
*
|
||||
* This is safe to do as we know the UID matches the package's UID (from the check above),
|
||||
* and app UIDs are set at install time. Additionally, a package name + UID is guaranteed to
|
||||
* be constant until a reboot. (After a reboot then a previously assigned UID could be
|
||||
* reassigned.)
|
||||
*/
|
||||
|
||||
// Build the caller info for the rest of the checks here.
|
||||
val callerPackageInfo = buildCallerInfo(callingPackage)
|
||||
?: throw IllegalStateException("Caller wasn't found in the system?")
|
||||
|
||||
// Verify that things aren't ... broken. (This test should always pass.)
|
||||
if (callerPackageInfo.uid != callingUid) {
|
||||
throw IllegalStateException("Caller's package UID doesn't match caller's actual UID?")
|
||||
}
|
||||
|
||||
val callerSignature = callerPackageInfo.signature
|
||||
|
||||
val isCallerKnown = when {
|
||||
// If it's our own app making the call, allow it.
|
||||
callingUid == Process.myUid() -> true
|
||||
// If the system is making the call, allow it.
|
||||
callingUid == Process.SYSTEM_UID -> true
|
||||
// If the app was signed by the same certificate as the platform itself, also allow it.
|
||||
callerSignature == platformSignature -> true
|
||||
/**
|
||||
* [MEDIA_CONTENT_CONTROL] permission is only available to system applications, and
|
||||
* while it isn't required to allow these apps to connect to a
|
||||
* [MediaBrowserServiceCompat], allowing this ensures optimal compatability with apps
|
||||
* such as Android TV and the Google Assistant.
|
||||
*/
|
||||
callerPackageInfo.permissions.contains(MEDIA_CONTENT_CONTROL) -> true
|
||||
/**
|
||||
* If the calling app has a notification listener it is able to retrieve notifications
|
||||
* and can connect to an active [MediaSessionCompat].
|
||||
*
|
||||
* It's not required to allow apps with a notification listener to
|
||||
* connect to your [MediaBrowserServiceCompat], but it does allow easy compatibility
|
||||
* with apps such as Wear OS.
|
||||
*/
|
||||
NotificationManagerCompat.getEnabledListenerPackages(this.context)
|
||||
.contains(callerPackageInfo.packageName) -> true
|
||||
|
||||
// If none of the previous checks succeeded, then the caller is unrecognized.
|
||||
else -> false
|
||||
}
|
||||
|
||||
if (!isCallerKnown) {
|
||||
logUnknownCaller(callerPackageInfo)
|
||||
}
|
||||
|
||||
// Save our work for next time.
|
||||
callerChecked[callingPackage] = Pair(callingUid, isCallerKnown)
|
||||
return isCallerKnown
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs an info level message with details of how to add a caller to the allowed callers list
|
||||
* when the app is debuggable.
|
||||
*/
|
||||
private fun logUnknownCaller(callerPackageInfo: CallerPackageInfo) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.w(TAG, "Unknown caller $callerPackageInfo")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [CallerPackageInfo] for a given package that can be used for all the
|
||||
* various checks that are performed before allowing an app to connect to a
|
||||
* [MediaBrowserServiceCompat].
|
||||
*/
|
||||
private fun buildCallerInfo(callingPackage: String): CallerPackageInfo? {
|
||||
val packageInfo = getPackageInfo(callingPackage) ?: return null
|
||||
|
||||
val appName = packageInfo.applicationInfo.loadLabel(packageManager).toString()
|
||||
val uid = packageInfo.applicationInfo.uid
|
||||
val signature = getSignature(packageInfo)
|
||||
|
||||
val requestedPermissions = packageInfo.requestedPermissions
|
||||
val permissionFlags = packageInfo.requestedPermissionsFlags
|
||||
val activePermissions = mutableSetOf<String>()
|
||||
requestedPermissions?.forEachIndexed { index, permission ->
|
||||
if (permissionFlags[index] and REQUESTED_PERMISSION_GRANTED != 0) {
|
||||
activePermissions += permission
|
||||
}
|
||||
}
|
||||
|
||||
return CallerPackageInfo(appName, callingPackage, uid, signature, activePermissions.toSet())
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the [PackageInfo] for a package name.
|
||||
* This requests both the signatures (for checking if an app is on the allow list) and
|
||||
* the app's permissions, which allow for more flexibility in the allow list.
|
||||
*
|
||||
* @return [PackageInfo] for the package name or null if it's not found.
|
||||
*/
|
||||
@Suppress("deprecation")
|
||||
@SuppressLint("PackageManagerGetSignatures")
|
||||
private fun getPackageInfo(callingPackage: String): PackageInfo? =
|
||||
packageManager.getPackageInfo(
|
||||
callingPackage,
|
||||
PackageManager.GET_SIGNATURES or PackageManager.GET_PERMISSIONS
|
||||
)
|
||||
|
||||
/**
|
||||
* Gets the signature of a given package's [PackageInfo].
|
||||
*
|
||||
* The "signature" is a SHA-256 hash of the public key of the signing certificate used by
|
||||
* the app.
|
||||
*
|
||||
* If the app is not found, or if the app does not have exactly one signature, this method
|
||||
* returns `null` as the signature.
|
||||
*/
|
||||
@Suppress("deprecation")
|
||||
private fun getSignature(packageInfo: PackageInfo): String? =
|
||||
if (packageInfo.signatures == null || packageInfo.signatures.size != 1) {
|
||||
// Security best practices dictate that an app should be signed with exactly one (1)
|
||||
// signature. Because of this, if there are multiple signatures, reject it.
|
||||
null
|
||||
} else {
|
||||
val certificate = packageInfo.signatures[0].toByteArray()
|
||||
getSignatureSha256(certificate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the Android platform signing key signature. This key is never null.
|
||||
*/
|
||||
private fun getSystemSignature(): String =
|
||||
getPackageInfo(ANDROID_PLATFORM)?.let { platformInfo ->
|
||||
getSignature(platformInfo)
|
||||
} ?: throw IllegalStateException("Platform signature not found")
|
||||
|
||||
/**
|
||||
* Creates a SHA-256 signature given a certificate byte array.
|
||||
*/
|
||||
private fun getSignatureSha256(certificate: ByteArray): String {
|
||||
val md: MessageDigest
|
||||
try {
|
||||
md = MessageDigest.getInstance("SHA256")
|
||||
} catch (noSuchAlgorithmException: NoSuchAlgorithmException) {
|
||||
Log.e(TAG, "No such algorithm: $noSuchAlgorithmException")
|
||||
throw RuntimeException("Could not find SHA256 hash algorithm", noSuchAlgorithmException)
|
||||
}
|
||||
md.update(certificate)
|
||||
|
||||
// This code takes the byte array generated by `md.digest()` and joins each of the bytes
|
||||
// to a string, applying the string format `%02x` on each digit before it's appended, with
|
||||
// a colon (':') between each of the items.
|
||||
// For example: input=[0,2,4,6,8,10,12], output="00:02:04:06:08:0a:0c"
|
||||
return md.digest().joinToString(":") { String.format("%02x", it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience class to hold all of the information about an app that's being checked
|
||||
* to see if it's a known caller.
|
||||
*/
|
||||
private data class CallerPackageInfo(
|
||||
val name: String,
|
||||
val packageName: String,
|
||||
val uid: Int,
|
||||
val signature: String?,
|
||||
val permissions: Set<String>
|
||||
)
|
||||
}
|
||||
|
||||
private const val TAG = "PackageValidator"
|
||||
private const val ANDROID_PLATFORM = "android"
|
||||
@@ -38,10 +38,10 @@ public class MediaSessionPlayerUi extends PlayerUi
|
||||
implements SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
private static final String TAG = "MediaSessUi";
|
||||
|
||||
@Nullable
|
||||
private MediaSessionCompat mediaSession;
|
||||
@Nullable
|
||||
private MediaSessionConnector sessionConnector;
|
||||
@NonNull
|
||||
private final MediaSessionCompat mediaSession;
|
||||
@NonNull
|
||||
private final MediaSessionConnector sessionConnector;
|
||||
|
||||
private final String ignoreHardwareMediaButtonsKey;
|
||||
private boolean shouldIgnoreHardwareMediaButtons = false;
|
||||
@@ -50,9 +50,13 @@ public class MediaSessionPlayerUi extends PlayerUi
|
||||
private List<NotificationActionData> prevNotificationActions = List.of();
|
||||
|
||||
|
||||
public MediaSessionPlayerUi(@NonNull final Player player) {
|
||||
public MediaSessionPlayerUi(@NonNull final Player player,
|
||||
@NonNull final MediaSessionCompat mediaSession,
|
||||
@NonNull final MediaSessionConnector sessionConnector) {
|
||||
super(player);
|
||||
ignoreHardwareMediaButtonsKey =
|
||||
this.mediaSession = mediaSession;
|
||||
this.sessionConnector = sessionConnector;
|
||||
this.ignoreHardwareMediaButtonsKey =
|
||||
context.getString(R.string.ignore_hardware_media_buttons_key);
|
||||
}
|
||||
|
||||
@@ -61,10 +65,8 @@ public class MediaSessionPlayerUi extends PlayerUi
|
||||
super.initPlayer();
|
||||
destroyPlayer(); // release previously used resources
|
||||
|
||||
mediaSession = new MediaSessionCompat(context, TAG);
|
||||
mediaSession.setActive(true);
|
||||
|
||||
sessionConnector = new MediaSessionConnector(mediaSession);
|
||||
sessionConnector.setQueueNavigator(new PlayQueueNavigator(mediaSession, player));
|
||||
sessionConnector.setPlayer(getForwardingPlayer());
|
||||
|
||||
@@ -89,27 +91,18 @@ public class MediaSessionPlayerUi extends PlayerUi
|
||||
public void destroyPlayer() {
|
||||
super.destroyPlayer();
|
||||
player.getPrefs().unregisterOnSharedPreferenceChangeListener(this);
|
||||
if (sessionConnector != null) {
|
||||
sessionConnector.setMediaButtonEventHandler(null);
|
||||
sessionConnector.setPlayer(null);
|
||||
sessionConnector.setQueueNavigator(null);
|
||||
sessionConnector = null;
|
||||
}
|
||||
if (mediaSession != null) {
|
||||
mediaSession.setActive(false);
|
||||
mediaSession.release();
|
||||
mediaSession = null;
|
||||
}
|
||||
sessionConnector.setMediaButtonEventHandler(null);
|
||||
sessionConnector.setPlayer(null);
|
||||
sessionConnector.setQueueNavigator(null);
|
||||
mediaSession.setActive(false);
|
||||
prevNotificationActions = List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onThumbnailLoaded(@Nullable final Bitmap bitmap) {
|
||||
super.onThumbnailLoaded(bitmap);
|
||||
if (sessionConnector != null) {
|
||||
// the thumbnail is now loaded: invalidate the metadata to trigger a metadata update
|
||||
sessionConnector.invalidateMediaSessionMetadata();
|
||||
}
|
||||
// the thumbnail is now loaded: invalidate the metadata to trigger a metadata update
|
||||
sessionConnector.invalidateMediaSessionMetadata();
|
||||
}
|
||||
|
||||
|
||||
@@ -200,8 +193,8 @@ public class MediaSessionPlayerUi extends PlayerUi
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionConnector == null) {
|
||||
// sessionConnector will be null after destroyPlayer is called
|
||||
if (!mediaSession.isActive()) {
|
||||
// mediaSession will be inactive after destroyPlayer is called
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package org.schabi.newpipe.player.playqueue;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.schabi.newpipe.extractor.InfoItem;
|
||||
import org.schabi.newpipe.extractor.ListExtractor;
|
||||
@@ -23,18 +24,23 @@ abstract class AbstractInfoPlayQueue<T extends ListInfo<? extends InfoItem>>
|
||||
|
||||
final int serviceId;
|
||||
final String baseUrl;
|
||||
@Nullable
|
||||
Page nextPage;
|
||||
|
||||
private transient Disposable fetchReactor;
|
||||
|
||||
protected AbstractInfoPlayQueue(final T info) {
|
||||
this(info, 0);
|
||||
}
|
||||
|
||||
protected AbstractInfoPlayQueue(final T info, final int index) {
|
||||
this(info.getServiceId(), info.getUrl(), info.getNextPage(),
|
||||
info.getRelatedItems()
|
||||
.stream()
|
||||
.filter(StreamInfoItem.class::isInstance)
|
||||
.map(StreamInfoItem.class::cast)
|
||||
.collect(Collectors.toList()),
|
||||
0);
|
||||
index);
|
||||
}
|
||||
|
||||
protected AbstractInfoPlayQueue(final int serviceId,
|
||||
|
||||
@@ -16,6 +16,10 @@ public final class PlaylistPlayQueue extends AbstractInfoPlayQueue<PlaylistInfo>
|
||||
super(info);
|
||||
}
|
||||
|
||||
public PlaylistPlayQueue(final PlaylistInfo info, final int index) {
|
||||
super(info, index);
|
||||
}
|
||||
|
||||
public PlaylistPlayQueue(final int serviceId,
|
||||
final String url,
|
||||
final Page nextPage,
|
||||
|
||||
@@ -382,7 +382,7 @@ public final class PopupPlayerUi extends VideoPlayerUi {
|
||||
private void end() {
|
||||
windowManager.removeView(closeOverlayBinding.getRoot());
|
||||
closeOverlayBinding = null;
|
||||
player.getService().stopService();
|
||||
player.getService().destroyPlayerAndStopService();
|
||||
}
|
||||
}).start();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package org.schabi.newpipe.settings;
|
||||
|
||||
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.app.AlertDialog;
|
||||
@@ -126,7 +125,6 @@ public class BackupRestoreSettingsFragment extends BasePreferenceFragment {
|
||||
}
|
||||
|
||||
private void requestExportPathResult(final ActivityResult result) {
|
||||
assureCorrectAppLanguage(requireContext());
|
||||
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
|
||||
// will be saved only on success
|
||||
final Uri lastExportDataUri = result.getData().getData();
|
||||
@@ -139,7 +137,6 @@ public class BackupRestoreSettingsFragment extends BasePreferenceFragment {
|
||||
}
|
||||
|
||||
private void requestImportPathResult(final ActivityResult result) {
|
||||
assureCorrectAppLanguage(requireContext());
|
||||
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
|
||||
// will be saved only on success
|
||||
final Uri lastImportDataUri = result.getData().getData();
|
||||
|
||||
@@ -1,57 +1,89 @@
|
||||
package org.schabi.newpipe.settings;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.core.os.LocaleListCompat;
|
||||
import androidx.preference.Preference;
|
||||
|
||||
import org.schabi.newpipe.DownloaderImpl;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.localization.ContentCountry;
|
||||
import org.schabi.newpipe.extractor.localization.Localization;
|
||||
import org.schabi.newpipe.player.helper.PlayerHelper;
|
||||
import org.schabi.newpipe.util.Localization;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
import org.schabi.newpipe.util.image.PicassoHelper;
|
||||
import org.schabi.newpipe.util.image.PreferredImageQuality;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Locale;
|
||||
|
||||
public class ContentSettingsFragment extends BasePreferenceFragment {
|
||||
private String youtubeRestrictedModeEnabledKey;
|
||||
|
||||
private Localization initialSelectedLocalization;
|
||||
private ContentCountry initialSelectedContentCountry;
|
||||
private String initialLanguage;
|
||||
|
||||
@Override
|
||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||
youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled);
|
||||
|
||||
addPreferencesFromResourceRegistry();
|
||||
|
||||
initialSelectedLocalization = org.schabi.newpipe.util.Localization
|
||||
.getPreferredLocalization(requireContext());
|
||||
initialSelectedContentCountry = org.schabi.newpipe.util.Localization
|
||||
.getPreferredContentCountry(requireContext());
|
||||
initialLanguage = defaultPreferences.getString(getString(R.string.app_language_key), "en");
|
||||
setupAppLanguagePreferences();
|
||||
setupImageQualityPref();
|
||||
}
|
||||
|
||||
final Preference imageQualityPreference = requirePreference(R.string.image_quality_key);
|
||||
imageQualityPreference.setOnPreferenceChangeListener(
|
||||
(preference, newValue) -> {
|
||||
ImageStrategy.setPreferredImageQuality(PreferredImageQuality
|
||||
.fromPreferenceKey(requireContext(), (String) newValue));
|
||||
try {
|
||||
PicassoHelper.clearCache(preference.getContext());
|
||||
Toast.makeText(preference.getContext(),
|
||||
R.string.thumbnail_cache_wipe_complete_notice, Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
} catch (final IOException e) {
|
||||
Log.e(TAG, "Unable to clear Picasso cache", e);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
private void setupAppLanguagePreferences() {
|
||||
final Preference appLanguagePref = requirePreference(R.string.app_language_key);
|
||||
// Android 13+ allows to set app specific languages
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
appLanguagePref.setVisible(false);
|
||||
|
||||
final Preference newAppLanguagePref =
|
||||
requirePreference(R.string.app_language_android_13_and_up_key);
|
||||
newAppLanguagePref.setSummaryProvider(preference -> {
|
||||
final Locale loc = AppCompatDelegate.getApplicationLocales().get(0);
|
||||
return loc != null ? loc.getDisplayName() : getString(R.string.systems_language);
|
||||
});
|
||||
newAppLanguagePref.setOnPreferenceClickListener(preference -> {
|
||||
final Intent intent = new Intent(Settings.ACTION_APP_LOCALE_SETTINGS)
|
||||
.setData(Uri.fromParts("package", requireContext().getPackageName(), null));
|
||||
startActivity(intent);
|
||||
return true;
|
||||
});
|
||||
newAppLanguagePref.setVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
appLanguagePref.setOnPreferenceChangeListener((preference, newValue) -> {
|
||||
final String language = (String) newValue;
|
||||
final String systemLang = getString(R.string.default_localization_key);
|
||||
final String tag = systemLang.equals(language) ? null : language;
|
||||
AppCompatDelegate.setApplicationLocales(LocaleListCompat.forLanguageTags(tag));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private void setupImageQualityPref() {
|
||||
requirePreference(R.string.image_quality_key).setOnPreferenceChangeListener(
|
||||
(preference, newValue) -> {
|
||||
ImageStrategy.setPreferredImageQuality(PreferredImageQuality
|
||||
.fromPreferenceKey(requireContext(), (String) newValue));
|
||||
try {
|
||||
PicassoHelper.clearCache(preference.getContext());
|
||||
Toast.makeText(preference.getContext(),
|
||||
R.string.thumbnail_cache_wipe_complete_notice, Toast.LENGTH_SHORT)
|
||||
.show();
|
||||
} catch (final IOException e) {
|
||||
Log.e(TAG, "Unable to clear Picasso cache", e);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -72,20 +104,10 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
|
||||
final Localization selectedLocalization = org.schabi.newpipe.util.Localization
|
||||
.getPreferredLocalization(requireContext());
|
||||
final ContentCountry selectedContentCountry = org.schabi.newpipe.util.Localization
|
||||
.getPreferredContentCountry(requireContext());
|
||||
final String selectedLanguage =
|
||||
defaultPreferences.getString(getString(R.string.app_language_key), "en");
|
||||
|
||||
if (!selectedLocalization.equals(initialSelectedLocalization)
|
||||
|| !selectedContentCountry.equals(initialSelectedContentCountry)
|
||||
|| !selectedLanguage.equals(initialLanguage)) {
|
||||
Toast.makeText(requireContext(), R.string.localization_changes_requires_app_restart,
|
||||
Toast.LENGTH_LONG).show();
|
||||
|
||||
NewPipe.setupLocalization(selectedLocalization, selectedContentCountry);
|
||||
}
|
||||
final Context context = requireContext();
|
||||
NewPipe.setupLocalization(
|
||||
Localization.getPreferredLocalization(context),
|
||||
Localization.getPreferredContentCountry(context));
|
||||
PlayerHelper.resetFormat();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package org.schabi.newpipe.settings;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.Context;
|
||||
@@ -209,8 +207,6 @@ public class DownloadSettingsFragment extends BasePreferenceFragment {
|
||||
}
|
||||
|
||||
private void requestDownloadPathResult(final ActivityResult result, final String key) {
|
||||
assureCorrectAppLanguage(getContext());
|
||||
|
||||
if (result.getResultCode() != Activity.RESULT_OK) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.schabi.newpipe.App;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.settings.migration.MigrationManager;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
|
||||
import java.io.File;
|
||||
@@ -46,7 +47,7 @@ public final class NewPipeSettings {
|
||||
|
||||
public static void initSettings(final Context context) {
|
||||
// first run migrations, then setDefaultValues, since the latter requires the correct types
|
||||
SettingMigrations.runMigrationsIfNeeded(context);
|
||||
MigrationManager.runMigrationsIfNeeded(context);
|
||||
|
||||
// readAgain is true so that if new settings are added their default value is set
|
||||
PreferenceManager.setDefaultValues(context, R.xml.main_settings, true);
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package org.schabi.newpipe.settings;
|
||||
|
||||
import android.content.DialogInterface;
|
||||
import android.os.Bundle;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.ImageView;
|
||||
import android.widget.ProgressBar;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||
import androidx.recyclerview.widget.RecyclerView;
|
||||
|
||||
import org.schabi.newpipe.NewPipeDatabase;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.AppDatabase;
|
||||
import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Vector;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Observer;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
/**
|
||||
* Created by Christian Schabesberger on 26.09.17.
|
||||
* SelectChannelFragment.java is part of NewPipe.
|
||||
* <p>
|
||||
* NewPipe is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
* </p>
|
||||
* <p>
|
||||
* NewPipe is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
* </p>
|
||||
* <p>
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||
* </p>
|
||||
*/
|
||||
|
||||
public class SelectFeedGroupFragment extends DialogFragment {
|
||||
|
||||
private OnSelectedListener onSelectedListener = null;
|
||||
private OnCancelListener onCancelListener = null;
|
||||
|
||||
private ProgressBar progressBar;
|
||||
private TextView emptyView;
|
||||
private RecyclerView recyclerView;
|
||||
|
||||
private List<FeedGroupEntity> feedGroups = new Vector<>();
|
||||
|
||||
public void setOnSelectedListener(final OnSelectedListener listener) {
|
||||
onSelectedListener = listener;
|
||||
}
|
||||
|
||||
public void setOnCancelListener(final OnCancelListener listener) {
|
||||
onCancelListener = listener;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Init
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCreate(@Nullable final Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public View onCreateView(@NonNull final LayoutInflater inflater, final ViewGroup container,
|
||||
final Bundle savedInstanceState) {
|
||||
final View v = inflater.inflate(R.layout.select_feed_group_fragment, container, false);
|
||||
recyclerView = v.findViewById(R.id.items_list);
|
||||
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
|
||||
final SelectFeedGroupAdapter feedGroupAdapter = new SelectFeedGroupAdapter();
|
||||
recyclerView.setAdapter(feedGroupAdapter);
|
||||
|
||||
progressBar = v.findViewById(R.id.progressBar);
|
||||
emptyView = v.findViewById(R.id.empty_state_view);
|
||||
progressBar.setVisibility(View.VISIBLE);
|
||||
recyclerView.setVisibility(View.GONE);
|
||||
emptyView.setVisibility(View.GONE);
|
||||
|
||||
|
||||
final AppDatabase database = NewPipeDatabase.getInstance(requireContext());
|
||||
database.feedGroupDAO().getAll().toObservable()
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(getFeedGroupObserver());
|
||||
|
||||
return v;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Handle actions
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCancel(@NonNull final DialogInterface dialogInterface) {
|
||||
super.onCancel(dialogInterface);
|
||||
if (onCancelListener != null) {
|
||||
onCancelListener.onCancel();
|
||||
}
|
||||
}
|
||||
|
||||
private void clickedItem(final int position) {
|
||||
if (onSelectedListener != null) {
|
||||
final FeedGroupEntity entry = feedGroups.get(position);
|
||||
onSelectedListener
|
||||
.onFeedGroupSelected(entry.getUid(), entry.getName(),
|
||||
entry.getIcon().getDrawableResource());
|
||||
}
|
||||
dismiss();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Item handling
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private void displayFeedGroups(final List<FeedGroupEntity> newFeedGroups) {
|
||||
this.feedGroups = newFeedGroups;
|
||||
progressBar.setVisibility(View.GONE);
|
||||
if (newFeedGroups.isEmpty()) {
|
||||
emptyView.setVisibility(View.VISIBLE);
|
||||
return;
|
||||
}
|
||||
recyclerView.setVisibility(View.VISIBLE);
|
||||
|
||||
}
|
||||
|
||||
private Observer<List<FeedGroupEntity>> getFeedGroupObserver() {
|
||||
return new Observer<List<FeedGroupEntity>>() {
|
||||
@Override
|
||||
public void onSubscribe(@NonNull final Disposable disposable) { }
|
||||
|
||||
@Override
|
||||
public void onNext(@NonNull final List<FeedGroupEntity> newGroups) {
|
||||
displayFeedGroups(newGroups);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(@NonNull final Throwable exception) {
|
||||
ErrorUtil.showUiErrorSnackbar(SelectFeedGroupFragment.this,
|
||||
"Loading Feed Groups", exception);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() { }
|
||||
};
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Interfaces
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
public interface OnSelectedListener {
|
||||
void onFeedGroupSelected(Long groupId, String name, int icon);
|
||||
}
|
||||
|
||||
public interface OnCancelListener {
|
||||
void onCancel();
|
||||
}
|
||||
|
||||
private class SelectFeedGroupAdapter
|
||||
extends RecyclerView.Adapter<SelectFeedGroupAdapter.SelectFeedGroupItemHolder> {
|
||||
@NonNull
|
||||
@Override
|
||||
public SelectFeedGroupItemHolder onCreateViewHolder(final ViewGroup parent,
|
||||
final int viewType) {
|
||||
final View item = LayoutInflater.from(parent.getContext())
|
||||
.inflate(R.layout.select_feed_group_item, parent, false);
|
||||
return new SelectFeedGroupItemHolder(item);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onBindViewHolder(final SelectFeedGroupItemHolder holder, final int position) {
|
||||
final FeedGroupEntity entry = feedGroups.get(position);
|
||||
holder.titleView.setText(entry.getName());
|
||||
holder.view.setOnClickListener(view -> clickedItem(position));
|
||||
holder.thumbnailView.setImageResource(entry.getIcon().getDrawableResource());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getItemCount() {
|
||||
return feedGroups.size();
|
||||
}
|
||||
|
||||
public class SelectFeedGroupItemHolder extends RecyclerView.ViewHolder {
|
||||
public final View view;
|
||||
final ImageView thumbnailView;
|
||||
final TextView titleView;
|
||||
SelectFeedGroupItemHolder(final View v) {
|
||||
super(v);
|
||||
this.view = v;
|
||||
thumbnailView = v.findViewById(R.id.itemThumbnailView);
|
||||
titleView = v.findViewById(R.id.itemTitleView);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
package org.schabi.newpipe.settings;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Bundle;
|
||||
import android.text.TextUtils;
|
||||
@@ -89,7 +87,6 @@ public class SettingsActivity extends AppCompatActivity implements
|
||||
@Override
|
||||
protected void onCreate(final Bundle savedInstanceBundle) {
|
||||
setTheme(ThemeHelper.getSettingsThemeStyle(this));
|
||||
assureCorrectAppLanguage(this);
|
||||
|
||||
super.onCreate(savedInstanceBundle);
|
||||
Bridge.restoreInstanceState(this, savedInstanceBundle);
|
||||
@@ -228,7 +225,6 @@ public class SettingsActivity extends AppCompatActivity implements
|
||||
|
||||
// Build search items
|
||||
final Context searchContext = getApplicationContext();
|
||||
assureCorrectAppLanguage(searchContext);
|
||||
final PreferenceParser parser = new PreferenceParser(searchContext, config);
|
||||
final PreferenceSearcher searcher = new PreferenceSearcher(config);
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package org.schabi.newpipe.settings.migration;
|
||||
|
||||
import android.content.Context;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.util.Consumer;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MigrationManager is responsible for running migrations and showing the user information about
|
||||
* the migrations that were applied.
|
||||
*/
|
||||
public final class MigrationManager {
|
||||
|
||||
private static final String TAG = MigrationManager.class.getSimpleName();
|
||||
/**
|
||||
* List of UI actions that are performed after the UI is initialized (e.g. showing alert
|
||||
* dialogs) to inform the user about changes that were applied by migrations.
|
||||
*/
|
||||
private static final List<Consumer<Context>> MIGRATION_INFO = new ArrayList<>();
|
||||
|
||||
private MigrationManager() {
|
||||
// MigrationManager is a utility class that is completely static
|
||||
}
|
||||
|
||||
/**
|
||||
* Run all migrations that are needed for the current version of NewPipe.
|
||||
* This method should be called at the start of the application, before any other operations
|
||||
* that depend on the settings.
|
||||
*
|
||||
* @param context Context that can be used to run migrations
|
||||
*/
|
||||
public static void runMigrationsIfNeeded(@NonNull final Context context) {
|
||||
SettingMigrations.runMigrationsIfNeeded(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform UI actions informing about migrations that took place if they are present.
|
||||
* @param context Context that can be used to show dialogs/snackbars/toasts
|
||||
*/
|
||||
public static void showUserInfoIfPresent(@NonNull final Context context) {
|
||||
if (MIGRATION_INFO.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
MIGRATION_INFO.get(0).accept(context);
|
||||
} catch (final Exception e) {
|
||||
ErrorUtil.showUiErrorSnackbar(context, "Showing migration info to the user", e);
|
||||
// Remove the migration that caused the error and continue with the next one
|
||||
MIGRATION_INFO.remove(0);
|
||||
showUserInfoIfPresent(context);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a migration info action that will be executed after the UI is initialized.
|
||||
* This can be used to show dialogs/snackbars/toasts to inform the user about changes that
|
||||
* were applied by migrations.
|
||||
*
|
||||
* @param info the action to be executed
|
||||
*/
|
||||
public static void addMigrationInfo(final Consumer<Context> info) {
|
||||
MIGRATION_INFO.add(info);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method should be called when the user dismisses the migration info
|
||||
* to check if there are any more migration info actions to be shown.
|
||||
* @param context Context that can be used to show dialogs/snackbars/toasts
|
||||
*/
|
||||
public static void onMigrationInfoDismissed(@NonNull final Context context) {
|
||||
MIGRATION_INFO.remove(0);
|
||||
showUserInfoIfPresent(context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a dialog to inform the user about the migration.
|
||||
* @param uiContext Context that can be used to show dialogs/snackbars/toasts
|
||||
* @param title the title of the dialog
|
||||
* @param message the message of the dialog
|
||||
* @return the dialog that can be shown to the user with a custom dismiss listener
|
||||
*/
|
||||
static AlertDialog createMigrationInfoDialog(@NonNull final Context uiContext,
|
||||
@NonNull final String title,
|
||||
@NonNull final String message) {
|
||||
return new AlertDialog.Builder(uiContext)
|
||||
.setTitle(title)
|
||||
.setMessage(message)
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.setOnDismissListener(dialog ->
|
||||
MigrationManager.onMigrationInfoDismissed(uiContext))
|
||||
.setCancelable(false) // prevents the dialog from being dismissed accidentally
|
||||
.create();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
package org.schabi.newpipe.settings;
|
||||
package org.schabi.newpipe.settings.migration;
|
||||
|
||||
import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||
import static org.schabi.newpipe.extractor.ServiceList.SoundCloud;
|
||||
import static org.schabi.newpipe.extractor.ServiceList.YouTube;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.util.Consumer;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.schabi.newpipe.App;
|
||||
@@ -12,20 +17,32 @@ import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.error.ErrorInfo;
|
||||
import org.schabi.newpipe.error.ErrorUtil;
|
||||
import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.settings.tabs.Tab;
|
||||
import org.schabi.newpipe.settings.tabs.TabsManager;
|
||||
import org.schabi.newpipe.util.DeviceUtils;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* In order to add a migration, follow these steps, given P is the previous version:<br>
|
||||
* - in the class body add a new {@code MIGRATION_P_P+1 = new Migration(P, P+1) { ... }} and put in
|
||||
* the {@code migrate()} method the code that need to be run when migrating from P to P+1<br>
|
||||
* - add {@code MIGRATION_P_P+1} at the end of {@link SettingMigrations#SETTING_MIGRATIONS}<br>
|
||||
* - increment {@link SettingMigrations#VERSION}'s value by 1 (so it should become P+1)
|
||||
* This class contains the code to migrate the settings from one version to another.
|
||||
* Migrations are run automatically when the app is started and the settings version changed.
|
||||
* <br>
|
||||
* In order to add a migration, follow these steps, given {@code P} is the previous version:
|
||||
* <ul>
|
||||
* <li>in the class body add a new {@code MIGRATION_P_P+1 = new Migration(P, P+1) { ... }} and put
|
||||
* in the {@code migrate()} method the code that need to be run
|
||||
* when migrating from {@code P} to {@code P+1}</li>
|
||||
* <li>add {@code MIGRATION_P_P+1} at the end of {@link SettingMigrations#SETTING_MIGRATIONS}</li>
|
||||
* <li>increment {@link SettingMigrations#VERSION}'s value by 1
|
||||
* (so it becomes {@code P+1})</li>
|
||||
* </ul>
|
||||
* Migrations can register UI actions using {@link MigrationManager#addMigrationInfo(Consumer)}
|
||||
* that will be performed after the UI is initialized to inform the user about changes
|
||||
* that were applied by migrations.
|
||||
*/
|
||||
public final class SettingMigrations {
|
||||
|
||||
@@ -129,7 +146,7 @@ public final class SettingMigrations {
|
||||
}
|
||||
};
|
||||
|
||||
public static final Migration MIGRATION_5_6 = new Migration(5, 6) {
|
||||
private static final Migration MIGRATION_5_6 = new Migration(5, 6) {
|
||||
@Override
|
||||
protected void migrate(@NonNull final Context context) {
|
||||
final boolean loadImages = sp.getBoolean("download_thumbnail_key", true);
|
||||
@@ -143,6 +160,67 @@ public final class SettingMigrations {
|
||||
}
|
||||
};
|
||||
|
||||
private static final Migration MIGRATION_6_7 = new Migration(6, 7) {
|
||||
@Override
|
||||
protected void migrate(@NonNull final Context context) {
|
||||
// The SoundCloud Top 50 Kiosk was removed in the extractor,
|
||||
// so we remove the corresponding tab if it exists.
|
||||
final TabsManager tabsManager = TabsManager.getManager(context);
|
||||
final List<Tab> tabs = tabsManager.getTabs();
|
||||
final List<Tab> cleanedTabs = tabs.stream()
|
||||
.filter(tab -> !(tab instanceof Tab.KioskTab kioskTab
|
||||
&& kioskTab.getKioskServiceId() == SoundCloud.getServiceId()
|
||||
&& kioskTab.getKioskId().equals("Top 50")))
|
||||
.collect(Collectors.toUnmodifiableList());
|
||||
if (tabs.size() != cleanedTabs.size()) {
|
||||
tabsManager.saveTabs(cleanedTabs);
|
||||
// create an AlertDialog to inform the user about the change
|
||||
MigrationManager.addMigrationInfo(uiContext ->
|
||||
MigrationManager.createMigrationInfoDialog(
|
||||
uiContext,
|
||||
uiContext.getString(R.string.migration_info_6_7_title),
|
||||
uiContext.getString(R.string.migration_info_6_7_message))
|
||||
.show());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private static final Migration MIGRATION_7_8 = new Migration(7, 8) {
|
||||
@Override
|
||||
protected void migrate(@NonNull final Context context) {
|
||||
// YouTube remove the combined Trending kiosk, see
|
||||
// https://github.com/TeamNewPipe/NewPipe/discussions/12445 for more information.
|
||||
// If the user has a dedicated YouTube/Trending kiosk tab,
|
||||
// it is removed and replaced with the new live kiosk tab.
|
||||
// The default trending kiosk tab is not touched
|
||||
// because it uses the default kiosk provided by the extractor
|
||||
// and is thus updated automatically.
|
||||
final TabsManager tabsManager = TabsManager.getManager(context);
|
||||
final List<Tab> tabs = tabsManager.getTabs();
|
||||
final List<Tab> cleanedTabs = tabs.stream()
|
||||
.filter(tab -> !(tab instanceof Tab.KioskTab kioskTab
|
||||
&& kioskTab.getKioskServiceId() == YouTube.getServiceId()
|
||||
&& kioskTab.getKioskId().equals("Trending")))
|
||||
.collect(Collectors.toUnmodifiableList());
|
||||
if (tabs.size() != cleanedTabs.size()) {
|
||||
tabsManager.saveTabs(cleanedTabs);
|
||||
}
|
||||
|
||||
final boolean hasDefaultTrendingTab = tabs.stream()
|
||||
.anyMatch(tab -> tab instanceof Tab.DefaultKioskTab);
|
||||
|
||||
if (tabs.size() != cleanedTabs.size() || hasDefaultTrendingTab) {
|
||||
// User is informed about the change
|
||||
MigrationManager.addMigrationInfo(uiContext ->
|
||||
MigrationManager.createMigrationInfoDialog(
|
||||
uiContext,
|
||||
uiContext.getString(R.string.migration_info_7_8_title),
|
||||
uiContext.getString(R.string.migration_info_7_8_message))
|
||||
.show());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* List of all implemented migrations.
|
||||
* <p>
|
||||
@@ -156,15 +234,17 @@ public final class SettingMigrations {
|
||||
MIGRATION_3_4,
|
||||
MIGRATION_4_5,
|
||||
MIGRATION_5_6,
|
||||
MIGRATION_6_7,
|
||||
MIGRATION_7_8,
|
||||
};
|
||||
|
||||
/**
|
||||
* Version number for preferences. Must be incremented every time a migration is necessary.
|
||||
*/
|
||||
private static final int VERSION = 6;
|
||||
private static final int VERSION = 8;
|
||||
|
||||
|
||||
public static void runMigrationsIfNeeded(@NonNull final Context context) {
|
||||
static void runMigrationsIfNeeded(@NonNull final Context context) {
|
||||
// setup migrations and check if there is something to do
|
||||
sp = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
final String lastPrefVersionKey = context.getString(R.string.last_used_preferences_version);
|
||||
@@ -34,6 +34,7 @@ import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.settings.SelectChannelFragment;
|
||||
import org.schabi.newpipe.settings.SelectKioskFragment;
|
||||
import org.schabi.newpipe.settings.SelectPlaylistFragment;
|
||||
import org.schabi.newpipe.settings.SelectFeedGroupFragment;
|
||||
import org.schabi.newpipe.settings.tabs.AddTabDialog.ChooseTabListItem;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
@@ -203,6 +204,14 @@ public class ChooseTabsFragment extends Fragment {
|
||||
});
|
||||
selectPlaylistFragment.show(getParentFragmentManager(), "select_playlist");
|
||||
return;
|
||||
case FEEDGROUP:
|
||||
final SelectFeedGroupFragment selectFeedGroupFragment =
|
||||
new SelectFeedGroupFragment();
|
||||
selectFeedGroupFragment.setOnSelectedListener(
|
||||
(groupId, name, iconId) ->
|
||||
addTab(new Tab.FeedGroupTab(groupId, name, iconId)));
|
||||
selectFeedGroupFragment.show(getParentFragmentManager(), "select_feed_group");
|
||||
return;
|
||||
default:
|
||||
addTab(type.getTab());
|
||||
break;
|
||||
@@ -244,6 +253,11 @@ public class ChooseTabsFragment extends Fragment {
|
||||
getString(R.string.playlist_page_summary),
|
||||
tab.getTabIconRes(context)));
|
||||
break;
|
||||
case FEEDGROUP:
|
||||
returnList.add(new ChooseTabListItem(tab.getTabId(),
|
||||
getString(R.string.feed_group_page_summary),
|
||||
tab.getTabIconRes(context)));
|
||||
break;
|
||||
default:
|
||||
if (!tabList.contains(tab)) {
|
||||
returnList.add(new ChooseTabListItem(context, tab));
|
||||
@@ -396,6 +410,9 @@ public class ChooseTabsFragment extends Fragment {
|
||||
? getString(R.string.local)
|
||||
: getNameOfServiceById(serviceId);
|
||||
return serviceName + "/" + tab.getTabName(requireContext());
|
||||
case FEEDGROUP:
|
||||
return getString(R.string.feed_groups_header_title)
|
||||
+ "/" + ((Tab.FeedGroupTab) tab).getFeedGroupName();
|
||||
default:
|
||||
return tab.getTabName(requireContext());
|
||||
}
|
||||
|
||||
@@ -93,6 +93,8 @@ public abstract class Tab {
|
||||
return new ChannelTab(jsonObject);
|
||||
case PLAYLIST:
|
||||
return new PlaylistTab(jsonObject);
|
||||
case FEEDGROUP:
|
||||
return new FeedGroupTab(jsonObject);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,7 +164,8 @@ public abstract class Tab {
|
||||
HISTORY(new HistoryTab()),
|
||||
KIOSK(new KioskTab()),
|
||||
CHANNEL(new ChannelTab()),
|
||||
PLAYLIST(new PlaylistTab());
|
||||
PLAYLIST(new PlaylistTab()),
|
||||
FEEDGROUP(new FeedGroupTab());
|
||||
|
||||
private final Tab tab;
|
||||
|
||||
@@ -458,7 +461,7 @@ public abstract class Tab {
|
||||
final ChannelTab other = (ChannelTab) obj;
|
||||
return super.equals(obj)
|
||||
&& channelServiceId == other.channelServiceId
|
||||
&& channelUrl.equals(other.channelName)
|
||||
&& channelUrl.equals(other.channelUrl)
|
||||
&& channelName.equals(other.channelName);
|
||||
}
|
||||
|
||||
@@ -652,4 +655,93 @@ public abstract class Tab {
|
||||
return playlistType;
|
||||
}
|
||||
}
|
||||
public static class FeedGroupTab extends Tab {
|
||||
public static final int ID = 9;
|
||||
private static final String JSON_FEED_GROUP_ID_KEY = "feed_group_id";
|
||||
private static final String JSON_FEED_GROUP_NAME_KEY = "feed_group_name";
|
||||
private static final String JSON_FEED_GROUP_ICON_KEY = "feed_group_icon";
|
||||
private Long feedGroupId;
|
||||
private String feedGroupName;
|
||||
private int iconId;
|
||||
|
||||
private FeedGroupTab() {
|
||||
this((long) -1, NO_NAME, R.drawable.ic_asterisk);
|
||||
}
|
||||
|
||||
public FeedGroupTab(final Long feedGroupId, final String feedGroupName,
|
||||
final int iconId) {
|
||||
this.feedGroupId = feedGroupId;
|
||||
this.feedGroupName = feedGroupName;
|
||||
this.iconId = iconId;
|
||||
|
||||
}
|
||||
|
||||
public FeedGroupTab(final JsonObject jsonObject) {
|
||||
super(jsonObject);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTabId() {
|
||||
return ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTabName(final Context context) {
|
||||
return context.getString(R.string.fragment_feed_title);
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
@Override
|
||||
public int getTabIconRes(final Context context) {
|
||||
return this.iconId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FeedFragment getFragment(final Context context) {
|
||||
return FeedFragment.newInstance(feedGroupId, feedGroupName);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void writeDataToJson(final JsonStringWriter writerSink) {
|
||||
writerSink.value(JSON_FEED_GROUP_ID_KEY, feedGroupId)
|
||||
.value(JSON_FEED_GROUP_NAME_KEY, feedGroupName)
|
||||
.value(JSON_FEED_GROUP_ICON_KEY, iconId);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void readDataFromJson(final JsonObject jsonObject) {
|
||||
feedGroupId = jsonObject.getLong(JSON_FEED_GROUP_ID_KEY, -1);
|
||||
feedGroupName = jsonObject.getString(JSON_FEED_GROUP_NAME_KEY, NO_NAME);
|
||||
iconId = jsonObject.getInt(JSON_FEED_GROUP_ICON_KEY, R.drawable.ic_asterisk);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(final Object obj) {
|
||||
if (!(obj instanceof FeedGroupTab)) {
|
||||
return false;
|
||||
}
|
||||
final FeedGroupTab other = (FeedGroupTab) obj;
|
||||
return super.equals(obj)
|
||||
&& feedGroupId.equals(other.feedGroupId)
|
||||
&& feedGroupName.equals(other.feedGroupName)
|
||||
&& iconId == other.iconId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(getTabId(), feedGroupId, feedGroupName, iconId);
|
||||
}
|
||||
|
||||
public Long getFeedGroupId() {
|
||||
return feedGroupId;
|
||||
}
|
||||
|
||||
public String getFeedGroupName() {
|
||||
return feedGroupName;
|
||||
}
|
||||
|
||||
public int getIconId() {
|
||||
return iconId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ public final class ChannelTabHelper {
|
||||
switch (tab) {
|
||||
case ChannelTabs.VIDEOS:
|
||||
case ChannelTabs.TRACKS:
|
||||
case ChannelTabs.LIKES:
|
||||
case ChannelTabs.SHORTS:
|
||||
case ChannelTabs.LIVESTREAMS:
|
||||
return true;
|
||||
@@ -62,6 +63,8 @@ public final class ChannelTabHelper {
|
||||
return R.string.show_channel_tabs_playlists;
|
||||
case ChannelTabs.ALBUMS:
|
||||
return R.string.show_channel_tabs_albums;
|
||||
case ChannelTabs.LIKES:
|
||||
return R.string.show_channel_tabs_likes;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
@@ -78,6 +81,8 @@ public final class ChannelTabHelper {
|
||||
return R.string.fetch_channel_tabs_shorts;
|
||||
case ChannelTabs.LIVESTREAMS:
|
||||
return R.string.fetch_channel_tabs_livestreams;
|
||||
case ChannelTabs.LIKES:
|
||||
return R.string.fetch_channel_tabs_likes;
|
||||
default:
|
||||
return -1;
|
||||
}
|
||||
@@ -100,6 +105,8 @@ public final class ChannelTabHelper {
|
||||
return R.string.channel_tab_playlists;
|
||||
case ChannelTabs.ALBUMS:
|
||||
return R.string.channel_tab_albums;
|
||||
case ChannelTabs.LIKES:
|
||||
return R.string.channel_tab_likes;
|
||||
default:
|
||||
return R.string.unknown_content;
|
||||
}
|
||||
|
||||
@@ -52,6 +52,14 @@ public final class KioskTranslator {
|
||||
return c.getString(R.string.featured);
|
||||
case "Radio":
|
||||
return c.getString(R.string.radio);
|
||||
case "trending_gaming":
|
||||
return c.getString(R.string.trending_gaming);
|
||||
case "trending_music":
|
||||
return c.getString(R.string.trending_music);
|
||||
case "trending_movies_and_shows":
|
||||
return c.getString(R.string.trending_movies);
|
||||
case "trending_podcasts_episodes":
|
||||
return c.getString(R.string.trending_podcasts);
|
||||
default:
|
||||
return kioskId;
|
||||
}
|
||||
@@ -77,6 +85,14 @@ public final class KioskTranslator {
|
||||
return R.drawable.ic_stars;
|
||||
case "Radio":
|
||||
return R.drawable.ic_radio;
|
||||
case "trending_gaming":
|
||||
return R.drawable.ic_videogame_asset;
|
||||
case "trending_music":
|
||||
return R.drawable.ic_music_note;
|
||||
case "trending_movies_and_shows":
|
||||
return R.drawable.ic_movie;
|
||||
case "trending_podcasts_episodes":
|
||||
return R.drawable.ic_podcasts;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -322,7 +322,7 @@ public final class ListHelper {
|
||||
}
|
||||
|
||||
// Sort collected streams by name
|
||||
return collectedStreams.values().stream().sorted(getAudioTrackNameComparator(context))
|
||||
return collectedStreams.values().stream().sorted(getAudioTrackNameComparator())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@@ -359,7 +359,7 @@ public final class ListHelper {
|
||||
}
|
||||
|
||||
// Sort tracks alphabetically, sort track streams by quality
|
||||
final Comparator<AudioStream> nameCmp = getAudioTrackNameComparator(context);
|
||||
final Comparator<AudioStream> nameCmp = getAudioTrackNameComparator();
|
||||
final Comparator<AudioStream> formatCmp = getAudioFormatComparator(context);
|
||||
|
||||
return collectedStreams.values().stream()
|
||||
@@ -867,12 +867,10 @@ public final class ListHelper {
|
||||
* Get a {@link Comparator} to compare {@link AudioStream}s by their languages and track types
|
||||
* for alphabetical sorting.
|
||||
*
|
||||
* @param context app context for localization
|
||||
* @return Comparator
|
||||
*/
|
||||
private static Comparator<AudioStream> getAudioTrackNameComparator(
|
||||
@NonNull final Context context) {
|
||||
final Locale appLoc = Localization.getAppLocale(context);
|
||||
private static Comparator<AudioStream> getAudioTrackNameComparator() {
|
||||
final Locale appLoc = Localization.getAppLocale();
|
||||
|
||||
return Comparator.comparing(AudioStream::getAudioLocale, Comparator.nullsLast(
|
||||
Comparator.comparing(locale -> locale.getDisplayName(appLoc))))
|
||||
|
||||
@@ -5,19 +5,21 @@ import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.content.res.Configuration;
|
||||
import android.content.res.Resources;
|
||||
import android.icu.text.CompactDecimalFormat;
|
||||
import android.os.Build;
|
||||
import android.text.BidiFormatter;
|
||||
import android.text.TextUtils;
|
||||
import android.text.format.DateUtils;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.PluralsRes;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.core.math.MathUtils;
|
||||
import androidx.core.os.LocaleListCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.ocpsoft.prettytime.PrettyTime;
|
||||
@@ -63,6 +65,7 @@ import java.util.stream.Collectors;
|
||||
*/
|
||||
|
||||
public final class Localization {
|
||||
private static final String TAG = Localization.class.toString();
|
||||
public static final String DOT_SEPARATOR = " • ";
|
||||
private static PrettyTime prettyTime;
|
||||
|
||||
@@ -80,6 +83,20 @@ public final class Localization {
|
||||
.collect(Collectors.joining(delimiter));
|
||||
}
|
||||
|
||||
/**
|
||||
* Localize a user name like <code>@foobar</code>.
|
||||
*
|
||||
* Will correctly handle right-to-left usernames by using a {@link BidiFormatter}.
|
||||
* For right-to-left usernames, it will put the @ on the right side to read more naturally.
|
||||
*
|
||||
* @param plainName username, with an optional leading <code>@</code>
|
||||
* @return a usernames that can include RTL-characters
|
||||
*/
|
||||
@NonNull
|
||||
public static String localizeUserName(final String plainName) {
|
||||
return BidiFormatter.getInstance().unicodeWrap(plainName);
|
||||
}
|
||||
|
||||
public static org.schabi.newpipe.extractor.localization.Localization getPreferredLocalization(
|
||||
final Context context) {
|
||||
return org.schabi.newpipe.extractor.localization.Localization
|
||||
@@ -100,35 +117,34 @@ public final class Localization {
|
||||
return getLocaleFromPrefs(context, R.string.content_language_key);
|
||||
}
|
||||
|
||||
public static Locale getAppLocale(@NonNull final Context context) {
|
||||
return getLocaleFromPrefs(context, R.string.app_language_key);
|
||||
public static Locale getAppLocale() {
|
||||
final Locale customLocale = AppCompatDelegate.getApplicationLocales().get(0);
|
||||
return customLocale != null ? customLocale : Locale.getDefault();
|
||||
}
|
||||
|
||||
public static String localizeNumber(@NonNull final Context context, final long number) {
|
||||
return localizeNumber(context, (double) number);
|
||||
public static String localizeNumber(final long number) {
|
||||
return localizeNumber((double) number);
|
||||
}
|
||||
|
||||
public static String localizeNumber(@NonNull final Context context, final double number) {
|
||||
final NumberFormat nf = NumberFormat.getInstance(getAppLocale(context));
|
||||
return nf.format(number);
|
||||
public static String localizeNumber(final double number) {
|
||||
return NumberFormat.getInstance(getAppLocale()).format(number);
|
||||
}
|
||||
|
||||
public static String formatDate(@NonNull final Context context,
|
||||
@NonNull final OffsetDateTime offsetDateTime) {
|
||||
public static String formatDate(@NonNull final OffsetDateTime offsetDateTime) {
|
||||
return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
.withLocale(getAppLocale(context)).format(offsetDateTime
|
||||
.atZoneSameInstant(ZoneId.systemDefault()));
|
||||
.withLocale(getAppLocale())
|
||||
.format(offsetDateTime.atZoneSameInstant(ZoneId.systemDefault()));
|
||||
}
|
||||
|
||||
@SuppressLint("StringFormatInvalid")
|
||||
public static String localizeUploadDate(@NonNull final Context context,
|
||||
@NonNull final OffsetDateTime offsetDateTime) {
|
||||
return context.getString(R.string.upload_date_text, formatDate(context, offsetDateTime));
|
||||
return context.getString(R.string.upload_date_text, formatDate(offsetDateTime));
|
||||
}
|
||||
|
||||
public static String localizeViewCount(@NonNull final Context context, final long viewCount) {
|
||||
return getQuantity(context, R.plurals.views, R.string.no_views, viewCount,
|
||||
localizeNumber(context, viewCount));
|
||||
localizeNumber(viewCount));
|
||||
}
|
||||
|
||||
public static String localizeStreamCount(@NonNull final Context context,
|
||||
@@ -142,7 +158,7 @@ public final class Localization {
|
||||
return context.getResources().getString(R.string.more_than_100_videos);
|
||||
default:
|
||||
return getQuantity(context, R.plurals.videos, R.string.no_videos, streamCount,
|
||||
localizeNumber(context, streamCount));
|
||||
localizeNumber(streamCount));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,27 +179,27 @@ public final class Localization {
|
||||
public static String localizeWatchingCount(@NonNull final Context context,
|
||||
final long watchingCount) {
|
||||
return getQuantity(context, R.plurals.watching, R.string.no_one_watching, watchingCount,
|
||||
localizeNumber(context, watchingCount));
|
||||
localizeNumber(watchingCount));
|
||||
}
|
||||
|
||||
public static String shortCount(@NonNull final Context context, final long count) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
return CompactDecimalFormat.getInstance(getAppLocale(context),
|
||||
return CompactDecimalFormat.getInstance(getAppLocale(),
|
||||
CompactDecimalFormat.CompactStyle.SHORT).format(count);
|
||||
}
|
||||
|
||||
final double value = (double) count;
|
||||
if (count >= 1000000000) {
|
||||
return localizeNumber(context, round(value / 1000000000))
|
||||
return localizeNumber(round(value / 1000000000))
|
||||
+ context.getString(R.string.short_billion);
|
||||
} else if (count >= 1000000) {
|
||||
return localizeNumber(context, round(value / 1000000))
|
||||
return localizeNumber(round(value / 1000000))
|
||||
+ context.getString(R.string.short_million);
|
||||
} else if (count >= 1000) {
|
||||
return localizeNumber(context, round(value / 1000))
|
||||
return localizeNumber(round(value / 1000))
|
||||
+ context.getString(R.string.short_thousand);
|
||||
} else {
|
||||
return localizeNumber(context, value);
|
||||
return localizeNumber(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,7 +324,7 @@ public final class Localization {
|
||||
* <ul>
|
||||
* <li>English (original)</li>
|
||||
* <li>English (descriptive)</li>
|
||||
* <li>Spanish (dubbed)</li>
|
||||
* <li>Spanish (Spain) (dubbed)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param context the context used to get the app language
|
||||
@@ -318,7 +334,7 @@ public final class Localization {
|
||||
public static String audioTrackName(@NonNull final Context context, final AudioStream track) {
|
||||
final String name;
|
||||
if (track.getAudioLocale() != null) {
|
||||
name = track.getAudioLocale().getDisplayLanguage(getAppLocale(context));
|
||||
name = track.getAudioLocale().getDisplayName();
|
||||
} else if (track.getAudioTrackName() != null) {
|
||||
name = track.getAudioTrackName();
|
||||
} else {
|
||||
@@ -353,8 +369,8 @@ public final class Localization {
|
||||
prettyTime.removeUnit(Decade.class);
|
||||
}
|
||||
|
||||
public static PrettyTime resolvePrettyTime(@NonNull final Context context) {
|
||||
return new PrettyTime(getAppLocale(context));
|
||||
public static PrettyTime resolvePrettyTime() {
|
||||
return new PrettyTime(getAppLocale());
|
||||
}
|
||||
|
||||
public static String relativeTime(@NonNull final OffsetDateTime offsetDateTime) {
|
||||
@@ -372,9 +388,10 @@ public final class Localization {
|
||||
* {@code parsed != null} and the relevant setting is enabled, {@code textual} will
|
||||
* be appended to the returned string for debugging purposes.
|
||||
*/
|
||||
@Nullable
|
||||
public static String relativeTimeOrTextual(@Nullable final Context context,
|
||||
@Nullable final DateWrapper parsed,
|
||||
final String textual) {
|
||||
@Nullable final String textual) {
|
||||
if (parsed == null) {
|
||||
return textual;
|
||||
} else if (DEBUG && context != null && PreferenceManager
|
||||
@@ -386,14 +403,6 @@ public final class Localization {
|
||||
}
|
||||
}
|
||||
|
||||
public static void assureCorrectAppLanguage(final Context c) {
|
||||
final Resources res = c.getResources();
|
||||
final DisplayMetrics dm = res.getDisplayMetrics();
|
||||
final Configuration conf = res.getConfiguration();
|
||||
conf.setLocale(getAppLocale(c));
|
||||
res.updateConfiguration(conf, dm);
|
||||
}
|
||||
|
||||
private static Locale getLocaleFromPrefs(@NonNull final Context context,
|
||||
@StringRes final int prefKey) {
|
||||
final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
@@ -427,4 +436,35 @@ public final class Localization {
|
||||
final int safeCount = (int) MathUtils.clamp(count, Integer.MIN_VALUE, Integer.MAX_VALUE);
|
||||
return context.getResources().getQuantityString(pluralId, safeCount, formattedCount);
|
||||
}
|
||||
|
||||
// Starting with pull request #12093, NewPipe exclusively uses Android's
|
||||
// public per-app language APIs to read and set the UI language for NewPipe.
|
||||
// The following code will migrate any existing custom app language in SharedPreferences to
|
||||
// use the public per-app language APIs instead.
|
||||
// For reference, see
|
||||
// https://android-developers.googleblog.com/2022/11/per-app-language-preferences-part-1.html
|
||||
public static void migrateAppLanguageSettingIfNecessary(@NonNull final Context context) {
|
||||
final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
final String appLanguageKey = context.getString(R.string.app_language_key);
|
||||
final String appLanguageValue = sp.getString(appLanguageKey, null);
|
||||
if (appLanguageValue != null) {
|
||||
// The app language key is used on Android versions < 33
|
||||
// for more info, see ContentSettingsFragment
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
sp.edit().remove(appLanguageKey).apply();
|
||||
}
|
||||
final String appLanguageDefaultValue =
|
||||
context.getString(R.string.default_localization_key);
|
||||
if (!appLanguageValue.equals(appLanguageDefaultValue)) {
|
||||
try {
|
||||
AppCompatDelegate.setApplicationLocales(
|
||||
LocaleListCompat.forLanguageTags(appLanguageValue));
|
||||
} catch (final RuntimeException e) {
|
||||
Log.e(TAG, "Failed to migrate previous custom app language "
|
||||
+ "setting to public per-app language APIs"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ fun parseChallengeData(rawChallengeData: String): String {
|
||||
val descrambled = descramble(scrambled.getString(1))
|
||||
JsonParser.array().from(descrambled)
|
||||
} else {
|
||||
scrambled.getArray(1)
|
||||
scrambled.getArray(0)
|
||||
}
|
||||
|
||||
val messageId = challengeData.getString(0)
|
||||
|
||||
@@ -71,6 +71,9 @@ 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;
|
||||
@@ -208,11 +211,17 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -832,6 +841,7 @@ public class MissionAdapter extends Adapter<ViewHolder> implements Handler.Callb
|
||||
ImageView icon;
|
||||
TextView name;
|
||||
TextView size;
|
||||
TextView date;
|
||||
ProgressDrawable progress;
|
||||
|
||||
PopupMenu popupMenu;
|
||||
@@ -862,6 +872,7 @@ 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);
|
||||
|
||||
|
||||
10
app/src/main/res/drawable/ic_bookmark_white.xml
Normal file
10
app/src/main/res/drawable/ic_bookmark_white.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<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>
|
||||
10
app/src/main/res/drawable/ic_history_white.xml
Normal file
10
app/src/main/res/drawable/ic_history_white.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<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>
|
||||
5
app/src/main/res/drawable/ic_podcasts.xml
Normal file
5
app/src/main/res/drawable/ic_podcasts.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="@android:color/white" android:pathData="M14,12c0,0.74 -0.4,1.38 -1,1.72V22h-2v-8.28c-0.6,-0.35 -1,-0.98 -1,-1.72c0,-1.1 0.9,-2 2,-2S14,10.9 14,12zM12,6c-3.31,0 -6,2.69 -6,6c0,1.74 0.75,3.31 1.94,4.4l1.42,-1.42C8.53,14.25 8,13.19 8,12c0,-2.21 1.79,-4 4,-4s4,1.79 4,4c0,1.19 -0.53,2.25 -1.36,2.98l1.42,1.42C17.25,15.31 18,13.74 18,12C18,8.69 15.31,6 12,6zM12,2C6.48,2 2,6.48 2,12c0,2.85 1.2,5.41 3.11,7.24l1.42,-1.42C4.98,16.36 4,14.29 4,12c0,-4.41 3.59,-8 8,-8s8,3.59 8,8c0,2.29 -0.98,4.36 -2.53,5.82l1.42,1.42C20.8,17.41 22,14.85 22,12C22,6.48 17.52,2 12,2z"/>
|
||||
|
||||
</vector>
|
||||
@@ -4,7 +4,9 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<include layout="@layout/main_bg" />
|
||||
<include
|
||||
android:id="@+id/blank_page_content"
|
||||
layout="@layout/main_bg" />
|
||||
|
||||
<include
|
||||
android:id="@+id/error_panel"
|
||||
|
||||
@@ -82,6 +82,18 @@
|
||||
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>
|
||||
|
||||
@@ -62,6 +62,18 @@
|
||||
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"
|
||||
|
||||
42
app/src/main/res/layout/select_feed_group_fragment.xml
Normal file
42
app/src/main/res/layout/select_feed_group_fragment.xml
Normal file
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="13dp">
|
||||
|
||||
<org.schabi.newpipe.views.NewPipeTextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="10dp"
|
||||
android:layout_marginLeft="10dp"
|
||||
android:layout_marginTop="5dp"
|
||||
android:layout_marginEnd="5dp"
|
||||
android:layout_marginRight="5dp"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:text="@string/select_a_feed_group"
|
||||
android:textAppearance="?android:attr/textAppearanceLarge" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/items_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:listitem="@layout/select_feed_group_item" />
|
||||
|
||||
|
||||
<org.schabi.newpipe.views.NewPipeTextView
|
||||
android:id="@+id/empty_state_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="10dp"
|
||||
android:text="@string/no_feed_group_created_yet"
|
||||
android:textAppearance="?android:attr/textAppearanceListItem" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progressBar"
|
||||
style="?android:attr/progressBarStyle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="5dp" />
|
||||
</LinearLayout>
|
||||
38
app/src/main/res/layout/select_feed_group_item.xml
Normal file
38
app/src/main/res/layout/select_feed_group_item.xml
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:orientation="vertical"
|
||||
android:padding="5dp">
|
||||
|
||||
<com.google.android.material.imageview.ShapeableImageView
|
||||
android:id="@+id/itemThumbnailView"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_alignParentStart="true"
|
||||
android:layout_alignParentLeft="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_marginStart="3dp"
|
||||
android:layout_marginRight="8dp"
|
||||
android:src="@drawable/ic_computer"
|
||||
tools:ignore="RtlHardcoded" />
|
||||
|
||||
<org.schabi.newpipe.views.NewPipeTextView
|
||||
android:id="@+id/itemTitleView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_centerVertical="true"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginTop="6dp"
|
||||
android:layout_marginBottom="6dp"
|
||||
android:layout_toEndOf="@+id/itemThumbnailView"
|
||||
android:ellipsize="end"
|
||||
android:lines="1"
|
||||
android:textAppearance="?android:attr/textAppearanceListItem"
|
||||
tools:text="Channel Title, Lorem ipsum" />
|
||||
|
||||
</RelativeLayout>
|
||||
@@ -2,5 +2,6 @@
|
||||
<menu xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<group android:id="@+id/menu_services_group" />
|
||||
<group android:id="@+id/menu_tabs_group" />
|
||||
<group android:id="@+id/menu_kiosks_group" />
|
||||
<group android:id="@+id/menu_options_about_group" />
|
||||
</menu>
|
||||
|
||||
1
app/src/main/res/resources.properties
Normal file
1
app/src/main/res/resources.properties
Normal file
@@ -0,0 +1 @@
|
||||
unqualifiedResLocale=en-US
|
||||
@@ -227,7 +227,6 @@
|
||||
<string name="featured">المميزة</string>
|
||||
<string name="show_age_restricted_content_summary">عرض المحتوى الذي يُحتمل أن يكون غير مناسب للأطفال لأن له حدًا عمريًا (مثل 18+)</string>
|
||||
<string name="start_here_on_background">بدأ التشغيل في الخلفية</string>
|
||||
<string name="localization_changes_requires_app_restart">ستتغير اللغة بمجرد إعادة تشغيل التطبيق</string>
|
||||
<string name="channel_tab_shorts">القصيرة</string>
|
||||
<string name="playlists">قوائم التشغيل</string>
|
||||
<string name="clear">تنظيف</string>
|
||||
@@ -856,6 +855,5 @@
|
||||
<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>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<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>
|
||||
@@ -37,7 +37,7 @@
|
||||
<string name="show_play_with_kodi_summary">اعرض خيار لتشغيل الفيديو عبر مركز وسائط Kodi</string>
|
||||
<string name="show_play_with_kodi_title">عرض خيار التشغيل بواسطة كودي</string>
|
||||
<string name="theme_title">السمة</string>
|
||||
<string name="upload_date_text">تم النشر في %1$s</string>
|
||||
<string name="upload_date_text">منشورة على %1$s</string>
|
||||
<string name="unsupported_url">رابط غير مدعوم</string>
|
||||
<string name="use_external_audio_player_title">استخدام مشغل صوت خارجي</string>
|
||||
<string name="use_external_video_player_title">استخدام مشغل فيديو خارجي</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>
|
||||
@@ -427,7 +427,6 @@
|
||||
<string name="default_kiosk_page_summary">الكشك الافتراضي</string>
|
||||
<string name="no_one_watching">لا توجد مشاهدة</string>
|
||||
<string name="no_one_listening">لا أحد يستمع</string>
|
||||
<string name="localization_changes_requires_app_restart">ستتغير اللغة بمجرد إعادة تشغيل التطبيق</string>
|
||||
<plurals name="watching">
|
||||
<item quantity="zero">%s مشاهدة</item>
|
||||
<item quantity="one">%s مشاهدة</item>
|
||||
@@ -558,7 +557,7 @@
|
||||
<string name="remove_watched">إزالة ما تمت مشاهدته</string>
|
||||
<string name="show_original_time_ago_summary">ستكون النصوص الأصلية من الخدمات مرئية في عناصر البث</string>
|
||||
<string name="show_original_time_ago_title">عرض الوقت الأصلي على العناصر</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">قم بتشغيل \"وضع تقييد المحتوى\" في يوتيوب\\</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">قم بتشغيل \"وضع تقييد المحتوى\" في يوتيوب</string>
|
||||
<string name="video_detail_by">بواسطة %s</string>
|
||||
<string name="channel_created_by">أنشأها %s</string>
|
||||
<string name="detail_sub_channel_thumbnail_view_description">الصورة الرمزية للقناة</string>
|
||||
@@ -856,7 +855,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">شارك تفاصيل قائمة التشغيل مثل اسم قائمة التشغيل وعناوين الفيديو أو كقائمة بسيطة من عناوين URL للفيديو</string>
|
||||
<string name="video_details_list_item">- %1$s: %2$s</string>
|
||||
<plurals name="replies">
|
||||
<item quantity="zero">رد %s</item>
|
||||
@@ -882,4 +880,14 @@
|
||||
<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>
|
||||
<string name="share_playlist_as_youtube_temporary_playlist">المشاركة كقائمة تشغيل مؤقتة على YouTube</string>
|
||||
<string name="tab_bookmarks_short">قوائم التشغيل</string>
|
||||
<string name="feed_group_page_summary">صفحة مجموعة القناة</string>
|
||||
<string name="select_a_feed_group">حدد مجموعة المحتوى</string>
|
||||
<string name="no_feed_group_created_yet">لم تنشئ مجموعة محتوى</string>
|
||||
<string name="channel_tab_likes">الإعجابات</string>
|
||||
<string name="search_with_service_name">البحث %1$s</string>
|
||||
<string name="search_with_service_name_and_filter">البحث %1$s (%2$s)</string>
|
||||
<string name="migration_info_6_7_title">تمت إزالة صفحة أفضل 50 من SoundCloud</string>
|
||||
<string name="migration_info_6_7_message">أوقفت SoundCloud صفحة أفضل 50 الأصلية. تمت إزالة علامة التبويب المقابلة من صفحتك الرئيسية.</string>
|
||||
</resources>
|
||||
|
||||
@@ -342,7 +342,6 @@
|
||||
<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>
|
||||
<string name="import_settings">Tənzimləmələri də idxal etmək istəyirsiniz\?</string>
|
||||
<string name="localization_changes_requires_app_restart">Tətbiq yenidən başladıldıqdan sonra dil dəyişəcəkdir</string>
|
||||
<string name="top_50">Ən yaxşı 50</string>
|
||||
<string name="new_and_hot">Yeni və populyar</string>
|
||||
<string name="local">Yerli</string>
|
||||
@@ -787,7 +786,6 @@
|
||||
<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_message">Pleylist adı və video başlıqları kimi təfsilatlar və ya video URL-lərin sadə siyahısı olaraq pleylist 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
|
||||
@@ -804,4 +802,9 @@
|
||||
<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>
|
||||
<string name="share_playlist_as_youtube_temporary_playlist">YouTube müvəqqəti pleylisti kimi paylaş</string>
|
||||
<string name="tab_bookmarks_short">Pleylistlər</string>
|
||||
<string name="select_a_feed_group">Axın qrupu seçin</string>
|
||||
<string name="no_feed_group_created_yet">Hələ heç bir axın qrupu yaradılmayıb</string>
|
||||
<string name="feed_group_page_summary">Kanal qrupu səhifəsi</string>
|
||||
</resources>
|
||||
|
||||
3
app/src/main/res/values-azb/strings.xml
Normal file
3
app/src/main/res/values-azb/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
</resources>
|
||||
@@ -223,7 +223,6 @@
|
||||
<string name="privacy_policy_title">Política de privacidá de NewPipe</string>
|
||||
<string name="error_file_creation">El ficheru nun pue crease</string>
|
||||
<string name="error_http_no_content">El sirvidor nun unvia datos</string>
|
||||
<string name="localization_changes_requires_app_restart">La llingua va camudar namás que se reanicie l\'aplicación.</string>
|
||||
<string name="search">Buscar</string>
|
||||
<string name="share_dialog_title">Compartir con</string>
|
||||
<string name="subscribed_button_title">Soscribiéstite</string>
|
||||
|
||||
@@ -551,7 +551,6 @@
|
||||
<string name="new_and_hot">Yangi va qaynoqlari</string>
|
||||
<string name="top_50">Top 50</string>
|
||||
<string name="trending">Ommabop</string>
|
||||
<string name="localization_changes_requires_app_restart">Ilova qayta ishga tushirilgandan so\'ng til o\'zgaradi.</string>
|
||||
<string name="error_unable_to_load_comments">Fikrlarni yuklab bo‘lmadi</string>
|
||||
<string name="import_settings">Sozlamalarni ham import qilmoqchimisiz\?</string>
|
||||
<string name="override_current_data">Bu sizning joriy sozlamangizni bekor qiladi.</string>
|
||||
|
||||
@@ -26,4 +26,6 @@
|
||||
<string name="use_external_video_player_summary">Duad bei manchen Auflösungen d\'Tonspur weggad</string>
|
||||
<string name="open_in_popup_mode">Im Pop-up Modus aufmacha</string>
|
||||
<string name="main_bg_subtitle">Drug auf\'d Lubn zum ofanga.</string>
|
||||
</resources>
|
||||
<string name="ok">Bassd scho</string>
|
||||
<string name="no">naa</string>
|
||||
</resources>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<string name="use_external_video_player_summary">Прыбірае гук пры пэўнай раздзяляльнасці</string>
|
||||
<string name="use_external_audio_player_title">Знешні аўдыяплэер</string>
|
||||
<string name="subscribe_button_title">Падпісацца</string>
|
||||
<string name="subscribed_button_title">Вы падпісаныя</string>
|
||||
<string name="subscribed_button_title">Вы падпісаны</string>
|
||||
<string name="channel_unsubscribed">Падпіска адменена</string>
|
||||
<string name="subscription_change_failed">Не ўдалося змяніць падпіску</string>
|
||||
<string name="subscription_update_failed">Не ўдалося абнавіць падпіску</string>
|
||||
@@ -55,7 +55,7 @@
|
||||
<string name="popup_remember_size_pos_summary">Памятаць апошнія памер і пазіцыю ўсплывальнага акна</string>
|
||||
<string name="use_inexact_seek_title">Хуткі пошук пазіцыі</string>
|
||||
<string name="use_inexact_seek_summary">Недакладны пошук дазваляе плэеру знаходзіць пазіцыі хутчэй са зніжанай дакладнасцю. Пошук цягам 5, 15 ці 25 секунд пры гэтым немажлівы</string>
|
||||
<string name="thumbnail_cache_wipe_complete_notice">Кэш малюнкаў ачышчаны</string>
|
||||
<string name="thumbnail_cache_wipe_complete_notice">Кэш відарысаў ачышчаны</string>
|
||||
<string name="metadata_cache_wipe_title">Ачысціць кэш метаданых</string>
|
||||
<string name="metadata_cache_wipe_summary">Выдаліць усе даныя вэб-старонак у кэшы</string>
|
||||
<string name="metadata_cache_wipe_complete_notice">Кэш метаданых ачышчаны</string>
|
||||
@@ -71,8 +71,8 @@
|
||||
<string name="resume_on_audio_focus_gain_summary">Працягваць прайграванне пасля перапынкаў (напрыклад, тэлефонных званкоў)</string>
|
||||
<string name="download_dialog_title">Спампаваць</string>
|
||||
<string name="show_next_and_similar_title">«Наступнае» і «Прапанаванае» відэа</string>
|
||||
<string name="show_hold_to_append_title">Паказваць падказку «Зацісніце, каб дадаць»</string>
|
||||
<string name="show_hold_to_append_summary">Паказваць падказку пры націсканні «У акне» або «У фоне» на старонцы звестак аб відэа</string>
|
||||
<string name="show_hold_to_append_title">Паказваць падказку «Утрымлівайце, каб дадаць у чаргу»</string>
|
||||
<string name="show_hold_to_append_summary">Паказваць падказку пры націсканні кнопкі «У акне» або «У фоне» на старонцы відэа</string>
|
||||
<string name="unsupported_url">URL не падтрымліваецца</string>
|
||||
<string name="default_content_country_title">Прадвызначаная краіна кантэнту</string>
|
||||
<string name="content_language_title">Прадвызначаная мова кантэнту</string>
|
||||
@@ -92,7 +92,7 @@
|
||||
<string name="error_report_title">Справаздача пра памылку</string>
|
||||
<string name="all">Усе</string>
|
||||
<string name="channels">Каналы</string>
|
||||
<string name="playlists">Плэйлісты</string>
|
||||
<string name="playlists">Плэй-лісты</string>
|
||||
<string name="tracks">Трэкі</string>
|
||||
<string name="users">Карыстальнікі</string>
|
||||
<string name="disabled">Адключана</string>
|
||||
@@ -111,8 +111,8 @@
|
||||
<string name="switch_to_main">Перайсці ў галоўнае акно</string>
|
||||
<string name="import_data_title">Імпартаваць даныя</string>
|
||||
<string name="export_data_title">Экспартаваць даныя</string>
|
||||
<string name="import_data_summary">Перавызначае вашу бягучую гісторыю, падпіскі, плэйлісты і (неабавязкова) налады</string>
|
||||
<string name="export_data_summary">Экспарт гісторыі, падпісак, плэйлістоў і налад</string>
|
||||
<string name="import_data_summary">Перавызначае вашу бягучую гісторыю, падпіскі, плэй-лісты і (неабавязкова) налады</string>
|
||||
<string name="export_data_summary">Экспарт гісторыі, падпісак, плэй-лістоў і налад</string>
|
||||
<string name="clear_views_history_title">Ачысціць гісторыю праглядаў</string>
|
||||
<string name="clear_views_history_summary">Выдаліць гісторыю прайграных патокаў і пазіцыі прайгравання</string>
|
||||
<string name="delete_view_history_alert">Выдаліць усю гісторыю праглядаў\?</string>
|
||||
@@ -135,9 +135,9 @@
|
||||
<string name="video_streams_empty">Відэапатокі не знойдзены</string>
|
||||
<string name="audio_streams_empty">Аўдыяпатокі не знойдзены</string>
|
||||
<string name="invalid_directory">Такой папкі не існуе</string>
|
||||
<string name="invalid_source">Крыніца кантэнту або файла не існуе</string>
|
||||
<string name="invalid_source">Такога файла або крыніцы кантэнту не існуе</string>
|
||||
<string name="invalid_file">Файл не існуе або няма дазволу на яго чытанне ці запіс</string>
|
||||
<string name="file_name_empty_error">Імя файла не можа быць пустым</string>
|
||||
<string name="file_name_empty_error">Назва файла не можа быць пустой</string>
|
||||
<string name="error_occurred_detail">Адбылася памылка: %1$s</string>
|
||||
<string name="no_streams_available_download">Няма трансляцый, даступных для спампоўвання</string>
|
||||
<string name="sorry_string">Прабачце, гэта не павінна было адбыцца.</string>
|
||||
@@ -147,14 +147,14 @@
|
||||
<string name="what_device_headline">Інфармацыя:</string>
|
||||
<string name="what_happened_headline">Што адбылося:</string>
|
||||
<string name="info_labels">Што:\\nЗапыт:\\nМова кантэнту:\\nКраіна кантэнту:\\nМова праграмы:\\nСэрвіс:\\nЧас GMT:\\nПакет:\\nВерсія:\\nВерсія АС:</string>
|
||||
<string name="your_comment">Ваш каментарый (на англійскай):</string>
|
||||
<string name="your_comment">Ваш каментарый (па-англійску):</string>
|
||||
<string name="error_details_headline">Падрабязнасці:</string>
|
||||
<string name="detail_thumbnail_view_description">Прайграць відэа, працягласць:</string>
|
||||
<string name="detail_uploader_thumbnail_view_description">Мініяцюра аватара карыстальніка</string>
|
||||
<string name="detail_likes_img_view_description">Спадабалася</string>
|
||||
<string name="detail_dislikes_img_view_description">Не спадабалася</string>
|
||||
<string name="search_no_results">Няма вынікаў</string>
|
||||
<string name="empty_list_subtitle">Нічога няма, акрамя цвыркуноў</string>
|
||||
<string name="empty_list_subtitle">Нічога няма, хоць сабак ганяй</string>
|
||||
<string name="detail_drag_description">Перацягніце, каб змяніць парадак</string>
|
||||
<string name="video">Відэа</string>
|
||||
<string name="audio">Аўдыя</string>
|
||||
@@ -165,8 +165,9 @@
|
||||
<string name="no_subscribers">Няма падпісчыкаў</string>
|
||||
<plurals name="subscribers">
|
||||
<item quantity="one">%s падпісчык</item>
|
||||
<item quantity="few">%s падпісчыка</item>
|
||||
<item quantity="few">%s падпісчыкі</item>
|
||||
<item quantity="many">%s падпісчыкаў</item>
|
||||
<item quantity="other">%s падпісчыкаў</item>
|
||||
</plurals>
|
||||
<string name="no_views">Няма праглядаў</string>
|
||||
<plurals name="views">
|
||||
@@ -177,7 +178,7 @@
|
||||
</plurals>
|
||||
<string name="no_videos">Няма відэа</string>
|
||||
<plurals name="videos">
|
||||
<item quantity="one">%s Відэа</item>
|
||||
<item quantity="one">%s відэа</item>
|
||||
<item quantity="few">%s відэа</item>
|
||||
<item quantity="many">%s відэа</item>
|
||||
<item quantity="other">%s відэа</item>
|
||||
@@ -190,7 +191,7 @@
|
||||
<string name="dismiss">Адхіліць</string>
|
||||
<string name="rename">Перайменаваць</string>
|
||||
<string name="ok">ОК</string>
|
||||
<string name="msg_name">Імя файла</string>
|
||||
<string name="msg_name">Назва файла</string>
|
||||
<string name="msg_threads">Патокі</string>
|
||||
<string name="msg_error">Памылка</string>
|
||||
<string name="msg_running">NewPipe спампоўвае</string>
|
||||
@@ -203,11 +204,11 @@
|
||||
<string name="title_activity_recaptcha">Запыт reCAPTCHA</string>
|
||||
<string name="recaptcha_request_toast">Запытаны ўвод reCAPTCHA</string>
|
||||
<string name="settings_category_downloads_title">Спампоўванне</string>
|
||||
<string name="settings_file_charset_title">Дапушчальныя ў назвах файлаў сімвалы</string>
|
||||
<string name="settings_file_charset_title">Сімвалы, дапушчальныя ў назвах файлаў</string>
|
||||
<string name="settings_file_replacement_character_summary">Недапушчальныя сімвалы замяняюцца на гэты</string>
|
||||
<string name="settings_file_replacement_character_title">Сімвал для замены</string>
|
||||
<string name="charset_letters_and_digits">Літары і лічбы</string>
|
||||
<string name="charset_most_special_characters">Большасць спецзнакаў</string>
|
||||
<string name="charset_most_special_characters">Большасць спецсімвалаў</string>
|
||||
<string name="title_activity_about">Аб NewPipe</string>
|
||||
<string name="title_licenses">Іншыя ліцэнзіі</string>
|
||||
<string name="copyright" formatted="true">© %1$s %2$s пад ліцэнзіяй %3$s</string>
|
||||
@@ -221,7 +222,7 @@
|
||||
<string name="donation_encouragement">NewPipe распрацаваны добраахвотнікамі, якія праводзяць свой вольны час, забяспечваючы лепшы карыстацкі досвед. Дапамажыце распрацоўшчыкам зрабіць NewPipe яшчэ лепшым, пакуль яны атрымліваюць асалоду ад кавы.</string>
|
||||
<string name="give_back">Ахвяраваць грошы</string>
|
||||
<string name="website_title">Вэб-сайт</string>
|
||||
<string name="website_encouragement">Дзеля атрымання больш падрабязнай інфармацыі і апошніх навін аб NewPipe наведайце наш вэб-сайт.</string>
|
||||
<string name="website_encouragement">Наведайце вэб-сайт, каб атрымаць больш інфармацыі і паглядзець апошнія навіны NewPipe.</string>
|
||||
<string name="privacy_policy_title">Палітыка прыватнасці NewPipe</string>
|
||||
<string name="privacy_policy_encouragement">Праект NewPipe вельмі адказна ставіцца да вашай прыватнасці. Таму праграма не збірае ніякіх даных без вашай згоды. \nПалітыка прыватнасці NewPipe падрабязна тлумачыць, якія даныя адпраўляюцца і захоўваюцца пры адпраўцы справаздачы пра збой.</string>
|
||||
<string name="read_privacy_policy">Прачытаць палітыку прыватнасці</string>
|
||||
@@ -231,9 +232,9 @@
|
||||
<string name="title_activity_history">Гісторыя</string>
|
||||
<string name="action_history">Гісторыя</string>
|
||||
<string name="delete_item_search_history">Выдаліць гэты элемент з гісторыі пошуку?</string>
|
||||
<string name="title_last_played">Нядаўна прайграныя</string>
|
||||
<string name="title_most_played">Найбольш прайграваныя</string>
|
||||
<string name="main_page_content">Кантэнт галоўнай старонкі</string>
|
||||
<string name="title_last_played">Прайгравалася нядаўна</string>
|
||||
<string name="title_most_played">Прайгравалася найбольш</string>
|
||||
<string name="main_page_content">Змесціва галоўнай старонкі</string>
|
||||
<string name="blank_page_summary">Пустая старонка</string>
|
||||
<string name="kiosk_page_summary">Старонка кіёска</string>
|
||||
<string name="channel_page_summary">Старонка канала</string>
|
||||
@@ -252,30 +253,30 @@
|
||||
<string name="play_queue_remove">Выдаліць</string>
|
||||
<string name="play_queue_stream_detail">Падрабязнасці</string>
|
||||
<string name="play_queue_audio_settings">Налады аўдыя</string>
|
||||
<string name="hold_to_append">Зацісніце, каб дадаць у чаргу</string>
|
||||
<string name="hold_to_append">Утрымлівайце, каб дадаць у чаргу</string>
|
||||
<string name="start_here_on_background">Пачаць прайграванне ў фоне</string>
|
||||
<string name="start_here_on_popup">Пачаць прайграванне у акне</string>
|
||||
<string name="start_here_on_popup">Пачаць прайграванне ў акне</string>
|
||||
<string name="drawer_open">Адкрыць бакавую панэль</string>
|
||||
<string name="drawer_close">Закрыць бакавую панэль</string>
|
||||
<string name="preferred_open_action_settings_title">Пры адкрыцці кантэнту</string>
|
||||
<string name="preferred_open_action_settings_summary">Пры адкрыцці спасылкі на кантэнт — %s</string>
|
||||
<string name="video_player">Відэаплэер</string>
|
||||
<string name="background_player">Фонавы плэер</string>
|
||||
<string name="background_player">Фонавы прайгравальнік</string>
|
||||
<string name="popup_player">Аконны прайгравальнік</string>
|
||||
<string name="always_ask_open_action">Заўсёды пытаць</string>
|
||||
<string name="preferred_player_fetcher_notification_title">Атрыманне звестак…</string>
|
||||
<string name="preferred_player_fetcher_notification_message">Загрузка запытанага кантэнту</string>
|
||||
<string name="create_playlist">Стварыць плэйліст</string>
|
||||
<string name="create_playlist">Стварыць плэй-ліст</string>
|
||||
<string name="rename_playlist">Перайменаваць</string>
|
||||
<string name="name">Імя</string>
|
||||
<string name="add_to_playlist">Дадаць у плэйліст</string>
|
||||
<string name="set_as_playlist_thumbnail">Усталяваць як мініяцюру плэйліста</string>
|
||||
<string name="bookmark_playlist">Дадаць плэйліст у закладкі</string>
|
||||
<string name="name">Назва</string>
|
||||
<string name="add_to_playlist">Дадаць у плэй-ліст</string>
|
||||
<string name="set_as_playlist_thumbnail">Зрабіць мініяцюрай плэй-ліста</string>
|
||||
<string name="bookmark_playlist">Дадаць плэй-ліст у закладкі</string>
|
||||
<string name="unbookmark_playlist">Выдаліць закладку</string>
|
||||
<string name="delete_playlist_prompt">Выдаліць плэйліст\?</string>
|
||||
<string name="playlist_creation_success">Плэйліст створаны</string>
|
||||
<string name="playlist_add_stream_success">Дададзена ў плэйліст</string>
|
||||
<string name="playlist_thumbnail_change_success">Мініяцюра плэйліста зменена.</string>
|
||||
<string name="delete_playlist_prompt">Выдаліць плэй-ліст?</string>
|
||||
<string name="playlist_creation_success">Плэй-ліст створаны</string>
|
||||
<string name="playlist_add_stream_success">Дададзена ў плэй-ліст</string>
|
||||
<string name="playlist_thumbnail_change_success">Мініяцюра плэй-ліста зменена.</string>
|
||||
<string name="caption_none">Без субцітраў</string>
|
||||
<string name="resize_fit">Падагнаць</string>
|
||||
<string name="resize_fill">Запоўніць</string>
|
||||
@@ -308,7 +309,7 @@
|
||||
<string name="skip_silence_checkbox">Прапускаць цішыню</string>
|
||||
<string name="playback_step">Крок</string>
|
||||
<string name="playback_reset">Скід</string>
|
||||
<string name="start_accept_privacy_policy">У адпаведнасці з Агульным рэгламентам па абароне даных ЕС (GDPR), звяртаем вашу ўвагу на палітыку прыватнасці NewPipe. Уважліва азнаёмцеся з ёй. \nВы павінны прыняць ўмовы, каб адправіць нам справаздачу пра памылку.</string>
|
||||
<string name="start_accept_privacy_policy">У адпаведнасці з Агульным рэгламентам па абароне даных ЕС (GDPR), звяртаем вашу ўвагу на палітыку прыватнасці NewPipe. Уважліва азнаёмцеся з ёй. \nВы павінны прыняць умовы, каб адправіць нам справаздачу пра памылку.</string>
|
||||
<string name="accept">Прыняць</string>
|
||||
<string name="decline">Адмовіцца</string>
|
||||
<string name="limit_data_usage_none_description">Без абмежаванняў</string>
|
||||
@@ -316,7 +317,7 @@
|
||||
<string name="minimize_on_exit_title">Згортванне пры пераключэнні праграмы</string>
|
||||
<string name="minimize_on_exit_summary">Дзеянне пры пераключэнні з асноўнага відэаплэера на іншую праграму — %s</string>
|
||||
<string name="minimize_on_exit_none_description">Нічога не рабіць</string>
|
||||
<string name="minimize_on_exit_background_description">Згортванне у фон</string>
|
||||
<string name="minimize_on_exit_background_description">Згортванне ў фон</string>
|
||||
<string name="minimize_on_exit_popup_description">Згортванне ў акно</string>
|
||||
<string name="unsubscribe">Адпісацца</string>
|
||||
<string name="tab_choose">Выберыце ўкладку</string>
|
||||
@@ -326,10 +327,10 @@
|
||||
<string name="app_update_notification_channel_description">Апавяшчэнні пра новыя версіі NewPipe</string>
|
||||
<string name="download_to_sdcard_error_title">Знешняе сховішча недаступна</string>
|
||||
<string name="download_to_sdcard_error_message">Спампоўванне на знешнюю SD-карту немагчыма. Скінуць размяшчэнне папкі спампоўвання?</string>
|
||||
<string name="saved_tabs_invalid_json">Памылка чытання захаваных укладак. Выкарыстоўваюцца ўкладкі па змаўчанні</string>
|
||||
<string name="saved_tabs_invalid_json">Не ўдалося прачытаць захаваныя ўкладкі, таму выкарыстоўваюцца прадвызначаныя</string>
|
||||
<string name="restore_defaults">Аднавіць прадвызначаныя значэнні</string>
|
||||
<string name="restore_defaults_confirmation">Аднавіць прадвызначаныя значэнні?</string>
|
||||
<string name="subscribers_count_not_available">Колькасць падпісчыкаў недаступная</string>
|
||||
<string name="subscribers_count_not_available">Колькасць падпісчыкаў недаступна</string>
|
||||
<string name="main_page_content_summary">Укладкі, бачныя на галоўнай старонцы</string>
|
||||
<string name="updates_setting_title">Абнаўленні</string>
|
||||
<string name="updates_setting_description">Паказваць апавяшчэнне пры наяўнасці новай версіі</string>
|
||||
@@ -346,9 +347,9 @@
|
||||
<string name="enqueue">Дадаць у чаргу</string>
|
||||
<string name="permission_denied">Дзеянне забаронена сістэмай</string>
|
||||
<string name="download_failed">Памылка спампоўвання</string>
|
||||
<string name="generate_unique_name">Стварыць унікальнае імя</string>
|
||||
<string name="generate_unique_name">Стварыць унікальную назву</string>
|
||||
<string name="overwrite">Перазапісаць</string>
|
||||
<string name="download_already_running">Файл з такім імем ужо спампоўваецца</string>
|
||||
<string name="download_already_running">Файл з такой назвай ўжо спампоўваецца</string>
|
||||
<string name="show_error">Паказаць тэкст памылкі</string>
|
||||
<string name="error_path_creation">Немагчыма стварыць папку прызначэння</string>
|
||||
<string name="error_file_creation">Немагчыма стварыць файл</string>
|
||||
@@ -362,7 +363,7 @@
|
||||
<string name="stop">Спыніць</string>
|
||||
<string name="max_retry_msg">Максімум спроб</string>
|
||||
<string name="max_retry_desc">Колькасць спроб спампаваць да адмены</string>
|
||||
<string name="pause_downloads_on_mobile">Перапыніць у платных сетках</string>
|
||||
<string name="pause_downloads_on_mobile">Прыпыняць у сетках з тарыфікацыяй</string>
|
||||
<string name="pause_downloads_on_mobile_desc">Карысна пры пераключэнні на мабільную сетку, хоць некаторыя спампоўванні немагчыма прыпыніць</string>
|
||||
<string name="events">Падзеі</string>
|
||||
<string name="conferences">Канферэнцыі</string>
|
||||
@@ -375,19 +376,19 @@
|
||||
<string name="enable_playback_resume_title">Працягваць прайграванне</string>
|
||||
<string name="enable_playback_resume_summary">Аднаўляць апошнюю пазіцыю</string>
|
||||
<string name="enable_playback_state_lists_title">Пазіцыі ў спісах</string>
|
||||
<string name="enable_playback_state_lists_summary">Адлюстроўваць індыкатары пазіцый прагляду ў спісах</string>
|
||||
<string name="enable_playback_state_lists_summary">Паказваць у спісах пазіцыю прайгравання</string>
|
||||
<string name="settings_category_clear_data_title">Ачыстка даных</string>
|
||||
<string name="watch_history_states_deleted">Пазіцыі прайгравання выдалены</string>
|
||||
<string name="missing_file">Файл перамешчаны ці выдалены</string>
|
||||
<string name="overwrite_unrelated_warning">Файл з такім імем ужо існуе</string>
|
||||
<string name="overwrite_finished_warning">Файл з такім імем ужо існуе</string>
|
||||
<string name="missing_file">Файл перамешчаны або выдалены</string>
|
||||
<string name="overwrite_unrelated_warning">Файл з такой назвай ужо існуе</string>
|
||||
<string name="overwrite_finished_warning">Спампаваны файл з такой назвай ужо існуе</string>
|
||||
<string name="overwrite_failed">немагчыма перазапісаць файл</string>
|
||||
<string name="download_already_pending">Файл з такім імем ужо дададзены ў чаргу спампоўвання</string>
|
||||
<string name="download_already_pending">Файл з такой назвай ужо ў чарзе спампоўвання</string>
|
||||
<string name="error_postprocessing_stopped">Праграма NewPipe была закрыта падчас працы з файлам</string>
|
||||
<string name="error_insufficient_storage_left">На прыладзе скончылася вольнае месца</string>
|
||||
<string name="error_progress_lost">Прагрэс страчаны, бо файл быў выдалены</string>
|
||||
<string name="error_timeout">Час злучэння выйшла</string>
|
||||
<string name="confirm_prompt">Вы хочаце ачысціць гісторыю спампоўвання ці выдаліць спампаваныя файлы?</string>
|
||||
<string name="error_timeout">Скончыўся час злучэння</string>
|
||||
<string name="confirm_prompt">Ачысціць гісторыю спампоўвання або выдаліць спампаваныя файлы?</string>
|
||||
<string name="enable_queue_limit">Абмежаваць чаргу спампоўвання</string>
|
||||
<string name="enable_queue_limit_desc">Толькі адно адначасовае спампоўванне</string>
|
||||
<string name="start_downloads">Пачаць спампоўванне</string>
|
||||
@@ -396,7 +397,7 @@
|
||||
<string name="downloads_storage_ask_summary">Пры кожным спампоўванні вам будзе прапанавана выбраць месца захавання. \nУключыце сістэмны сродак выбару папак (SAF), калі хочаце спампоўваць файлы на знешнюю SD-карту</string>
|
||||
<string name="downloads_storage_use_saf_title">Выкарыстоўваць сістэмны сродак выбару папак (SAF)</string>
|
||||
<string name="downloads_storage_use_saf_summary">«Storage Access Framework» дазваляе выконваць спампоўванне на знешнюю SD-карту</string>
|
||||
<string name="drawer_header_description">Пераключыць службу, выбраную ў дадзены момант:</string>
|
||||
<string name="drawer_header_description">Пераключэнне сэрвісу, зараз выбраны:</string>
|
||||
<string name="clear_playback_states_summary">Выдаліць усе пазіцыі прайгравання</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">Уключыць «Абмежаваны рэжым» YouTube</string>
|
||||
<string name="peertube_instance_add_https_only">Падтрымліваюцца толькі URL-адрасы HTTPS</string>
|
||||
@@ -420,7 +421,7 @@
|
||||
<string name="peertube_instance_add_fail">Не ўдалося праверыць сервер</string>
|
||||
<string name="peertube_instance_add_help">Увядзіце URL-адрас сервера</string>
|
||||
<string name="peertube_instance_url_summary">Выберыце ўлюбёныя серверы PeerTube</string>
|
||||
<string name="clear_queue_confirmation_description">Актыўны плэер быў зменены</string>
|
||||
<string name="clear_queue_confirmation_description">Чарга актыўнага прайгравальніка будзе заменена</string>
|
||||
<string name="clear_queue_confirmation_summary">Пераключэнне з аднаго плэера на другі можа прывесці да замены вашай чаргі</string>
|
||||
<string name="clear_queue_confirmation_title">Запытваць пацвярджэнне перад ачысткай чаргі</string>
|
||||
<string name="never">Ніколі</string>
|
||||
@@ -434,15 +435,15 @@
|
||||
<string name="most_liked">Найбольш папулярнае</string>
|
||||
<string name="local">Лакальнае</string>
|
||||
<string name="recently_added">Нядаўна дададзенае</string>
|
||||
<string name="no_playlist_bookmarked_yet">Плэйлісты яшчэ не дададзены</string>
|
||||
<string name="select_a_playlist">Выберыце плэйліст</string>
|
||||
<string name="no_playlist_bookmarked_yet">Плэй-лісты яшчэ не дададзены</string>
|
||||
<string name="select_a_playlist">Выберыце плэй-ліст</string>
|
||||
<string name="default_kiosk_page_summary">Прадвызначаны кіёск</string>
|
||||
<string name="done">Так</string>
|
||||
<string name="subtitle_activity_recaptcha">Па завяршэнні націсніце «Гатова»</string>
|
||||
<string name="infinite_videos">∞ відэа</string>
|
||||
<string name="more_than_100_videos">100+ відэа</string>
|
||||
<string name="error_report_open_issue_button_text">Багрэпарт на GitHub</string>
|
||||
<string name="copy_for_github">Скапіруйце адфарматаваны багрэпарт</string>
|
||||
<string name="error_report_open_issue_button_text">Паведаміць на GitHub</string>
|
||||
<string name="copy_for_github">Скапіяваць адфарматаваную справаздачу</string>
|
||||
<string name="permission_display_over_apps">Дайце дазвол на адлюстраванне паверх іншых праграм</string>
|
||||
<string name="delete_playback_states_alert">Выдаліць усе пазіцыі прайгравання\?</string>
|
||||
<string name="clear_playback_states_title">Выдаліць пазіцыі прайгравання</string>
|
||||
@@ -452,7 +453,7 @@
|
||||
<string name="albums">Альбомы</string>
|
||||
<string name="songs">Песні</string>
|
||||
<string name="videos_string">Відэа</string>
|
||||
<string name="auto_queue_toggle">Аўтаматычная чарга</string>
|
||||
<string name="auto_queue_toggle">Аўтапрайграванне</string>
|
||||
<string name="seek_duration_title">Крок перамотвання</string>
|
||||
<string name="notification_colorize_title">Каляровыя апавяшчэнні</string>
|
||||
<string name="notification_action_nothing">Нічога</string>
|
||||
@@ -484,15 +485,15 @@
|
||||
<string name="night_theme_summary">Выберыце любімую начную тэму - %s</string>
|
||||
<string name="description_select_enable">Дазвол вылучэння тэксту ў апісанні</string>
|
||||
<string name="select_night_theme_toast">Вы можаце выбраць сваю любімую начную тэму ніжэй</string>
|
||||
<string name="night_theme_available">Гэта опцыя даступна толькі тады, калі %s будзе выбранай тэмаю</string>
|
||||
<string name="night_theme_available">Параметр даступны, толькі калі выбрана тэма %s</string>
|
||||
<string name="download_has_started">Спампоўванне пачалося</string>
|
||||
<string name="notifications_disabled">Апавяшчэнні адключаны</string>
|
||||
<string name="tablet_mode_title">Рэжым планшэта</string>
|
||||
<string name="off">Адключыць</string>
|
||||
<string name="no_audio_streams_available_for_external_players">Няма аўдыяпатокаў даступных для знешніх плэераў</string>
|
||||
<string name="get_notified">Апавяшчаць</string>
|
||||
<string name="no_video_streams_available_for_external_players">Няма даступных відэатрансляцый для знешніх плэераў</string>
|
||||
<string name="selected_stream_external_player_not_supported">Выбраная трансляцыя не падтрымліваецца знешнімі плэерамі</string>
|
||||
<string name="no_video_streams_available_for_external_players">Няма відэапатокаў даступных для знешніх плэераў</string>
|
||||
<string name="selected_stream_external_player_not_supported">Выбраны паток не падтрымліваецца знешнімі плэерамі</string>
|
||||
<string name="select_quality_external_players">Выберыце якасць для знешніх плэераў</string>
|
||||
<string name="unknown_quality">Невядомая якасць</string>
|
||||
<string name="unknown_format">Невядомы фармат</string>
|
||||
@@ -503,8 +504,8 @@
|
||||
<string name="open_with">Адкрыць праз</string>
|
||||
<string name="night_theme_title">Начная тэма</string>
|
||||
<string name="open_website_license">Адкрыць вэб-сайт</string>
|
||||
<string name="description_select_note">Цяпер Вы можаце вылучаць тэкст у апісанні. Звярніце ўвагу, што ў рэжыме вылучэння старонка можа мігацець, а спасылкі могуць быць недаступныя для націскання.</string>
|
||||
<string name="start_main_player_fullscreen_title">Запускаць галоўны прайгравальнік у поўнаэкранным рэжыме</string>
|
||||
<string name="description_select_note">Цяпер можна вылучаць тэкст у апісанні. Звярніце ўвагу, што ў рэжыме вылучэння старонка можа мільгаць, а спасылкі не націскацца.</string>
|
||||
<string name="start_main_player_fullscreen_title">Запускаць асноўны прайгравальнік у поўнаэкранным рэжыме</string>
|
||||
<string name="show_channel_details">Паказаць дэталі канала</string>
|
||||
<string name="low_quality_smaller">Нізкая якасць (менш)</string>
|
||||
<string name="hash_channel_name">Апавяшчэнне пра відэахэшаванне</string>
|
||||
@@ -536,17 +537,16 @@
|
||||
<item quantity="other">%d дзён</item>
|
||||
</plurals>
|
||||
<string name="clear_download_history">Ачысціць гісторыю спампоўвання</string>
|
||||
<string name="localization_changes_requires_app_restart">Мова зменіцца пасля перазапуску праграмы</string>
|
||||
<string name="no_one_listening">Ніхто не слухае</string>
|
||||
<string name="on">Уключыць</string>
|
||||
<string name="hash_channel_description">Апавяшчэнні пра ход відэахэшавання</string>
|
||||
<string name="create_error_notification">Стварыць паведамленне пра памылку</string>
|
||||
<string name="feed_group_dialog_select_subscriptions">Выберыце падпіскі</string>
|
||||
<string name="import_subscriptions_hint">Імпарт ці экспарт падпісак з 3-кропкавага меню</string>
|
||||
<string name="import_subscriptions_hint">Імпартуйце або экспартуйце падпіскі праз меню з трыма кропкамі ⁝</string>
|
||||
<string name="description_select_disable">Забарона вылучэння тэксту ў апісанні</string>
|
||||
<string name="fast_mode">Хуткі рэжым</string>
|
||||
<string name="faq_description">Калі ў вас узніклі праблемы з выкарыстаннем праграмы, абавязкова азнаёмцеся з адказамі на частыя пытанні!</string>
|
||||
<string name="disable_media_tunneling_title">Адключыць тунэляванне медыя</string>
|
||||
<string name="disable_media_tunneling_title">Адключыць тунэляванне мультымедыя</string>
|
||||
<string name="seekbar_preview_thumbnail_title">Мініяцюра з перадпраглядам у паласе перамотвання</string>
|
||||
<string name="high_quality_larger">Высокая якасць (больш)</string>
|
||||
<string name="dont_show">Не паказваць</string>
|
||||
@@ -562,8 +562,8 @@
|
||||
<plurals name="minutes">
|
||||
<item quantity="one">%d хвіліна</item>
|
||||
<item quantity="few">%d хвіліны</item>
|
||||
<item quantity="many">%d хвілінаў</item>
|
||||
<item quantity="other">%d хвілінаў</item>
|
||||
<item quantity="many">%d хвілін</item>
|
||||
<item quantity="other">%d хвілін</item>
|
||||
</plurals>
|
||||
<string name="progressive_load_interval_summary">Змяніць памер інтэрвалу загрузкі прагрэсіўнага змесціва (у цяперашні час %s). Меншае значэнне можа паскорыць іх першапачатковую загрузку</string>
|
||||
<string name="show_description_summary">Выключыце, каб схаваць апісанне відэа і дадатковую інфармацыю</string>
|
||||
@@ -575,12 +575,12 @@
|
||||
<string name="error_report_channel_name">Апавяшчэнне аб памылцы</string>
|
||||
<string name="error_report_channel_description">Апавяшчэнні для паведамлення аб памылках</string>
|
||||
<string name="error_report_notification_title">Адбылася памылка NewPipe, націсніце, каб адправіць справаздачу</string>
|
||||
<string name="start_main_player_fullscreen_summary">Запускаць відэа ва ўвесь экран, калі адключаны аўтапаварот. Міні-плэер даступны пры выхадзе з поўнаэкраннага рэжыму</string>
|
||||
<string name="start_main_player_fullscreen_summary">Калі аўтапаварот адключаны, відэа адразу запускаецца ў поўнаэкранным рэжыме. Міні-плэер застаецца даступным, трэба толькі выйсці з поўнаэкраннага рэжыму</string>
|
||||
<string name="peertube_instance_url_help">Шукайце серверы, якія вам даспадобы, на %s</string>
|
||||
<string name="show_meta_info_title">Паказваць метаданыя</string>
|
||||
<string name="ignore_hardware_media_buttons_title">Ігнараваць падзеі апаратных медыякнопак</string>
|
||||
<string name="show_age_restricted_content_summary">Паказваць змесціва, магчыма непрыдатнае для дзяцей, таму што яно мае ўзроставыя абмежаванні (напрыклад, 18+)</string>
|
||||
<string name="error_report_open_github_notice">Калі ласка, праверце, ці існуе ўжо праблема з абмеркаваннем вашага збою. Пры стварэнні дублікатаў тыкетаў вы забіраеце ў нас час, які мы маглі б патраціць на выпраўленне фактычнай памылкі.</string>
|
||||
<string name="error_report_open_github_notice">Праверце, ці не існуе заяўкі з абмеркаваннем вашай праблемы. Дублікаты марнуюць наш час і праз гэта адцягваецца вырашэнне сапраўдных задач.</string>
|
||||
<string name="error_report_notification_toast">Адбылася памылка, глядзіце апавяшчэнне</string>
|
||||
<string name="crash_the_player">Збой плэера</string>
|
||||
<string name="ignore_hardware_media_buttons_summary">Карысна, напрыклад, калі вы карыстаецеся гарнітурай са зламанымі фізічнымі кнопкамі</string>
|
||||
@@ -592,7 +592,7 @@
|
||||
<string name="msg_calculating_hash">Разлік хэша</string>
|
||||
<string name="recaptcha_solve">Вырашана</string>
|
||||
<string name="playlist_no_uploader">Створана аўтаматычна (запампавальнік не знойдзены)</string>
|
||||
<string name="duplicate_in_playlist">Плэйлісты, якія пазначаны шэрым, ужо ўтрымліваюць гэты элемент.</string>
|
||||
<string name="duplicate_in_playlist">Плэй-лісты, якія пазначаны шэрым, ужо ўтрымліваюць гэты элемент.</string>
|
||||
<plurals name="new_streams">
|
||||
<item quantity="one">%s новая трансляцыя</item>
|
||||
<item quantity="few">%s новыя трансляцыі</item>
|
||||
@@ -603,19 +603,19 @@
|
||||
<string name="enqueue_next_stream">У чаргу далей</string>
|
||||
<string name="enqueued_next">У чарзе наступны</string>
|
||||
<string name="loading_stream_details">Загрузка звестак аб стрыме…</string>
|
||||
<string name="processing_may_take_a_moment">Апрацоўка... Можа заняць некаторы час</string>
|
||||
<string name="playlist_add_stream_success_duplicate">Дублікат дададзены %d раз</string>
|
||||
<string name="processing_may_take_a_moment">Ідзе апрацоўка… Крыху пачакайце</string>
|
||||
<string name="playlist_add_stream_success_duplicate">Дублікат дададзены %d раз(ы)</string>
|
||||
<string name="leak_canary_not_available">LeakCanary недаступны</string>
|
||||
<string name="show_memory_leaks">Паказаць уцечкі памяці</string>
|
||||
<string name="disable_media_tunneling_summary">Адключыце мультымедыйнае тунэляванне, калі ў вас з\'яўляецца чорны экран або заіканне падчас прайгравання відэа.</string>
|
||||
<string name="disable_media_tunneling_summary">Адключыце тунэляванне мультымедыя, калі відэа прайграецца перарывіста або паказваецца чорны экран.</string>
|
||||
<string name="msg_failed_to_copy">Не ўдалося скапіяваць у буфер абмену</string>
|
||||
<string name="no_dir_yet">Папка спампоўвання яшчэ не зададзена, выберыце папку спампоўвання цяпер</string>
|
||||
<string name="faq_title">Частыя пытанні</string>
|
||||
<string name="faq">Перайсці на вэб-сайт</string>
|
||||
<string name="main_page_content_swipe_remove">Правядзіце пальцам па элементах, каб выдаліць іх</string>
|
||||
<string name="unset_playlist_thumbnail">Адмяніць пастаянную мініяцюру</string>
|
||||
<string name="show_image_indicators_title">Паказваць індыкатары выяў</string>
|
||||
<string name="show_image_indicators_summary">Паказваць каляровыя стужкі Пікаса на выявах, якія пазначаюць іх крыніцу: чырвоная для сеткі, сіняя для дыска і зялёная для памяці</string>
|
||||
<string name="main_page_content_swipe_remove">Каб выдаліць элемент, змахніце яго ўбок</string>
|
||||
<string name="unset_playlist_thumbnail">Прыбраць пастаянную мініяцюру</string>
|
||||
<string name="show_image_indicators_title">Паказваць на відарысах указальнікі</string>
|
||||
<string name="show_image_indicators_summary">Паказваць на відарысах каляровыя меткі Picasso, якія абазначаюць яго крыніцу: чырвоная — сетка, сіняя — дыск, зялёная — памяць</string>
|
||||
<string name="feed_processing_message">Апрацоўка стужкі…</string>
|
||||
<string name="downloads_storage_ask_summary_no_saf_notice">Пры кожным спампоўванні вам будзе прапанавана выбраць месца захавання</string>
|
||||
<string name="feed_notification_loading">Загрузка канала…</string>
|
||||
@@ -624,7 +624,7 @@
|
||||
<string name="percent">Працэнт</string>
|
||||
<string name="remove_watched_popup_warning">Відэа, якія прагледжаны перад дадаваннем і пасля дадавання ў спіс прайгравання, будуць выдалены. \nВы ўпэўнены? Гэта дзеянне немагчыма скасаваць!</string>
|
||||
<string name="show_crash_the_player_summary">Паказвае варыянт збою пры выкарыстанні плэера</string>
|
||||
<string name="remove_watched">Выдаліць прагледжанае</string>
|
||||
<string name="remove_watched">Выдаліць прагледжаныя</string>
|
||||
<string name="show_error_snackbar">Паказаць панэль памылак</string>
|
||||
<string name="semitone">Паўтон</string>
|
||||
<string name="any_network">Любая сетка</string>
|
||||
@@ -641,16 +641,16 @@
|
||||
<item quantity="many">%d выбраных</item>
|
||||
<item quantity="other">%d выбраных</item>
|
||||
</plurals>
|
||||
<string name="feed_group_dialog_empty_name">Пустая назва групы</string>
|
||||
<string name="feed_group_dialog_delete_message">Выдаліць гэту групу?</string>
|
||||
<string name="feed_group_dialog_empty_name">Назва групы пустая</string>
|
||||
<string name="feed_group_dialog_delete_message">Выдаліць групу?</string>
|
||||
<string name="feed_create_new_group_button_title">Новая</string>
|
||||
<string name="feed_group_show_only_ungrouped_subscriptions">Паказаць толькі разгрупаваныя падпіскі</string>
|
||||
<string name="feed_show_upcoming">Маючыя адбыцца</string>
|
||||
<string name="feed_group_show_only_ungrouped_subscriptions">Паказваць толькі не згрупаваныя падпіскі</string>
|
||||
<string name="feed_show_upcoming">Запланаваныя</string>
|
||||
<string name="show_crash_the_player_title">Паказваць «Збой плэера»</string>
|
||||
<string name="check_new_streams">Запусціце праверку новых патокаў</string>
|
||||
<string name="crash_the_app">Збой праграмы</string>
|
||||
<string name="enable_streams_notifications_title">Апавяшчэнні аб новых стрымах</string>
|
||||
<string name="enable_streams_notifications_summary">Апавяшчаць аб новых стрымах з падпісак</string>
|
||||
<string name="enable_streams_notifications_title">Апавяшчэнні пра новыя відэа</string>
|
||||
<string name="enable_streams_notifications_summary">Апавяшчаць пра новыя відэа з падпісак</string>
|
||||
<string name="streams_notifications_interval_title">Частата праверкі</string>
|
||||
<string name="streams_notifications_network_title">Неабходны тып злучэння</string>
|
||||
<string name="check_for_updates">Праверыць наяўнасць абнаўленняў</string>
|
||||
@@ -666,13 +666,13 @@
|
||||
</plurals>
|
||||
<string name="feed_update_threshold_option_always_update">Заўсёды абнаўляць</string>
|
||||
<string name="feed_update_threshold_title">Парог абнаўлення стужкі</string>
|
||||
<string name="feed_load_error_account_info">Немагчыма загрузіць канал для «%s».</string>
|
||||
<string name="feed_load_error_account_info">Не ўдалося загрузіць канал для «%s».</string>
|
||||
<string name="settings_category_feed_title">Стужка</string>
|
||||
<string name="feed_update_threshold_summary">Час пасля апошняга абнаўлення, перш чым падпіска лічыцца састарэлай — %s</string>
|
||||
<string name="feed_load_error">Памылка загрузкі канала</string>
|
||||
<string name="feed_load_error_terminated">Уліковы запіс аўтара быў спынены. \nNewPipe не зможа загрузіць гэты канал у будучыні. \nАдпісацца ад канала?</string>
|
||||
<string name="feed_load_error_fast_unknown">Рэжым хуткай загрузкі стужкі не дае дадатковай інфармацыі аб гэтым.</string>
|
||||
<string name="feed_use_dedicated_fetch_method_title">Атрымлівайце са спецыяльнага канала, калі ён даступны</string>
|
||||
<string name="feed_use_dedicated_fetch_method_title">Атрыманне даных са спецыяльнага канала, калі ён ёсць</string>
|
||||
<string name="feed_use_dedicated_fetch_method_enable_button">Уключыць хуткі рэжым</string>
|
||||
<string name="metadata_category">Катэгорыя</string>
|
||||
<string name="metadata_tags">Тэгі</string>
|
||||
@@ -685,7 +685,7 @@
|
||||
<string name="streams_not_yet_supported_removed">Трансляцыі, спампоўванне якіх яшчэ не падтрымліваецца, не паказваюцца</string>
|
||||
<string name="detail_sub_channel_thumbnail_view_description">Мініяцюра аватара канала</string>
|
||||
<string name="video_detail_by">Аўтар: %s</string>
|
||||
<string name="detail_heart_img_view_description">Аўтару відэа спадабалася гэта</string>
|
||||
<string name="detail_heart_img_view_description">Спадабалася аўтару відэа</string>
|
||||
<string name="channel_created_by">Створана %s</string>
|
||||
<string name="feed_use_dedicated_fetch_method_disable_button">Адключыць хуткі рэжым</string>
|
||||
<string name="metadata_privacy_public">Публічная</string>
|
||||
@@ -693,12 +693,12 @@
|
||||
<string name="you_successfully_subscribed">Вы падпісаліся на канал</string>
|
||||
<string name="recent">Апошнія</string>
|
||||
<string name="radio">Радыё</string>
|
||||
<string name="feed_hide_streams_title">Паказваць запланаваныя трансляцыі</string>
|
||||
<string name="feed_hide_streams_title">Паказваць наступныя патокі</string>
|
||||
<string name="feed_show_hide_streams">Паказаць/схаваць трансляцыі</string>
|
||||
<string name="content_not_supported">Гэты кантэнт яшчэ не падтрымліваецца NewPipe.
|
||||
\n
|
||||
\nСпадзяюся, ён будзе падтрымлівацца ў наступных версіях.</string>
|
||||
<string name="playlist_page_summary">Старонка плэйліста</string>
|
||||
<string name="playlist_page_summary">Старонка плэй-ліста</string>
|
||||
<string name="show_thumbnail_title">Паказваць мініяцюру</string>
|
||||
<string name="show_thumbnail_summary">Выкарыстоўваць мініяцюру як фон для экрана блакіроўкі і апавяшчэнняў</string>
|
||||
<string name="no_appropriate_file_manager_message">Для гэтага дзеяння не знойдзены прыдатны файлавы менеджар. \nУсталюйце файлавы менеджар або паспрабуйце адключыць «%s» у наладах спампоўвання</string>
|
||||
@@ -710,14 +710,14 @@
|
||||
<string name="service_provides_reason">%s дае наступную прычыну:</string>
|
||||
<string name="featured">Вартае ўвагі</string>
|
||||
<string name="metadata_privacy_internal">Унутраная</string>
|
||||
<string name="feed_show_watched">Цалкам прагледзеў</string>
|
||||
<string name="feed_show_watched">Прагледжаныя цалкам</string>
|
||||
<string name="paid_content">Гэты кантэнт даступны толькі для аплачаных карыстальнікаў, таму NewPipe не можа яго трансляваць або спампоўваць.</string>
|
||||
<string name="feed_use_dedicated_fetch_method_summary">Даступны ў некаторых службах, звычайна нашмат хутчэй, але можа вяртаць абмежаваную колькасць элементаў і часта няпоўную інфармацыю (напрыклад, без працягласці, тыпу элемента, без актыўнага стану)</string>
|
||||
<string name="feed_use_dedicated_fetch_method_summary">Даступна для некаторых сэрвісаў, звычайна значна хутчэй, але можа перадаваць абмежаваную колькасць элементаў і не ўсю інфармацыю (можа адсутнічаць працягласць, тып элемента, паказчык трансляцыі)</string>
|
||||
<string name="metadata_age_limit">Узроставае абмежаванне</string>
|
||||
<string name="no_appropriate_file_manager_message_android_10">Для гэтага дзеяння не знойдзены прыдатны файлавы менеджар. \nУсталюйце файлавы менеджар, сумяшчальны з Storage Access Framework</string>
|
||||
<string name="no_app_to_open_intent">Ніякая праграма на вашай прыладзе не можа адкрыць гэта</string>
|
||||
<string name="no_app_to_open_intent">На прыладзе няма праграмы, каб адкрыць гэты файл</string>
|
||||
<string name="progressive_load_interval_exoplayer_default">Стандартнае значэнне ExoPlayer</string>
|
||||
<string name="feed_show_partially_watched">Часткова прагледжана</string>
|
||||
<string name="feed_show_partially_watched">Прагледжаныя часткова</string>
|
||||
<string name="feed_use_dedicated_fetch_method_help_text">Лічыце, што загрузка каналаў адбываецца занадта павольна? Калі так, паспрабуйце ўключыць хуткую загрузку (можна змяніць у наладах або націснуўшы кнопку ніжэй). \n \nNewPipe прапануе два спосабы загрузкі каналаў: \n• Атрыманне ўсяго канала падпіскі. Павольны, але інфармацыя поўная). \n• Выкарыстанне спецыяльнай канчатковай кропкі абслугоўвання. Хуткі, але звычайна інфармацыя няпоўная). \n \nРозніца паміж імі ў тым, што ў хуткім звычайна адсутнічае частка інфармацыі, напрыклад, працягласць або тып (немагчыма адрозніць трансляцыі ад звычайных відэа), і ён можа вяртаць менш элементаў. \n \nYouTube з\'яўляецца прыкладам сэрвісу, які прапануе гэты хуткі метад праз RSS-канал. \n \nТакім чынам, выбар залежыць ад таго, чаму вы аддаяце перавагу: хуткасці або дакладнасці інфармацыя.</string>
|
||||
<string name="metadata_privacy">Прыватнасць</string>
|
||||
<string name="metadata_language">Мова</string>
|
||||
@@ -747,10 +747,8 @@
|
||||
<string name="audio_track_present_in_video">У гэтым патоку ўжо павінна быць гукавая дарожка</string>
|
||||
<string name="use_exoplayer_decoder_fallback_summary">Уключыце гэту опцыю, калі ў вас ёсць праблемы з ініцыялізацыяй дэкодэра, якая вяртаецца да дэкодэраў з больш нізкім прыярытэтам, калі ініцыялізацыя асноўных дэкодэраў не ўдаецца. Гэта можа прывесці да нізкай прадукцыйнасці прайгравання, чым пры выкарыстанні асноўных дэкодэраў</string>
|
||||
<string name="settings_category_exoplayer_summary">Кіраванне некаторымі наладамі ExoPlayer. Каб гэтыя змены ўступілі ў сілу, патрабуецца перазапуск прайгравальніка</string>
|
||||
<string name="always_use_exoplayer_set_output_surface_workaround_summary">Гэты абыходны шлях вызваляе і паўторна стварае відэакодэкі, калі адбываецца змяненне паверхні, замест таго, каб усталёўваць паверхню непасрэдна для кодэка. ExoPlayer ужо выкарыстоўваецца на некаторых прыладах з гэтай праблемай, гэты параметр мае ўплыў толькі на прыладах з Android 6 і вышэй
|
||||
\n
|
||||
\nУключэнне гэтай опцыі можа прадухіліць памылкі прайгравання пры пераключэнні бягучага відэаплэера або пераключэнні ў поўнаэкранны рэжым</string>
|
||||
<string name="image_quality_title">Якасць выяў</string>
|
||||
<string name="always_use_exoplayer_set_output_surface_workaround_summary">Гэты абыходны шлях вызваляе і паўторна стварае відэакодэкі, калі адбываецца змяненне паверхні, замест таго, каб задаваць паверхню непасрэдна для кодэка. Ужо выкарыстоўваецца ExoPlayer на некаторых прыладах з такой праблемай, гэты параметр ужываецца толькі на прыладах з Android 6 і вышэй\n\nУключэнне параметра можа прадухіліць памылкі прайгравання пры пераключэнні бягучага відэаплэера або пераключэнні ў поўнаэкранны рэжым</string>
|
||||
<string name="image_quality_title">Якасць відарысаў</string>
|
||||
<string name="channel_tab_videos">Відэа</string>
|
||||
<string name="question_mark">\?</string>
|
||||
<string name="metadata_subscribers">Падпісчыкі</string>
|
||||
@@ -769,29 +767,28 @@
|
||||
<string name="feed_fetch_channel_tabs">Атрыманне ўкладак канала</string>
|
||||
<string name="metadata_avatars">Аватары</string>
|
||||
<string name="next_stream">Наступны паток</string>
|
||||
<string name="disable_media_tunneling_automatic_info">Прадвызначана на вашай прыладзе адключана медыятунэляванне, бо гэтая мадэль прылады яго не падтрымлівае.</string>
|
||||
<string name="disable_media_tunneling_automatic_info">Прадвызначана на вашай прыладзе адключана тунэляванне мультымедыя, бо вядома, што гэта мадэль яго не падтрымлівае.</string>
|
||||
<string name="metadata_subchannel_avatars">Аватары падканалаў</string>
|
||||
<string name="open_play_queue">Адкрыйце чаргу прайгравання</string>
|
||||
<string name="image_quality_none">Не загружаць выявы</string>
|
||||
<string name="open_play_queue">Адкрыць чаргу прайгравання</string>
|
||||
<string name="image_quality_none">Не загружаць відарысы</string>
|
||||
<string name="image_quality_high">Высокая якасць</string>
|
||||
<string name="channel_tab_about">Аб канале</string>
|
||||
<string name="share_playlist">Абагуліць плэйліст</string>
|
||||
<string name="channel_tab_about">Пра канал</string>
|
||||
<string name="share_playlist">Абагуліць плэй-ліст</string>
|
||||
<string name="forward">Пераматаць наперад</string>
|
||||
<string name="channel_tab_albums">Альбомы</string>
|
||||
<string name="rewind">Пераматаць назад</string>
|
||||
<string name="replay">Паўтарыць</string>
|
||||
<string name="feed_fetch_channel_tabs_summary">Атрыманыя ўкладкі пры абнаўленні стужкі. Гэты параметр не прымяняецца, калі канал абнаўляецца ў хуткім рэжыме.</string>
|
||||
<string name="share_playlist_with_titles_message">Абагуліць плэйліст, перадаецца назва плэйліста і назвы відэа або просты спіс URL-адрасоў відэа</string>
|
||||
<string name="feed_fetch_channel_tabs_summary">Укладкі, для якіх атрымліваюцца даныя пры абнаўленні стужкі. Гэты параметр не дзейнічае, калі канал абнаўляецца з выкарыстаннем хуткага рэжыму.</string>
|
||||
<string name="image_quality_medium">Сярэдняя якасць</string>
|
||||
<string name="metadata_uploader_avatars">Загрузнік аватараў</string>
|
||||
<string name="metadata_banners">Банеры</string>
|
||||
<string name="channel_tab_playlists">Плэйлісты</string>
|
||||
<string name="channel_tab_playlists">Плэй-лісты</string>
|
||||
<string name="video_details_list_item">- %1$s: %2$s</string>
|
||||
<string name="main_tabs_position_summary">Перамясціць панэль укладак ўніз</string>
|
||||
<string name="main_tabs_position_summary">Перамясціць панэль укладак уніз</string>
|
||||
<string name="no_live_streams">Няма жывых трансляцый</string>
|
||||
<string name="image_quality_summary">Выберыце якасць выяў і ці трэба спампоўваць выявы ўвогуле, каб паменшыць выкарыстанне даных і памяці. Змены ачышчаюць кэш выяў як у памяці, так і на дыску - %s</string>
|
||||
<string name="image_quality_summary">Выберыце якасць відарысаў ці ўвогуле не загружаць відарысы, каб паменшыць выкарыстанне даных і памяці. Змены ачышчаюць кэш відарысаў у памяці і на дыску (%s)</string>
|
||||
<string name="play">Прайграць</string>
|
||||
<string name="more_options">Іншыя опцыі</string>
|
||||
<string name="more_options">Іншыя параметры</string>
|
||||
<string name="metadata_thumbnails">Мініяцюры</string>
|
||||
<string name="channel_tab_tracks">Трэкі</string>
|
||||
<string name="duration">Працягласць</string>
|
||||
@@ -810,12 +807,20 @@
|
||||
<string name="notification_actions_summary_android13">Каб адрэдагаваць кожнае з дзеянняў у апавяшчэнні, націсніце на яго. Першыя тры дзеянні (прайграванне/паўза, папярэдні і наступны) зададзены сістэмай, іх змяніць немагчыма.</string>
|
||||
<string name="error_insufficient_storage">Недастаткова вольнага месца на прыладзе</string>
|
||||
<string name="yes">Так</string>
|
||||
<string name="auto_update_check_description">NewPipe можа аўтаматычна правяраць наяўнасць абнаўленняў і паведаміць вам, калі яны будуць даступны. \nУключыць гэту функцыю?</string>
|
||||
<string name="auto_update_check_description">NewPipe можа час ад часу аўтаматычна правяраць наяўнасць новай версіі і апавяшчаць, калі яна будзе даступна. \nУключыць гэту функцыю?</string>
|
||||
<string name="import_settings_vulnerable_format">Налады ў імпартаваным экспарце выкарыстоўваюць уразлівы фармат, які састарэў з версіі NewPipe 0.27.0. Пераканайцеся, што імпартаваны экспарт атрыманы з надзейнай крыніцы, і ў будучыні пераважней выкарыстоўваць толькі экспарт, атрыманы з NewPipe 0.27.0 ці навей. Падтрымка імпарту налад у гэтым уразлівым фармаце хутка будзе цалкам выдаленая, і тады старыя версіі NewPipe больш не змогуць імпартаваць наладкі з экспарту з новых версій.</string>
|
||||
<string name="no">Не</string>
|
||||
<string name="settings_category_backup_restore_title">Рэзервовае капіраванне і аднаўленне</string>
|
||||
<string name="settings_category_backup_restore_title">Рэзервовае капіяванне і аднаўленне</string>
|
||||
<string name="reset_settings_title">Скінуць налады</string>
|
||||
<string name="reset_settings_summary">Скінуць усе налады на іх прадвызначаныя значэнні</string>
|
||||
<string name="reset_all_settings">Пры скіданні ўсіх налад будуць адхілены ўсе вашы змены налад і праграма перазапусціцца. \n \nСапраўды хочаце працягнуць?</string>
|
||||
<string name="audio_track_type_secondary">другасны</string>
|
||||
<string name="share_playlist_as_youtube_temporary_playlist">Абагуліць як часовы плэйліст YouTube</string>
|
||||
<string name="tab_bookmarks_short">Плэй-лісты</string>
|
||||
<string name="select_a_feed_group">Выберыце групу каналаў</string>
|
||||
<string name="no_feed_group_created_yet">Група каналаў яшчэ не створана</string>
|
||||
<string name="feed_group_page_summary">Старонка групы каналаў</string>
|
||||
<string name="search_with_service_name">Пошук %1$s</string>
|
||||
<string name="search_with_service_name_and_filter">Пошук %1$s (%2$s)</string>
|
||||
<string name="channel_tab_likes">Спадабалася</string>
|
||||
</resources>
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
<string name="copyright" formatted="true">© %1$s от %2$s под лиценза %3$s</string>
|
||||
<string name="contribution_title">Съдействайте</string>
|
||||
<string name="contribution_encouragement">За всичко, което се сетите: превод, промени по дизайна, изчистване на кода или много сериозни промени по кода – помощта е винаги добре дошла. Колкото повече развитие, толкова по-добре!</string>
|
||||
<string name="donation_title">Направете дарение</string>
|
||||
<string name="donation_title">Дарение</string>
|
||||
<string name="donation_encouragement">NewPipe се разработва от доброволци, които отделят от своето време, за да предоставят най-доброто потребителско изживяване. Включете се в разработката като почерпите разработчиците с една чашка кафе, които да изпият, докато правят NewPipe още по-добро приложение.</string>
|
||||
<string name="give_back">Дари</string>
|
||||
<string name="website_title">Уебсайт</string>
|
||||
@@ -203,7 +203,7 @@
|
||||
<string name="read_privacy_policy">Прочетете нашата политика за поверителност</string>
|
||||
<string name="app_license_title">Лицензът на NewPipe</string>
|
||||
<string name="no_player_found_toast">Липсва стрийм плейър (можете да изтеглите VLC, за да пуснете стрийма).</string>
|
||||
<string name="show_hold_to_append_summary">Покажи съвет при натискане на фона или изскачащия бутон във видеоклипа „Подробности:“</string>
|
||||
<string name="show_hold_to_append_summary">Покажи съвет при натискане на фона или изскачащия бутон във видеоклипа \"Подробности:“</string>
|
||||
<string name="clear_views_history_summary">Изтрива историята на възпроизвежданите стриймове и позицията на възпроизвеждането</string>
|
||||
<string name="video_streams_empty">Не са намерени видео стриймове</string>
|
||||
<string name="audio_streams_empty">Не са намерени аудио стриймове</string>
|
||||
@@ -229,7 +229,7 @@
|
||||
<string name="main_page_content">Съдържание на главната страница</string>
|
||||
<string name="blank_page_summary">Празна страница</string>
|
||||
<string name="kiosk_page_summary">Страница-павилион</string>
|
||||
<string name="channel_page_summary">Страница на определен канал</string>
|
||||
<string name="channel_page_summary">Страница на канал</string>
|
||||
<string name="select_a_channel">Изберете канал</string>
|
||||
<string name="no_channel_subscribed_yet">За момента нямате абонаменти</string>
|
||||
<string name="select_a_kiosk">Изберете павилион</string>
|
||||
@@ -432,7 +432,6 @@
|
||||
<string name="most_liked">Най-харесвани</string>
|
||||
<string name="done">Готово</string>
|
||||
<string name="comments_tab_description">Коментари</string>
|
||||
<string name="localization_changes_requires_app_restart">Езикът ще се смени след рестартиране на приложението</string>
|
||||
<string name="metadata_privacy_unlisted">Скрит</string>
|
||||
<string name="metadata_privacy_private">Частен</string>
|
||||
<string name="remote_search_suggestions">Предложения за отдалечено търсене</string>
|
||||
@@ -519,7 +518,7 @@
|
||||
<string name="pause_downloads_on_mobile_desc">Полезно при превключване към мобилни данни, въпреки че някои изтегляния не поддържат възобновяване и ще започнат отначало</string>
|
||||
<string name="crash_the_app">Срив на приложението</string>
|
||||
<string name="notification_colorize_summary">Цветът на известието да се избира според главния цвят в миниатюрата на видеото (може да не работи на всички устройства)</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">Използване на ограничения режим на YouTube</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">Включване на \"Ограничен режим“ в YouTube</string>
|
||||
<string name="youtube_restricted_mode_enabled_summary">YouTube предлага „ограничен режим“, чрез който можете да филтрирате потенциално съдържание за възрастни</string>
|
||||
<string name="restricted_video">Това видео е с възрастова граница.
|
||||
\n
|
||||
@@ -558,7 +557,7 @@
|
||||
<string name="enable_playback_state_lists_summary">Покажи индикатори за позиция на възпроизвеждане в списъци</string>
|
||||
<string name="notification_actions_summary_android13">Редактирайте всяко действие за известяване по-долу, като щракнете върху него. Първите три действия (възпроизвеждане/пауза, предишно и следващо) се задават от системата и не могат да бъдат конфигурирани.</string>
|
||||
<string name="right_gesture_control_summary">Изберете жест за дясната половина на екрана на плейъра</string>
|
||||
<string name="right_gesture_control_title">Действие с жест на дясно</string>
|
||||
<string name="right_gesture_control_title">Действие с жест надясно</string>
|
||||
<string name="start_main_player_fullscreen_title">Стартирайте основния плейър на цял екран</string>
|
||||
<string name="streams_notification_channel_description">Известия за нови видеоклипове в абонаментите</string>
|
||||
<string name="enable_streams_notifications_summary">Известявайте за нови видеоклипове в абонаментите</string>
|
||||
@@ -714,7 +713,6 @@
|
||||
<string name="replay">Повторение</string>
|
||||
<string name="rewind">Превъртане назад</string>
|
||||
<string name="forward">Напред</string>
|
||||
<string name="share_playlist_with_titles_message">Споделете плейлист с подробности, като име на плейлист и заглавия на видеоклипове или като обикновен списък с URL адреси на видеоклипове</string>
|
||||
<string name="share_playlist_with_list">Споделяне на списък с URL</string>
|
||||
<string name="delete_playback_states_alert">Изтрии всички позиции на възпроизвеждане?</string>
|
||||
<string name="watch_history_states_deleted">Позициите за възпроизвеждане са изтрити</string>
|
||||
@@ -811,4 +809,14 @@
|
||||
<string name="always_use_exoplayer_set_output_surface_workaround_title">Винаги използвайте заобикаляне на настройката на повърхността на видеоизхода на ExoPlayer</string>
|
||||
<string name="clear_playback_states_title">Изтрий позиции за възпроизвеждане</string>
|
||||
<string name="audio_track_type_secondary">вторичен</string>
|
||||
<string name="share_playlist_as_youtube_temporary_playlist">Споделяне като временен плейлист в YouTube</string>
|
||||
<string name="tab_bookmarks_short">Плейлисти</string>
|
||||
<string name="no_feed_group_created_yet">Все още няма създадена група за емисии</string>
|
||||
<string name="feed_group_page_summary">Страница на групата канали</string>
|
||||
<string name="select_a_feed_group">Изберете група емисии</string>
|
||||
<string name="search_with_service_name">Търсене %1$s</string>
|
||||
<string name="search_with_service_name_and_filter">Търсене %1$s (%2$s)</string>
|
||||
<string name="channel_tab_likes">Харесвания</string>
|
||||
<string name="migration_info_6_7_title">Страница SoundCloud Top 50 е премахната</string>
|
||||
<string name="migration_info_6_7_message">SoundCloud преустанови оригиналните класации Топ 50. Съответният раздел е премахнат от главната ви страница.</string>
|
||||
</resources>
|
||||
|
||||
@@ -534,7 +534,6 @@
|
||||
<string name="downloads_storage_ask_summary">প্রত্যেক ডাউনলোড কোথায় রাখা হবে তা জিজ্ঞেস করা হবে।
|
||||
\nমেমোরি কার্ডে ডাউনলোড করতে সিস্টেম ফোল্ডার পিকার (SAF) এনেবল করুন</string>
|
||||
<string name="download_already_running">এই নামের একটি ডাউনলোড চলমান</string>
|
||||
<string name="localization_changes_requires_app_restart">অ্যাপ আবার শুরু হলে ভাষা পাল্টাবে</string>
|
||||
<string name="disable_media_tunneling_title">মিডিয়া সুরঙ্গকরণ অক্ষম</string>
|
||||
<string name="feed_load_error_fast_unknown">দ্রুত ফিড অবস্থা এ বিষয়ে এর বেশি তথ্য দেয় না।</string>
|
||||
<string name="no_dir_yet">কোনো ডাউনলোড ফোল্ডার নির্দিষ্ট করা হয়নি, এখনই একটা সহজাত ডাউনলোড ফোল্ডার নির্বাচন করো</string>
|
||||
|
||||
2
app/src/main/res/values-bqi/strings.xml
Normal file
2
app/src/main/res/values-bqi/strings.xml
Normal file
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources></resources>
|
||||
121
app/src/main/res/values-br/strings.xml
Normal file
121
app/src/main/res/values-br/strings.xml
Normal file
@@ -0,0 +1,121 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="cancel">Nullañ</string>
|
||||
<string name="ok">Mat eo</string>
|
||||
<string name="no">Ket</string>
|
||||
<string name="open_in_browser">Digeriñ e-barzh ar merdeer</string>
|
||||
<string name="open_with">Digeriñ gant</string>
|
||||
<string name="share">Rannañ</string>
|
||||
<string name="download">Pellgargañ</string>
|
||||
<string name="did_you_mean">Klask a raec\'h \"%1$s\"?</string>
|
||||
<string name="share_dialog_title">Rannañ gant</string>
|
||||
<string name="use_external_audio_player_title">Arverañ ul lenner aodio diavaez</string>
|
||||
<string name="subscribe_button_title">Koumanantiñ</string>
|
||||
<string name="unsubscribe">Digoumanantiñ</string>
|
||||
<string name="subscription_change_failed">N\'haller ket kemmañ ar c\'houmanant</string>
|
||||
<string name="subscription_update_failed">N\'haller ket hizivaat ar c\'houmanant</string>
|
||||
<string name="show_info">Diskouez an titouroù</string>
|
||||
<string name="tab_bookmarks">Rolloù-lenn enrollet</string>
|
||||
<string name="tab_bookmarks_short">Rolloù-lenn</string>
|
||||
<string name="tab_choose">Dibab un ivinell</string>
|
||||
<string name="controls_background_title">Drekleur</string>
|
||||
<string name="controls_popup_title">Diflugell</string>
|
||||
<string name="controls_add_to_playlist_title">Ouzhpennañ da</string>
|
||||
<string name="download_path_title">Teuliad pellgargañ ar videoioù</string>
|
||||
<string name="download_path_audio_summary">Amañ e vez kadavet ar restroù aodio pellgarget</string>
|
||||
<string name="download_path_summary">Amañ e vez kadavet ar restroù video pellgarget</string>
|
||||
<string name="notification_action_buffering">O kargañ</string>
|
||||
<string name="notification_action_nothing">Netra</string>
|
||||
<string name="default_audio_format_title">Mentrezh aodio dre ziouer</string>
|
||||
<string name="theme_title">Dodenn</string>
|
||||
<string name="night_theme_title">Dodenn noz</string>
|
||||
<string name="light_theme_title">Sklaer</string>
|
||||
<string name="dark_theme_title">Teñval</string>
|
||||
<string name="volume">Tregern</string>
|
||||
<string name="enable_search_history_title">Roll istor enklask</string>
|
||||
<string name="download_dialog_title">Pellgargañ</string>
|
||||
<string name="start_main_player_fullscreen_title">Lañsañ al lenner pennañ e mod skramm a-bezh</string>
|
||||
<string name="autoplay_title">Lenn emgefreek</string>
|
||||
<string name="default_content_country_title">Bro an endalc\'had dre ziouer</string>
|
||||
<string name="peertube_instance_url_title">Erioloù PeerTube</string>
|
||||
<string name="none">Tra ebet</string>
|
||||
<string name="default_video_format_title">Mentrezh video dre ziouer</string>
|
||||
<string name="notification_action_shuffle">Lenn mell-divell</string>
|
||||
<string name="play_audio">Aodio</string>
|
||||
<string name="play_with_kodi_title">Lenn gant Kodi</string>
|
||||
<string name="brightness">Lintr</string>
|
||||
<string name="settings_category_clear_data_title">Skarzhañ ar roadennoù</string>
|
||||
<string name="search">Klask</string>
|
||||
<string name="use_external_video_player_title">Arverañ ul lenner video diavaez</string>
|
||||
<string name="download_path_audio_title">Teuliad pellgargañ ar restroù aodio</string>
|
||||
<string name="black_theme_title">Du</string>
|
||||
<string name="show_search_suggestions_title">Kinnigoù enklask</string>
|
||||
<string name="resume_on_audio_focus_gain_title">Kenderc\'hel al lenn</string>
|
||||
<string name="unsupported_url">URL anskor</string>
|
||||
<string name="content_language_title">Yezh an endalc\'had dre ziouer</string>
|
||||
<string name="controls_download_desc">Pellgargañ restr al lanv</string>
|
||||
<string name="install">Staliañ</string>
|
||||
<string name="yes">Ya</string>
|
||||
<string name="tab_subscriptions">Koumanantoù</string>
|
||||
<string name="settings">Arventennoù</string>
|
||||
<string name="search_showing_result_for">Setu an disoc\'hoù evit: %s</string>
|
||||
<string name="use_external_video_player_summary">Lamet e vez an aodio gant diarunustedoù \'zo</string>
|
||||
<string name="channel_unsubscribed">Digoumanantet oc\'h bet d\'ar chadenn</string>
|
||||
<string name="enable_watch_history_title">Sellet ouzh ar roll istor</string>
|
||||
<string name="subscribed_button_title">Koumanantet</string>
|
||||
<string name="default_resolution_title">Diarunusted dre ziouer</string>
|
||||
<string name="peertube_instance_url_summary">Diuzit hoc\'h erioloù PeerTube gwell ganeoc\'h</string>
|
||||
<string name="peertube_instance_url_help">Kavit an erioloù a blij deoc\'h war %s</string>
|
||||
<string name="peertube_instance_add_title">Ouzhpennañ un eriol</string>
|
||||
<string name="settings_category_player_title">Lenner</string>
|
||||
<string name="settings_category_video_audio_title">Video hag aodio</string>
|
||||
<string name="settings_category_history_title">Roll istor ha krubuilh</string>
|
||||
<string name="settings_category_appearance_title">Neuz</string>
|
||||
<string name="settings_category_debug_title">Diveugañ</string>
|
||||
<string name="settings_category_updates_title">Hizivadurioù</string>
|
||||
<string name="settings_category_player_notification_title">Rebuzadur al lenner</string>
|
||||
<string name="settings_category_backup_restore_title">Assav ha gwarediñ</string>
|
||||
<string name="duration_live">War-eeun</string>
|
||||
<string name="downloads_title">Pellgargadurioù</string>
|
||||
<string name="all">Pep tra</string>
|
||||
<string name="channels">Chadennoù</string>
|
||||
<string name="videos_string">Videoioù</string>
|
||||
<string name="tracks">Loabroù</string>
|
||||
<string name="users">Arveriaded</string>
|
||||
<string name="events">Degouezhioù</string>
|
||||
<string name="songs">Tonioù</string>
|
||||
<string name="albums">Albomoù</string>
|
||||
<string name="artists">Arzourien</string>
|
||||
<string name="disabled">Diweredekaet</string>
|
||||
<string name="clear">Skarzhañ</string>
|
||||
<string name="undo">Dizober</string>
|
||||
<string name="file_deleted">Dilamet eo bet ar restr</string>
|
||||
<string name="play_all">Lenn pep tra</string>
|
||||
<string name="always">Atav</string>
|
||||
<string name="file">Restr</string>
|
||||
<string name="notifications">Rebuzadurioù</string>
|
||||
<string name="notification_channel_name">Rebuzadur NewPipe</string>
|
||||
<string name="notification_channel_description">Rebuzadurioù evit al lenner NewPipe</string>
|
||||
<string name="app_update_notification_channel_description">Rebuzadurioù evit handelvoù nevez NewPipe</string>
|
||||
<string name="streams_notification_channel_name">Lanvioù nevez</string>
|
||||
<string name="just_once">Ur wech nemetken</string>
|
||||
<string name="best_resolution">Diarunusted wellañ</string>
|
||||
<string name="general_error">Fazi</string>
|
||||
<string name="app_update_notification_channel_name">Rebuzadur hizivadur an arload</string>
|
||||
<string name="content">Endalc\'had</string>
|
||||
<string name="settings_category_player_behavior_title">Emzalc\'h</string>
|
||||
<string name="playlists">Rolloù-lenn</string>
|
||||
<string name="downloads">Pellgargadurioù</string>
|
||||
<string name="error_snackbar_action">Sevel un danevell</string>
|
||||
<string name="error_details_headline">Munudoù:</string>
|
||||
<string name="audio">Aodio</string>
|
||||
<string name="retry">Klask en-dro</string>
|
||||
<string name="description_tab_description">Deskrivadur</string>
|
||||
<string name="search_no_results">Disoc\'h ebet</string>
|
||||
<string name="empty_list_subtitle">Endalc’had ebet</string>
|
||||
<string name="what_happened_headline">Petra zo c\'hoarvezet:</string>
|
||||
<string name="detail_thumbnail_view_description">Lenn ar video, pad:</string>
|
||||
<string name="what_device_headline">Titouroù:</string>
|
||||
<string name="video">Video</string>
|
||||
<string name="streams_notification_channel_description"></string>
|
||||
</resources>
|
||||
@@ -54,6 +54,7 @@
|
||||
<string name="audio">Àudio</string>
|
||||
<plurals name="subscribers">
|
||||
<item quantity="one">%s subscriptor</item>
|
||||
<item quantity="many">%s subscriptors</item>
|
||||
<item quantity="other">%s subscriptors</item>
|
||||
</plurals>
|
||||
<string name="ok">D\'acord</string>
|
||||
@@ -169,11 +170,13 @@
|
||||
<string name="no_views">Cap reproducció</string>
|
||||
<plurals name="views">
|
||||
<item quantity="one">%s reproducció</item>
|
||||
<item quantity="many">%s reproduccions</item>
|
||||
<item quantity="other">%s reproduccions</item>
|
||||
</plurals>
|
||||
<string name="no_videos">Cap vídeo</string>
|
||||
<plurals name="videos">
|
||||
<item quantity="one">%s vídeo</item>
|
||||
<item quantity="many">%s vídeos</item>
|
||||
<item quantity="other">%s vídeos</item>
|
||||
</plurals>
|
||||
<string name="pause">Pausa</string>
|
||||
@@ -274,15 +277,7 @@
|
||||
<string name="enable_leak_canary_summary">La supervisió de fugues de memòria pot fer que l\'aplicació deixi de respondre mentre es bolca la memòria</string>
|
||||
<string name="enable_disposed_exceptions_title">Informa d\'errors fora del cicle de vida</string>
|
||||
<string name="enable_disposed_exceptions_summary">Força l\'informe d\'excepcions Rx que no es puguin transmetre que tinguin lloc fora del cicle de vida d\'un fragment o activitat després de disposar-los</string>
|
||||
<string name="import_youtube_instructions">Importeu les vostres subscripcions de YouTube mitjançant la còpia de contingut de Google Takeout:
|
||||
\n
|
||||
\n1. Aneu a : %1$s
|
||||
\n2. Inicieu la sessió si se us demana
|
||||
\n3. Premeu \"Totes les dades incloses\", després \"Dessel·lecciona-ho tot\", llavors sel·leccioneu només \"Subscripcions\" i finalment premeu \"D\'acord\".
|
||||
\n4. Premeu \"Pas següent\" i llavors a \"Crea una exportació\"
|
||||
\n5. Premeu el botó \"Baixa\" un cop hagi aparegut
|
||||
\n6. Premeu a IMPORTA EL FITXER i sel·leccioneu el fitxer .zip descarregat
|
||||
\n7. [En cas que la importació del fitxer .zip hagi fallat] extreieu-ne el fitxer subscripcions.csv (es troba generalment a \"Takeout/YouTube i YouTube Music/subscripcions/subscripcions.csv\"), premeu a IMPORTA EL FITXER i sel·leccioneu el fitxer .csv extret.</string>
|
||||
<string name="import_youtube_instructions">Importeu les vostres subscripcions de YouTube mitjançant la còpia de contingut de Google Takeout: \n \n1. Aneu a : %1$s \n2. Inicieu la sessió si se us demana \n3. Premeu \"Totes les dades incloses\", després \"Dessel·lecciona-ho tot\", llavors sel·leccioneu només \"Subscripcions\" i finalment premeu \"D\'acord\". \n4. Premeu \"Pas següent\" i llavors a \"Crea una exportació\" \n5. Premeu el botó \"Baixa\" un cop hagi aparegut \n6. Premeu a IMPORTA EL FITXER i sel·leccioneu el fitxer .zip descarregat \n7. [En cas que la importació del fitxer .zip hagi fallat] extreieu-ne el fitxer subscripcions.csv (es troba generalment a \"Takeout/YouTube i YouTube Music/subscripcions/subscripcions.csv\"), premeu a IMPORTA EL FITXER i sel·leccioneu el fitxer .csv extret</string>
|
||||
<string name="import_soundcloud_instructions">Importeu un perfil del SoundCloud mitjançant l\'URL o l\'identificador del vostre perfil:
|
||||
\n
|
||||
\n1. Activeu el «Mode d\'ordinador» en un navegador (el lloc web no està disponible per a dispositius mòbils)
|
||||
@@ -407,7 +402,7 @@
|
||||
<string name="missing_file">El fitxer s\'ha mogut o suprimit</string>
|
||||
<string name="enable_queue_limit_desc">Només una baixada alhora</string>
|
||||
<string name="downloads_storage_use_saf_title">Fes servir el SAF</string>
|
||||
<string name="downloads_storage_use_saf_summary">El SAF (Storage Access Framework; estructura d\'accés a l\'emmagatzematge) us permet realitzar baixades a una memòria externa com una targeta SD.</string>
|
||||
<string name="downloads_storage_use_saf_summary">El SAF (Storage Access Framework; estructura d\'accés a l\'emmagatzematge) us permet realitzar baixades a una memòria externa com una targeta SD</string>
|
||||
<string name="clear_playback_states_title">Esborra les posicions de reproducció</string>
|
||||
<string name="clear_playback_states_summary">Esborra totes les posicions de reproducció</string>
|
||||
<string name="delete_playback_states_alert">Voleu suprimir tots els punts de reproducció\?</string>
|
||||
@@ -415,14 +410,15 @@
|
||||
<string name="no_one_watching">Cap visualització</string>
|
||||
<plurals name="watching">
|
||||
<item quantity="one">%s visualització</item>
|
||||
<item quantity="many">%s visualitzacions</item>
|
||||
<item quantity="other">%s visualitzacions</item>
|
||||
</plurals>
|
||||
<string name="no_one_listening">Cap reproducció</string>
|
||||
<plurals name="listening">
|
||||
<item quantity="one">%s escoltant</item>
|
||||
<item quantity="many">%s escoltants</item>
|
||||
<item quantity="other">%s escoltants</item>
|
||||
</plurals>
|
||||
<string name="localization_changes_requires_app_restart">Es canviarà l\'idioma en reiniciar l\'aplicació</string>
|
||||
<string name="default_kiosk_page_summary">Tendències</string>
|
||||
<string name="show_original_time_ago_title">Ensenya el temps passat original sobre els \"items\"</string>
|
||||
<string name="playlist_no_uploader">Auto-generat (no es troba cap uploader)</string>
|
||||
@@ -477,17 +473,18 @@
|
||||
<string name="feed_update_threshold_option_always_update">Actualitza sempre</string>
|
||||
<string name="feed_update_threshold_summary">Temps que ha de passar perquè una subscripció es consideri obsoleta — %s</string>
|
||||
<string name="feed_update_threshold_title">Llindar d\'actualització del contingut</string>
|
||||
<string name="settings_category_feed_title">Feed</string>
|
||||
<string name="settings_category_feed_title">Flux</string>
|
||||
<string name="feed_group_show_only_ungrouped_subscriptions">Mostra només les subscripcions sense grup</string>
|
||||
<string name="feed_create_new_group_button_title">Nou</string>
|
||||
<string name="feed_group_dialog_delete_message">Esteu segurs de voler suprimir aquest grup\?</string>
|
||||
<string name="feed_group_dialog_empty_name">Nom de grup buit</string>
|
||||
<plurals name="feed_group_dialog_selection_count">
|
||||
<item quantity="one">%d de sel·leccionat</item>
|
||||
<item quantity="other">%d de sel·leccionats</item>
|
||||
<item quantity="one">%d de seleccionat</item>
|
||||
<item quantity="many">%d de seleccionats</item>
|
||||
<item quantity="other">%d de seleccionats</item>
|
||||
</plurals>
|
||||
<string name="feed_group_dialog_empty_selection">Cap subscripció sel·leccionada</string>
|
||||
<string name="feed_group_dialog_select_subscriptions">Sel·leccioneu les subscripcions</string>
|
||||
<string name="feed_group_dialog_empty_selection">Cap subscripció seleccionada</string>
|
||||
<string name="feed_group_dialog_select_subscriptions">Selecciona subscripcions</string>
|
||||
<string name="feed_processing_message">Processant el contingut…</string>
|
||||
<string name="feed_notification_loading">Carregant el contingut…</string>
|
||||
<string name="feed_subscription_not_loaded_count">No carregat: %d</string>
|
||||
@@ -495,18 +492,22 @@
|
||||
<string name="feed_groups_header_title">Grups de canals</string>
|
||||
<plurals name="days">
|
||||
<item quantity="one">%d dia</item>
|
||||
<item quantity="many">%d dies</item>
|
||||
<item quantity="other">%d dies</item>
|
||||
</plurals>
|
||||
<plurals name="hours">
|
||||
<item quantity="one">%d hora</item>
|
||||
<item quantity="many">%d hores</item>
|
||||
<item quantity="other">%d hores</item>
|
||||
</plurals>
|
||||
<plurals name="minutes">
|
||||
<item quantity="one">%d minut</item>
|
||||
<item quantity="many">%d minuts</item>
|
||||
<item quantity="other">%d minuts</item>
|
||||
</plurals>
|
||||
<plurals name="seconds">
|
||||
<item quantity="one">%d segon</item>
|
||||
<item quantity="many">%d segons</item>
|
||||
<item quantity="other">%d segons</item>
|
||||
</plurals>
|
||||
<string name="new_seek_duration_toast">A causa de les limitacions d\'ExoPlayer, la durada de cerca és de %d segons</string>
|
||||
@@ -626,7 +627,7 @@
|
||||
<string name="low_quality_smaller">Baixa qualitat (més petit)</string>
|
||||
<string name="high_quality_larger">Alta qualitat (més gran)</string>
|
||||
<string name="show_image_indicators_title">Mostra indicadors de la imatge</string>
|
||||
<string name="disable_media_tunneling_summary">Desactiva l\'entunelament del contingut si en els videos hi ha una pantalla negre o tartamudegen</string>
|
||||
<string name="disable_media_tunneling_summary">Desactiva l\'entunelament del contingut si en reproduir el vídeos la pantalla se\'n va a negre o s\'entretallen.</string>
|
||||
<string name="show_channel_details">Mostra detalls del canal</string>
|
||||
<string name="no_dir_yet">No s\'ha establert una carpeta de descàrregues, selecciona la carpeta per defecte ara</string>
|
||||
<string name="comments_are_disabled">Els comentaris estan desactivats</string>
|
||||
@@ -644,13 +645,12 @@
|
||||
<string name="manual_update_description">Comprovar manualment si hi ha noves versions</string>
|
||||
<plurals name="download_finished_notification">
|
||||
<item quantity="one">Baixada finalitzada</item>
|
||||
<item quantity="many">%s baixades finalitzades</item>
|
||||
<item quantity="other">%s baixades finalitzades</item>
|
||||
</plurals>
|
||||
<string name="seekbar_preview_thumbnail_title">Vista prèvia de les miniatures de la barra de cerca</string>
|
||||
<string name="no_appropriate_file_manager_message_android_10">No s\'ha trobat cap gestor de fitxers adequat per a aquesta acció.
|
||||
\nInstal·leu un gestor de fitxers compatible amb l\'entorn d\'accés d\'emmagatzematge.</string>
|
||||
<string name="no_appropriate_file_manager_message">No s\'ha trobat cap gestor de fitxers adequat per a aquesta acció.
|
||||
\nInstal·leu un gestor de fitxers o intenteu desactivar «%s» als paràmetres de baixada.</string>
|
||||
<string name="no_appropriate_file_manager_message_android_10">No s\'ha trobat cap gestor de fitxers adequat per a aquesta acció. \nInstal·leu un gestor de fitxers compatible amb l\'entorn d\'accés d\'emmagatzematge</string>
|
||||
<string name="no_appropriate_file_manager_message">No s\'ha trobat cap gestor de fitxers adequat per a aquesta acció. \nInstal·leu un gestor de fitxers o intenteu desactivar «%s» als paràmetres de baixada</string>
|
||||
<string name="error_report_notification_toast">S\'ha produït un error, consulteu la notificació</string>
|
||||
<string name="enqueued_next">Afegit el següent vídeo a la cua</string>
|
||||
<string name="error_report_notification_title">NewPipe ha trobat un error, toca per informar</string>
|
||||
@@ -661,6 +661,7 @@
|
||||
<string name="checking_updates_toast">S\'estan comprovant les actualitzacions…</string>
|
||||
<plurals name="deleted_downloads_toast">
|
||||
<item quantity="one">S\'ha suprimit %1$s baixada</item>
|
||||
<item quantity="many">S\'han suprimit %1$s baixades</item>
|
||||
<item quantity="other">S\'han suprimit %1$s baixades</item>
|
||||
</plurals>
|
||||
<string name="downloads_storage_use_saf_summary_api_29">A partir de l\'Android 10 només s\'admet el \"Sistema d\'Accés a l\'Emmagatzematge\"</string>
|
||||
@@ -691,8 +692,8 @@
|
||||
<string name="unknown_format">Format desconegut</string>
|
||||
<string name="unknown_quality">Cualitat desconeguda</string>
|
||||
<string name="sort">Ordenar</string>
|
||||
<string name="settings_category_player_notification_summary">Configura la notificació de reproducció actual.</string>
|
||||
<string name="progressive_load_interval_summary">Canvia la mida de l\'interval de càrrega en continguts progressius (actualment %s). Un valor inferior pot accelerar la càrrega inicial del vídeo.</string>
|
||||
<string name="settings_category_player_notification_summary">Configura la notificació de reproducció actual</string>
|
||||
<string name="progressive_load_interval_summary">Canvia la mida de l\'interval de càrrega en continguts progressius (actualment %s). Un valor inferior pot accelerar la càrrega inicial del vídeo</string>
|
||||
<string name="ignore_hardware_media_buttons_title">Ignora els esdeveniments dels botons de reproducció físics</string>
|
||||
<string name="ignore_hardware_media_buttons_summary">Útil, per exemple, si feu servir uns auriculars amb els botons físicament trencats</string>
|
||||
<string name="left_gesture_control_summary">Trieu un gest per la part esquerra de la pantalla</string>
|
||||
@@ -728,4 +729,102 @@
|
||||
<string name="audio_track">Pista d\'àudio</string>
|
||||
<string name="no">No</string>
|
||||
<string name="no_streams">Cap emissió</string>
|
||||
<string name="enable_streams_notifications_summary">Notifica sobre les noves retransmissions de les subscripcions</string>
|
||||
<string name="enable_streams_notifications_title">Noves notificacions de retransmissions</string>
|
||||
<string name="duplicate_in_playlist">Les llistes de reproducció que estan en gris ja contenen aquest element.</string>
|
||||
<string name="unset_playlist_thumbnail">Desestableix la miniatura permanent</string>
|
||||
<string name="playlist_add_stream_success_duplicate">Duplicat afegit/s %d vegada/es</string>
|
||||
<string name="disable_media_tunneling_automatic_info">El túnel multimèdia s\'ha desactivat de manera predeterminada al dispositiu perquè se sap que el vostre model de dispositiu no ho permet.</string>
|
||||
<string name="semitone">Semiton</string>
|
||||
<string name="app_update_unavailable_toast">Estàs fent servir la darrera versió de NewPipe</string>
|
||||
<string name="error_insufficient_storage">No hi ha prou espai lliure al dispositiu</string>
|
||||
<string name="tab_bookmarks_short">Llistes de reproducció</string>
|
||||
<string name="card">Targeta</string>
|
||||
<string name="remove_duplicates_message">Vols suprimir tots els elements duplicats d\'aquesta llista de reproducció?</string>
|
||||
<string name="channel_tab_playlists">Llistes de reproducció</string>
|
||||
<string name="remove_duplicates">Suprimeix els duplicats</string>
|
||||
<string name="reset_settings_title">Restableix la configuració</string>
|
||||
<string name="auto_update_check_description">NewPipe pot cercar automàticament actualitzacions i fer-t\'ho saber en estar disponibles.\nVols habilitar-ho?</string>
|
||||
<string name="remove_duplicates_title">Suprimeixo els duplicats?</string>
|
||||
<string name="reset_settings_summary">Restableix tots els paràmetres als valors per defecte</string>
|
||||
<string name="reset_all_settings">Restablir tots els paràmetres descartarà els teus paràmetres preferits i reiniciarà l\'aplicació.\n\nN\'estàs segur?</string>
|
||||
<string name="app_update_available_notification_text">Clica per descarregar%s</string>
|
||||
<string name="audio_track_type_dubbed">doblat</string>
|
||||
<string name="toggle_all">Commuta-ho tot</string>
|
||||
<string name="feed_show_upcoming">Pròximament</string>
|
||||
<string name="channel_tab_livestreams">En directe</string>
|
||||
<string name="play">Reprodueix</string>
|
||||
<string name="replay">Torna a reproduir</string>
|
||||
<string name="more_options">Més opcions</string>
|
||||
<string name="share_playlist_with_list">Comparteix la llista dels URLs</string>
|
||||
<string name="video_details_list_item">- %1$s: %2$s</string>
|
||||
<string name="metadata_uploader_avatars">Avatars de l\'autor</string>
|
||||
<string name="metadata_subchannel_avatars">Avatars del sots-canal</string>
|
||||
<string name="metadata_subscribers">Subscriptors</string>
|
||||
<string name="audio_track_present_in_video">Ja hi hauria d\'haver una pista d\'àudio en aquest flux</string>
|
||||
<string name="selected_stream_external_player_not_supported">El contingut escollit no és suportat per cap reproductor extern</string>
|
||||
<string name="no_video_streams_available_for_external_players">No hi ha cap flux de vídeo disponible per a reproductors externs</string>
|
||||
<string name="select_audio_track_external_players">Escull la pista d\'àudio per a reproductors externs</string>
|
||||
<string name="unknown_audio_track">Desconegut</string>
|
||||
<string name="audio_track_name">%1$s%2$s</string>
|
||||
<string name="audio_track_type_original">original</string>
|
||||
<string name="audio_track_type_descriptive">descriptiu</string>
|
||||
<string name="channel_tab_videos">Vídeos</string>
|
||||
<string name="show_channel_tabs_summary">Quines pestanyes es mostren a les pàgines del canal</string>
|
||||
<string name="open_play_queue">Obre la cua de reproducció</string>
|
||||
<string name="toggle_screen_orientation">Canvia l\'orientació de la pantalla</string>
|
||||
<string name="previous_stream">Vídeo anterior</string>
|
||||
<string name="forward">Avança</string>
|
||||
<string name="image_quality_title">Qualitat de la imatge</string>
|
||||
<string name="image_quality_none">No carregues les imatges</string>
|
||||
<string name="image_quality_medium">Qualitat mitjana</string>
|
||||
<string name="image_quality_low">Qualitat baixa</string>
|
||||
<string name="share_playlist_as_youtube_temporary_playlist">Comparteix com a llista de reproducció temporal de Youtube</string>
|
||||
<string name="share_playlist_content_details">%1$s\n%2$s</string>
|
||||
<string name="show_more">Mostra més</string>
|
||||
<string name="metadata_avatars">Avatars</string>
|
||||
<string name="metadata_banners">Bàners</string>
|
||||
<string name="duration">Durada</string>
|
||||
<string name="rewind">Rebobina</string>
|
||||
<string name="share_playlist_with_titles">Comparteix amb els títols</string>
|
||||
<string name="streams_not_yet_supported_removed">No es mostren els contiguts que no suporten descàrrega</string>
|
||||
<string name="feed_hide_streams_title">Mostra els vídeos següents</string>
|
||||
<string name="no_audio_streams_available_for_external_players">No hi ha cap flux d\'àudio disponible per a reproductors externs</string>
|
||||
<string name="feed_show_hide_streams">Mostra/Amaga els vídeos</string>
|
||||
<string name="night_theme_available">Aquesta opció només està disponible si%ss\'ha seleccionat per al tema</string>
|
||||
<string name="next_stream">Vídeo següent</string>
|
||||
<plurals name="replies">
|
||||
<item quantity="one">%sresposta</item>
|
||||
<item quantity="many">%srespostes</item>
|
||||
<item quantity="other">%srespostes</item>
|
||||
</plurals>
|
||||
<string name="feed_fetch_channel_tabs">Recupera les pestanyes del canal</string>
|
||||
<string name="progressive_load_interval_exoplayer_default">Valor per defecte d\'ExoPlayer</string>
|
||||
<string name="metadata_thumbnails">Miniatures</string>
|
||||
<string name="channel_tab_about">Quant a</string>
|
||||
<string name="always_use_exoplayer_set_output_surface_workaround_title">Usa sempre la sortida de vídeo d\'ExoPlayer com a solució de contingència</string>
|
||||
<string name="import_settings_vulnerable_format">La configuració exportada que vols importar té un format vulnerable que és obsolet des de NewPipe 0.27.0. Assegura\'t que l\'exportació que vols importar prové d\'una font de confiança i prefereix només les exportacions fetes amb NewPipe 0.27.0 o posterior d\'ara endavant. El suport a la importació de configuracions en aquest format vulnerable aviat serà suprimit completament i aleshores les antigues versions de NewPipe ja no podran importar les exportacions de les configuracions des de les noves versions.</string>
|
||||
<string name="channel_tab_albums">Àlbums</string>
|
||||
<string name="show_channel_tabs">Pestanyes del canal</string>
|
||||
<string name="image_quality_high">Qualitat alta</string>
|
||||
<string name="image_quality_summary">Tria la qualitat de les imatges i si carregar-les totalment o no per reduir l\'ús de les dades i la memòria. Els canvis suprimiran la memòria cau de les imatges a la memòria i al disc — %s</string>
|
||||
<string name="feed_fetch_channel_tabs_summary">Pestanyes que es recuperaran en actualitzar el contingut. Aquesta opció no s\'aplica si el canal s\'actualitza en mode ràpid.</string>
|
||||
<string name="feed_show_partially_watched">Vist parcialment</string>
|
||||
<string name="feed_show_watched">Vist completament</string>
|
||||
<string name="settings_category_exoplayer_title">Paràmetres d\'ExoPlayer</string>
|
||||
<string name="settings_category_exoplayer_summary">Gestiona alguns paràmetres d\'ExoPlayer. Caldrà reinciciar el reproductor per activar-los</string>
|
||||
<string name="use_exoplayer_decoder_fallback_title">Usa la funció de suport de decodificació d\'ExoPlayer</string>
|
||||
<string name="use_exoplayer_decoder_fallback_summary">Habilita aquesta opció si tens problemes en iniciar el decodificador. S\'usaran decodificadors alternatius de baixa prioritat si falla el decodificador primari. Això pot provocar una disminució de la qualitat de la reproducció en relació a l\'ús del decodificador primari</string>
|
||||
<string name="always_use_exoplayer_set_output_surface_workaround_summary">Aquesta alternativa allibera i reinstancia els còdecs de vídeo si hi ha un canvi de màscara en lloc de configurar-la directament al còdec. ExoPlayer ja ho aplica en alguns dispositius amb aquest problema. Aquesta configuració només té efecte en Android 6 i posteriors\n\nHabilitar aquest opció pot prevenir errors de reproducció en canviar el reproductor actual o en passar a pantalla completa</string>
|
||||
<string name="channel_tab_tracks">Pistes</string>
|
||||
<string name="channel_tab_shorts">Curts</string>
|
||||
<string name="toggle_fullscreen">Canvia a pantalla completa</string>
|
||||
<string name="question_mark">\?</string>
|
||||
<string name="share_playlist">Comparteix la llista de reproducció</string>
|
||||
<string name="show_less">Mostra menys</string>
|
||||
<string name="audio_track_type_secondary">secundària</string>
|
||||
<string name="channel_tab_channels">Canals</string>
|
||||
<string name="no_feed_group_created_yet">Encara no s\'ha creat cap grup de continguts</string>
|
||||
<string name="select_a_feed_group">Tria un grup de continguts</string>
|
||||
<string name="feed_group_page_summary">Pàgina del grup de canals</string>
|
||||
</resources>
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
<string name="subscription_update_failed">ناتوانرێت بهژداریكردنهكه نوێبكرێتهوه</string>
|
||||
<string name="controls_background_title">پشت شاشە</string>
|
||||
<string name="search_no_results">بێ ئەنجامه</string>
|
||||
<string name="localization_changes_requires_app_restart">زمان دەگۆڕدرێت لەدوای داگیرساندنەوەی بهرنامهكه</string>
|
||||
<string name="remove_watched">لادانی سەیرکراو</string>
|
||||
<string name="enable_playback_state_lists_summary">پیشاندانی نیشانەکەری شوێنی کارپێکەر لە خشتەکاندا</string>
|
||||
<string name="enable_playback_state_lists_title">شوێنەکان لە خشتەکاندا</string>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="upload_date_text">Publikováno na %1$s</string>
|
||||
<string name="upload_date_text">Publikováno %1$s</string>
|
||||
<string name="no_player_found">Nenalezen žádný přehrávač. Nainstalovat VLC?</string>
|
||||
<string name="install">Instalovat</string>
|
||||
<string name="cancel">Zrušit</string>
|
||||
@@ -57,7 +57,7 @@
|
||||
<string name="msg_name">Jméno souboru</string>
|
||||
<string name="msg_threads">Vlákna</string>
|
||||
<string name="pause">Zastavit</string>
|
||||
<string name="delete">Smazat</string>
|
||||
<string name="delete">Odstranit</string>
|
||||
<string name="start">Start</string>
|
||||
<string name="retry">Zkusit znovu</string>
|
||||
<string name="video">Video</string>
|
||||
@@ -139,9 +139,9 @@
|
||||
</plurals>
|
||||
<string name="no_videos">Žádná videa</string>
|
||||
<plurals name="videos">
|
||||
<item quantity="one">%s Video</item>
|
||||
<item quantity="few">%s Videa</item>
|
||||
<item quantity="other">%s Videí</item>
|
||||
<item quantity="one">%s video</item>
|
||||
<item quantity="few">%s videa</item>
|
||||
<item quantity="other">%s videí</item>
|
||||
</plurals>
|
||||
<string name="settings_category_downloads_title">Stahování</string>
|
||||
<string name="settings_file_charset_title">Povolené znaky v názvech souborů</string>
|
||||
@@ -234,8 +234,8 @@
|
||||
<string name="add_to_playlist">Přidat do playlistu</string>
|
||||
<string name="set_as_playlist_thumbnail">Nastavit jako náhled playlistu</string>
|
||||
<string name="bookmark_playlist">Přidat playlist do záložek</string>
|
||||
<string name="unbookmark_playlist">Smazat záložku</string>
|
||||
<string name="delete_playlist_prompt">Smazat tento playlist\?</string>
|
||||
<string name="unbookmark_playlist">Odstranit záložku</string>
|
||||
<string name="delete_playlist_prompt">Odstranit tento playlist?</string>
|
||||
<string name="playlist_creation_success">Playlist vytvořen</string>
|
||||
<string name="playlist_add_stream_success">V playlistu</string>
|
||||
<string name="playlist_thumbnail_change_success">Náhled playlistu změněn.</string>
|
||||
@@ -411,9 +411,9 @@
|
||||
<string name="enable_playback_state_lists_summary">Zobrazit pozici přehrávání v seznamech</string>
|
||||
<string name="watch_history_states_deleted">Pozice playbacku smazány</string>
|
||||
<string name="error_timeout">Timeout spojení</string>
|
||||
<string name="clear_playback_states_title">Smazat pozice playbacku</string>
|
||||
<string name="clear_playback_states_summary">Smazat všechny pozice playbacku</string>
|
||||
<string name="delete_playback_states_alert">Smazat všechny pozice playbacku\?</string>
|
||||
<string name="clear_playback_states_title">Vymazat pozice přehrávání</string>
|
||||
<string name="clear_playback_states_summary">Vymaže všechny pozice přehrávání</string>
|
||||
<string name="delete_playback_states_alert">Vymazat všechny pozice přehrávání?</string>
|
||||
<string name="drawer_header_description">Přepnout službu, právě vybráno:</string>
|
||||
<string name="no_one_watching">Nikdo nesleduje</string>
|
||||
<plurals name="watching">
|
||||
@@ -427,7 +427,6 @@
|
||||
<item quantity="few">%s posluchači</item>
|
||||
<item quantity="other">%s posluchačů</item>
|
||||
</plurals>
|
||||
<string name="localization_changes_requires_app_restart">Ke změně jazyka dojde po restartu aplikace</string>
|
||||
<string name="default_kiosk_page_summary">Výchozí kiosek</string>
|
||||
<string name="seek_duration_title">Délka přetočení vpřed/zpět</string>
|
||||
<string name="peertube_instance_url_title">Instance PeerTube</string>
|
||||
@@ -445,8 +444,8 @@
|
||||
<string name="recovering">obnovuji</string>
|
||||
<string name="error_download_resource_gone">Toto stahování nelze obnovit</string>
|
||||
<string name="choose_instance_prompt">Vyberte instanci</string>
|
||||
<string name="clear_download_history">Smazat historii stahování</string>
|
||||
<string name="delete_downloaded_files">Smazat stažené soubory</string>
|
||||
<string name="clear_download_history">Vymazat historii stahování</string>
|
||||
<string name="delete_downloaded_files">Odstranit stažené soubory</string>
|
||||
<string name="permission_display_over_apps">Souhlasit se zobrazením přes jiné aplikace</string>
|
||||
<string name="app_language_title">Jazyk aplikace</string>
|
||||
<string name="systems_language">Jazyk systému</string>
|
||||
@@ -489,7 +488,7 @@
|
||||
<item quantity="other">%d vybráno</item>
|
||||
</plurals>
|
||||
<string name="feed_group_dialog_empty_name">Prázdné jméno skupiny</string>
|
||||
<string name="feed_group_dialog_delete_message">Přejete si smazat tuto skupinu\?</string>
|
||||
<string name="feed_group_dialog_delete_message">Přejete si odstranit tuto skupinu?</string>
|
||||
<string name="feed_create_new_group_button_title">Nová</string>
|
||||
<string name="settings_category_feed_title">Novinky</string>
|
||||
<string name="feed_update_threshold_title">Limit aktualizace novinek</string>
|
||||
@@ -690,7 +689,7 @@
|
||||
<string name="streams_notifications_interval_title">Frekvence kontroly</string>
|
||||
<string name="any_network">Jakákoli síť</string>
|
||||
<string name="streams_notifications_network_title">Požadované síťové připojení</string>
|
||||
<string name="delete_downloaded_files_confirm">Smazat všechny stažené soubory z disku\?</string>
|
||||
<string name="delete_downloaded_files_confirm">Odstranit všechny stažené soubory z disku?</string>
|
||||
<string name="you_successfully_subscribed">Objednali jste si nyní tento kanál</string>
|
||||
<string name="toggle_all">Všechny přepnout</string>
|
||||
<string name="streams_notification_channel_name">Nové streamy</string>
|
||||
@@ -785,7 +784,7 @@
|
||||
<string name="question_mark">\?</string>
|
||||
<string name="metadata_subscribers">Odběratelé</string>
|
||||
<string name="show_channel_tabs_summary">Které karty mají být zobrazeny na stránkách kanálů</string>
|
||||
<string name="share_playlist_with_list">Sdílet URL seznamu</string>
|
||||
<string name="share_playlist_with_list">Sdílet seznam adres</string>
|
||||
<string name="share_playlist_with_titles">Sdílet s názvy</string>
|
||||
<string name="share_playlist_content_details">%1$s
|
||||
\n%2$s</string>
|
||||
@@ -806,7 +805,6 @@
|
||||
<string name="channel_tab_albums">Alba</string>
|
||||
<string name="rewind">Přetočení zpět</string>
|
||||
<string name="replay">Znovu přehrát</string>
|
||||
<string name="share_playlist_with_titles_message">Sdílejte playlist s podrobnostmi jako je jeho název a názvy videí, nebo jako jednoduchý seznam adres videí</string>
|
||||
<string name="image_quality_medium">Střední kvalita</string>
|
||||
<string name="metadata_banners">Bannery</string>
|
||||
<string name="channel_tab_playlists">Playlisty</string>
|
||||
@@ -840,4 +838,14 @@
|
||||
\nChcete tuto funkci povolit?</string>
|
||||
<string name="import_settings_vulnerable_format">Nastavení v importovaném exportu používají zranitelný formát. NewPipe používá nový formát od verze 0.27.0. Ujistěte se, že export importujete z důvěryhodného zdroje a v budoucnu upřednostňujte používání exportů získaných z NewPipe 0.27.0 nebo novějších. Podpora importu nastavení v tomto zranitelném formátu bude brzy kompletně odstraněna, kvůli čemuž staré verze NewPipe nebudou moci importovat nastavení z exportů z nových verzí.</string>
|
||||
<string name="audio_track_type_secondary">sekundární</string>
|
||||
<string name="share_playlist_as_youtube_temporary_playlist">Sdílet jako dočasný playlist YouTube</string>
|
||||
<string name="tab_bookmarks_short">Playlisty</string>
|
||||
<string name="select_a_feed_group">Vybrat skupinu kanálů</string>
|
||||
<string name="no_feed_group_created_yet">Zatím nebyla vytvořena žádná skupina kanálů</string>
|
||||
<string name="feed_group_page_summary">Stránka skupiny kanálů</string>
|
||||
<string name="search_with_service_name">Hledat %1$s</string>
|
||||
<string name="search_with_service_name_and_filter">Hledat %1$s (%2$s)</string>
|
||||
<string name="channel_tab_likes">Líbí se</string>
|
||||
<string name="migration_info_6_7_title">Stránka SoundCloud Top 50 odstraněna</string>
|
||||
<string name="migration_info_6_7_message">SoundCloud zrušil původní žebříčky Top 50. Příslušná karta byla odstraněna z vaší hlavní stránky.</string>
|
||||
</resources>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user