Merge branch 'refactor' into PoToken-suspend
This commit is contained in:
commit
8c3e1dcac6
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');
|
||||
|
||||
@ -9,6 +9,7 @@ plugins {
|
||||
alias libs.plugins.kotlin.compose
|
||||
alias libs.plugins.kotlin.kapt
|
||||
alias libs.plugins.kotlin.parcelize
|
||||
alias libs.plugins.kotlinx.serialization
|
||||
alias libs.plugins.checkstyle
|
||||
alias libs.plugins.sonarqube
|
||||
alias libs.plugins.hilt
|
||||
@ -16,7 +17,7 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk 34
|
||||
compileSdk 35
|
||||
namespace 'org.schabi.newpipe'
|
||||
|
||||
defaultConfig {
|
||||
@ -27,9 +28,9 @@ android {
|
||||
if (System.properties.containsKey('versionCodeOverride')) {
|
||||
versionCode System.getProperty('versionCodeOverride') as Integer
|
||||
} else {
|
||||
versionCode 1003
|
||||
versionCode 1004
|
||||
}
|
||||
versionName "0.27.6"
|
||||
versionName "0.27.7"
|
||||
if (System.properties.containsKey('versionNameSuffix')) {
|
||||
versionNameSuffix System.getProperty('versionNameSuffix')
|
||||
}
|
||||
@ -101,6 +102,10 @@ android {
|
||||
androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
|
||||
}
|
||||
|
||||
androidResources {
|
||||
generateLocaleConfig = true
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
compose true
|
||||
@ -222,7 +227,6 @@ dependencies {
|
||||
implementation libs.androidx.fragment.compose
|
||||
implementation libs.androidx.lifecycle.livedata
|
||||
implementation libs.androidx.lifecycle.viewmodel
|
||||
implementation libs.androidx.localbroadcastmanager
|
||||
implementation libs.androidx.media
|
||||
implementation libs.androidx.preference
|
||||
implementation libs.androidx.recyclerview
|
||||
@ -315,6 +319,9 @@ dependencies {
|
||||
// Scroll
|
||||
implementation libs.lazycolumnscrollbar
|
||||
|
||||
// Kotlinx Serialization
|
||||
implementation libs.kotlinx.serialization.json
|
||||
|
||||
/** Debugging **/
|
||||
// Memory leak detection
|
||||
debugImplementation libs.leakcanary.object.watcher
|
||||
|
||||
22
app/proguard-rules.pro
vendored
22
app/proguard-rules.pro
vendored
@ -5,10 +5,17 @@
|
||||
|
||||
## Rules for NewPipeExtractor
|
||||
-keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; }
|
||||
## Rules for Rhino and Rhino Engine
|
||||
-keep class org.mozilla.javascript.* { *; }
|
||||
-keep class org.mozilla.javascript.** { *; }
|
||||
-keep class org.mozilla.javascript.engine.** { *; }
|
||||
-keep class org.mozilla.classfile.ClassFileWriter
|
||||
-dontwarn org.mozilla.javascript.JavaToJSONConverters
|
||||
-dontwarn org.mozilla.javascript.tools.**
|
||||
-keep class javax.script.** { *; }
|
||||
-dontwarn javax.script.**
|
||||
-keep class jdk.dynalink.** { *; }
|
||||
-dontwarn jdk.dynalink.**
|
||||
|
||||
## Rules for ExoPlayer
|
||||
-keep class com.google.android.exoplayer2.** { *; }
|
||||
@ -27,3 +34,18 @@
|
||||
|
||||
## For some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml)
|
||||
-keep class org.schabi.newpipe.settings.notifications.** { *; }
|
||||
|
||||
## Keep Kotlinx Serialization classes
|
||||
-keepclassmembers class kotlinx.serialization.json.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class kotlinx.serialization.json.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
-keep,includedescriptorclasses class org.schabi.newpipe.**$$serializer { *; }
|
||||
-keepclassmembers class org.schabi.newpipe.** {
|
||||
*** Companion;
|
||||
}
|
||||
-keepclasseswithmembers class org.schabi.newpipe.** {
|
||||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
|
||||
<!-- We need to be able to open links in the browser on API 30+ -->
|
||||
@ -64,6 +65,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
|
||||
@ -87,8 +91,10 @@
|
||||
android:exported="false"
|
||||
android:label="@string/title_activity_about" />
|
||||
|
||||
<service android:name=".local.subscription.services.SubscriptionsImportService" />
|
||||
<service android:name=".local.subscription.services.SubscriptionsExportService" />
|
||||
<service
|
||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
tools:node="merge" />
|
||||
<service android:name=".local.feed.service.FeedLoadService" />
|
||||
|
||||
<activity
|
||||
@ -429,5 +435,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>
|
||||
|
||||
@ -38,6 +38,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;
|
||||
@ -136,6 +137,19 @@ public class MainActivity extends AppCompatActivity {
|
||||
ThemeHelper.setDayNightMode(this);
|
||||
ThemeHelper.setTheme(this, ServiceHelper.getSelectedServiceId(this));
|
||||
|
||||
// Fixes text color turning black in dark/black mode:
|
||||
// https://github.com/TeamNewPipe/NewPipe/issues/12016
|
||||
// For further reference see: https://issuetracker.google.com/issues/37124582
|
||||
if (DeviceUtils.supportsWebView()) {
|
||||
try {
|
||||
new WebView(this);
|
||||
} catch (final Throwable e) {
|
||||
if (DEBUG) {
|
||||
Log.e(TAG, "Failed to create WebView", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assureCorrectAppLanguage(this);
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
@ -172,6 +186,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
&& ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
|
||||
UpdateSettingsFragment.askForConsentToUpdateChecks(this);
|
||||
}
|
||||
|
||||
Localization.migrateAppLanguageSettingIfNecessary(getApplicationContext());
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -578,8 +594,8 @@ public class MainActivity extends AppCompatActivity {
|
||||
if (player instanceof BackPressable backPressable && !backPressable.onBackPressed()) {
|
||||
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder)
|
||||
.setState(BottomSheetBehavior.STATE_COLLAPSED);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (fragmentManager.getBackStackEntryCount() == 1) {
|
||||
@ -826,7 +842,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.
|
||||
@ -838,6 +855,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -90,7 +90,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
|
||||
internal abstract fun silentInsertAllInternal(entities: List<SubscriptionEntity>): List<Long>
|
||||
|
||||
@Transaction
|
||||
open fun upsertAll(entities: List<SubscriptionEntity>): List<SubscriptionEntity> {
|
||||
open fun upsertAll(entities: List<SubscriptionEntity>) {
|
||||
val insertUidList = silentInsertAllInternal(entities)
|
||||
|
||||
insertUidList.forEachIndexed { index: Int, uidFromInsert: Long ->
|
||||
@ -106,7 +106,5 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
|
||||
update(entity)
|
||||
}
|
||||
}
|
||||
|
||||
return entities
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,8 +95,7 @@ import org.schabi.newpipe.player.Player;
|
||||
import org.schabi.newpipe.player.PlayerService;
|
||||
import org.schabi.newpipe.player.PlayerType;
|
||||
import org.schabi.newpipe.player.event.OnKeyDownListener;
|
||||
import org.schabi.newpipe.player.event.PlayerHolderLifecycleEventListener;
|
||||
import org.schabi.newpipe.player.event.PlayerServiceEventListener;
|
||||
import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener;
|
||||
import org.schabi.newpipe.player.helper.PlayerHelper;
|
||||
import org.schabi.newpipe.player.helper.PlayerHolder;
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||
@ -137,8 +136,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
public final class VideoDetailFragment
|
||||
extends BaseStateFragment<StreamInfo>
|
||||
implements BackPressable,
|
||||
PlayerServiceEventListener,
|
||||
PlayerHolderLifecycleEventListener,
|
||||
PlayerServiceExtendedEventListener,
|
||||
OnKeyDownListener {
|
||||
public static final String KEY_SWITCHING_PLAYERS = "switching_players";
|
||||
|
||||
@ -190,21 +188,21 @@ public final class VideoDetailFragment
|
||||
};
|
||||
|
||||
@State
|
||||
protected int serviceId = Constants.NO_SERVICE_ID;
|
||||
int serviceId = Constants.NO_SERVICE_ID;
|
||||
@State
|
||||
@NonNull
|
||||
protected String title = "";
|
||||
String title = "";
|
||||
@State
|
||||
@Nullable
|
||||
protected String url = null;
|
||||
String url = null;
|
||||
@Nullable
|
||||
protected PlayQueue playQueue = null;
|
||||
private PlayQueue playQueue = null;
|
||||
@State
|
||||
int bottomSheetState = BottomSheetBehavior.STATE_EXPANDED;
|
||||
@State
|
||||
int lastStableBottomSheetState = BottomSheetBehavior.STATE_EXPANDED;
|
||||
@State
|
||||
protected boolean autoPlayEnabled = true;
|
||||
boolean autoPlayEnabled = true;
|
||||
|
||||
@Nullable
|
||||
private StreamInfo currentInfo = null;
|
||||
@ -236,15 +234,19 @@ public final class VideoDetailFragment
|
||||
// Service management
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
@Override
|
||||
public void onServiceConnected(final PlayerService connectedPlayerService,
|
||||
final boolean playAfterConnect) {
|
||||
player = connectedPlayerService.getPlayer();
|
||||
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();
|
||||
|
||||
final Optional<MainPlayerUi> playerUi = player.UIs().get(MainPlayerUi.class);
|
||||
final Optional<MainPlayerUi> playerUi = player.UIs().getOpt(MainPlayerUi.class);
|
||||
if (!player.videoPlayerSelected() && !playAfterConnect) {
|
||||
return;
|
||||
}
|
||||
@ -271,11 +273,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();
|
||||
}
|
||||
|
||||
|
||||
@ -394,7 +403,7 @@ public final class VideoDetailFragment
|
||||
if (activity.isFinishing() && isPlayerAvailable() && player.videoPlayerSelected()) {
|
||||
playerHolder.stopService();
|
||||
} else {
|
||||
playerHolder.unsetListeners();
|
||||
playerHolder.setListener(null);
|
||||
}
|
||||
|
||||
PreferenceManager.getDefaultSharedPreferences(activity)
|
||||
@ -429,18 +438,15 @@ public final class VideoDetailFragment
|
||||
@Override
|
||||
public void onActivityResult(final int requestCode, final int resultCode, final Intent data) {
|
||||
super.onActivityResult(requestCode, resultCode, data);
|
||||
switch (requestCode) {
|
||||
case ReCaptchaActivity.RECAPTCHA_REQUEST:
|
||||
if (resultCode == Activity.RESULT_OK) {
|
||||
NavigationHelper.openVideoDetailFragment(requireContext(), getFM(),
|
||||
serviceId, url, title, null, false);
|
||||
} else {
|
||||
Log.e(TAG, "ReCaptcha failed");
|
||||
}
|
||||
break;
|
||||
default:
|
||||
Log.e(TAG, "Request code from activity not supported [" + requestCode + "]");
|
||||
break;
|
||||
if (requestCode == ReCaptchaActivity.RECAPTCHA_REQUEST) {
|
||||
if (resultCode == Activity.RESULT_OK) {
|
||||
NavigationHelper.openVideoDetailFragment(requireContext(), getFM(),
|
||||
serviceId, url, title, null, false);
|
||||
} else {
|
||||
Log.e(TAG, "ReCaptcha failed");
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "Request code from activity not supported [" + requestCode + "]");
|
||||
}
|
||||
}
|
||||
|
||||
@ -520,7 +526,7 @@ public final class VideoDetailFragment
|
||||
binding.overlayPlayPauseButton.setOnClickListener(v -> {
|
||||
if (playerIsNotStopped()) {
|
||||
player.playPause();
|
||||
player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0));
|
||||
player.UIs().getOpt(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0));
|
||||
showSystemUi();
|
||||
} else {
|
||||
autoPlayEnabled = true; // forcefully start playing
|
||||
@ -659,10 +665,10 @@ public final class VideoDetailFragment
|
||||
});
|
||||
|
||||
setupBottomPlayer();
|
||||
if (playerHolder.isNotBoundYet()) {
|
||||
if (!playerHolder.isBound()) {
|
||||
setHeightThumbnail();
|
||||
} else {
|
||||
playerHolder.startService(false, this, this);
|
||||
playerHolder.startService(false, this);
|
||||
}
|
||||
}
|
||||
|
||||
@ -679,7 +685,7 @@ public final class VideoDetailFragment
|
||||
@Override
|
||||
public boolean onKeyDown(final int keyCode) {
|
||||
return isPlayerAvailable()
|
||||
&& player.UIs().get(VideoPlayerUi.class)
|
||||
&& player.UIs().getOpt(VideoPlayerUi.class)
|
||||
.map(playerUi -> playerUi.onKeyDown(keyCode)).orElse(false);
|
||||
}
|
||||
|
||||
@ -806,25 +812,17 @@ public final class VideoDetailFragment
|
||||
|
||||
}
|
||||
|
||||
protected void prepareAndLoadInfo() {
|
||||
private void prepareAndLoadInfo() {
|
||||
scrollToTop();
|
||||
startLoading(false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startLoading(final boolean forceLoad) {
|
||||
super.startLoading(forceLoad);
|
||||
|
||||
initTabs();
|
||||
currentInfo = null;
|
||||
if (currentWorker != null) {
|
||||
currentWorker.dispose();
|
||||
}
|
||||
|
||||
runWorker(forceLoad, stack.isEmpty());
|
||||
startLoading(forceLoad, null);
|
||||
}
|
||||
|
||||
private void startLoading(final boolean forceLoad, final boolean addToBackStack) {
|
||||
private void startLoading(final boolean forceLoad, final @Nullable Boolean addToBackStack) {
|
||||
super.startLoading(forceLoad);
|
||||
|
||||
initTabs();
|
||||
@ -833,7 +831,7 @@ public final class VideoDetailFragment
|
||||
currentWorker.dispose();
|
||||
}
|
||||
|
||||
runWorker(forceLoad, addToBackStack);
|
||||
runWorker(forceLoad, addToBackStack != null ? addToBackStack : stack.isEmpty());
|
||||
}
|
||||
|
||||
private void runWorker(final boolean forceLoad, final boolean addToBackStack) {
|
||||
@ -1019,7 +1017,7 @@ public final class VideoDetailFragment
|
||||
// If a user watched video inside fullscreen mode and than chose another player
|
||||
// return to non-fullscreen mode
|
||||
if (isPlayerAvailable()) {
|
||||
player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> {
|
||||
player.UIs().getOpt(MainPlayerUi.class).ifPresent(playerUi -> {
|
||||
if (playerUi.isFullscreen()) {
|
||||
playerUi.toggleFullscreen();
|
||||
}
|
||||
@ -1053,7 +1051,7 @@ public final class VideoDetailFragment
|
||||
|
||||
// See UI changes while remote playQueue changes
|
||||
if (!isPlayerAvailable()) {
|
||||
playerHolder.startService(false, this, this);
|
||||
playerHolder.startService(false, this);
|
||||
} else {
|
||||
// FIXME Workaround #7427
|
||||
player.setRecovery();
|
||||
@ -1116,7 +1114,7 @@ public final class VideoDetailFragment
|
||||
private void openNormalBackgroundPlayer(final boolean append) {
|
||||
// See UI changes while remote playQueue changes
|
||||
if (!isPlayerAvailable()) {
|
||||
playerHolder.startService(false, this, this);
|
||||
playerHolder.startService(false, this);
|
||||
}
|
||||
|
||||
final PlayQueue queue = setupPlayQueueForIntent(append);
|
||||
@ -1129,8 +1127,8 @@ public final class VideoDetailFragment
|
||||
}
|
||||
|
||||
private void openMainPlayer() {
|
||||
if (!isPlayerServiceAvailable()) {
|
||||
playerHolder.startService(autoPlayEnabled, this, this);
|
||||
if (noPlayerServiceAvailable()) {
|
||||
playerHolder.startService(autoPlayEnabled, this);
|
||||
return;
|
||||
}
|
||||
if (currentInfo == null) {
|
||||
@ -1154,7 +1152,7 @@ public final class VideoDetailFragment
|
||||
*/
|
||||
private void hideMainPlayerOnLoadingNewStream() {
|
||||
final var root = getRoot();
|
||||
if (!isPlayerServiceAvailable() || root.isEmpty() || !player.videoPlayerSelected()) {
|
||||
if (noPlayerServiceAvailable() || root.isEmpty() || !player.videoPlayerSelected()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1235,7 +1233,7 @@ public final class VideoDetailFragment
|
||||
// setup the surface view height, so that it fits the video correctly
|
||||
setHeightThumbnail();
|
||||
|
||||
player.UIs().get(MainPlayerUi.class).ifPresent(playerUi -> {
|
||||
player.UIs().getOpt(MainPlayerUi.class).ifPresent(playerUi -> {
|
||||
// sometimes binding would be null here, even though getView() != null above u.u
|
||||
if (binding != null) {
|
||||
// prevent from re-adding a view multiple times
|
||||
@ -1251,7 +1249,7 @@ public final class VideoDetailFragment
|
||||
makeDefaultHeightForVideoPlaceholder();
|
||||
|
||||
if (player != null) {
|
||||
player.UIs().get(VideoPlayerUi.class).ifPresent(VideoPlayerUi::removeViewFromParent);
|
||||
player.UIs().getOpt(VideoPlayerUi.class).ifPresent(VideoPlayerUi::removeViewFromParent);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1318,7 +1316,7 @@ public final class VideoDetailFragment
|
||||
binding.detailThumbnailImageView.setMinimumHeight(newHeight);
|
||||
if (isPlayerAvailable()) {
|
||||
final int maxHeight = (int) (metrics.heightPixels * MAX_PLAYER_HEIGHT);
|
||||
player.UIs().get(VideoPlayerUi.class).ifPresent(ui ->
|
||||
player.UIs().getOpt(VideoPlayerUi.class).ifPresent(ui ->
|
||||
ui.getBinding().surfaceView.setHeights(newHeight,
|
||||
ui.isFullscreen() ? newHeight : maxHeight));
|
||||
}
|
||||
@ -1328,23 +1326,23 @@ public final class VideoDetailFragment
|
||||
binding.detailContentRootHiding.setVisibility(View.VISIBLE);
|
||||
}
|
||||
|
||||
protected void setInitialData(final int newServiceId,
|
||||
@Nullable final String newUrl,
|
||||
@NonNull final String newTitle,
|
||||
@Nullable final PlayQueue newPlayQueue) {
|
||||
private void setInitialData(final int newServiceId,
|
||||
@Nullable final String newUrl,
|
||||
@NonNull final String newTitle,
|
||||
@Nullable final PlayQueue newPlayQueue) {
|
||||
this.serviceId = newServiceId;
|
||||
this.url = newUrl;
|
||||
this.title = newTitle;
|
||||
this.playQueue = newPlayQueue;
|
||||
}
|
||||
|
||||
private void setErrorImage(final int imageResource) {
|
||||
private void setErrorImage() {
|
||||
if (binding == null || activity == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
binding.detailThumbnailImageView.setImageDrawable(
|
||||
AppCompatResources.getDrawable(requireContext(), imageResource));
|
||||
AppCompatResources.getDrawable(requireContext(), R.drawable.not_available_monkey));
|
||||
animate(binding.detailThumbnailImageView, false, 0, AnimationType.ALPHA,
|
||||
0, () -> animate(binding.detailThumbnailImageView, true, 500));
|
||||
}
|
||||
@ -1352,7 +1350,7 @@ public final class VideoDetailFragment
|
||||
@Override
|
||||
public void handleError() {
|
||||
super.handleError();
|
||||
setErrorImage(R.drawable.not_available_monkey);
|
||||
setErrorImage();
|
||||
|
||||
if (binding.relatedItemsLayout != null) { // hide related streams for tablets
|
||||
binding.relatedItemsLayout.setVisibility(View.INVISIBLE);
|
||||
@ -1385,11 +1383,9 @@ public final class VideoDetailFragment
|
||||
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
|
||||
}
|
||||
// Rebound to the service if it was closed via notification or mini player
|
||||
if (playerHolder.isNotBoundYet()) {
|
||||
if (!playerHolder.isBound()) {
|
||||
playerHolder.startService(
|
||||
false,
|
||||
VideoDetailFragment.this,
|
||||
VideoDetailFragment.this);
|
||||
false, VideoDetailFragment.this);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -1769,16 +1765,14 @@ public final class VideoDetailFragment
|
||||
final PlaybackParameters parameters) {
|
||||
setOverlayPlayPauseImage(player != null && player.isPlaying());
|
||||
|
||||
switch (state) {
|
||||
case Player.STATE_PLAYING:
|
||||
if (binding.positionView.getAlpha() != 1.0f
|
||||
&& player.getPlayQueue() != null
|
||||
&& player.getPlayQueue().getItem() != null
|
||||
&& player.getPlayQueue().getItem().getUrl().equals(url)) {
|
||||
animate(binding.positionView, true, 100);
|
||||
animate(binding.detailPositionView, true, 100);
|
||||
}
|
||||
break;
|
||||
if (state == Player.STATE_PLAYING) {
|
||||
if (binding.positionView.getAlpha() != 1.0f
|
||||
&& player.getPlayQueue() != null
|
||||
&& player.getPlayQueue().getItem() != null
|
||||
&& player.getPlayQueue().getItem().getUrl().equals(url)) {
|
||||
animate(binding.positionView, true, 100);
|
||||
animate(binding.detailPositionView, true, 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1838,20 +1832,23 @@ 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
|
||||
public void onFullscreenStateChanged(final boolean fullscreen) {
|
||||
setupBrightness();
|
||||
if (!isPlayerAndPlayerServiceAvailable()
|
||||
|| player.UIs().get(MainPlayerUi.class).isEmpty()
|
||||
|| player.UIs().getOpt(MainPlayerUi.class).isEmpty()
|
||||
|| getRoot().map(View::getParent).isEmpty()) {
|
||||
return;
|
||||
}
|
||||
@ -1880,7 +1877,7 @@ public final class VideoDetailFragment
|
||||
final boolean isLandscape = DeviceUtils.isLandscape(requireContext());
|
||||
if (DeviceUtils.isTablet(activity)
|
||||
&& (!globalScreenOrientationLocked(activity) || isLandscape)) {
|
||||
player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen);
|
||||
player.UIs().getOpt(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1980,7 +1977,7 @@ public final class VideoDetailFragment
|
||||
}
|
||||
|
||||
private boolean isFullscreen() {
|
||||
return isPlayerAvailable() && player.UIs().get(VideoPlayerUi.class)
|
||||
return isPlayerAvailable() && player.UIs().getOpt(VideoPlayerUi.class)
|
||||
.map(VideoPlayerUi::isFullscreen).orElse(false);
|
||||
}
|
||||
|
||||
@ -2057,7 +2054,7 @@ public final class VideoDetailFragment
|
||||
setAutoPlay(true);
|
||||
}
|
||||
|
||||
player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::checkLandscape);
|
||||
player.UIs().getOpt(MainPlayerUi.class).ifPresent(MainPlayerUi::checkLandscape);
|
||||
// Let's give a user time to look at video information page if video is not playing
|
||||
if (globalScreenOrientationLocked(activity) && !player.isPlaying()) {
|
||||
player.play();
|
||||
@ -2322,7 +2319,7 @@ public final class VideoDetailFragment
|
||||
&& player.isPlaying()
|
||||
&& !isFullscreen()
|
||||
&& !DeviceUtils.isTablet(activity)) {
|
||||
player.UIs().get(MainPlayerUi.class)
|
||||
player.UIs().getOpt(MainPlayerUi.class)
|
||||
.ifPresent(MainPlayerUi::toggleFullscreen);
|
||||
}
|
||||
setOverlayLook(binding.appBarLayout, behavior, 1);
|
||||
@ -2336,7 +2333,7 @@ public final class VideoDetailFragment
|
||||
// Re-enable clicks
|
||||
setOverlayElementsClickable(true);
|
||||
if (isPlayerAvailable()) {
|
||||
player.UIs().get(MainPlayerUi.class)
|
||||
player.UIs().getOpt(MainPlayerUi.class)
|
||||
.ifPresent(MainPlayerUi::closeItemsList);
|
||||
}
|
||||
setOverlayLook(binding.appBarLayout, behavior, 0);
|
||||
@ -2347,7 +2344,7 @@ public final class VideoDetailFragment
|
||||
showSystemUi();
|
||||
}
|
||||
if (isPlayerAvailable()) {
|
||||
player.UIs().get(MainPlayerUi.class).ifPresent(ui -> {
|
||||
player.UIs().getOpt(MainPlayerUi.class).ifPresent(ui -> {
|
||||
if (ui.isControlsVisible()) {
|
||||
ui.hideControls(0, 0);
|
||||
}
|
||||
@ -2434,8 +2431,8 @@ public final class VideoDetailFragment
|
||||
return player != null;
|
||||
}
|
||||
|
||||
boolean isPlayerServiceAvailable() {
|
||||
return playerService != null;
|
||||
boolean noPlayerServiceAvailable() {
|
||||
return playerService == null;
|
||||
}
|
||||
|
||||
boolean isPlayerAndPlayerServiceAvailable() {
|
||||
@ -2444,7 +2441,7 @@ public final class VideoDetailFragment
|
||||
|
||||
public Optional<View> getRoot() {
|
||||
return Optional.ofNullable(player)
|
||||
.flatMap(player1 -> player1.UIs().get(VideoPlayerUi.class))
|
||||
.flatMap(player1 -> player1.UIs().getOpt(VideoPlayerUi.class))
|
||||
.map(playerUi -> playerUi.getBinding().getRoot());
|
||||
}
|
||||
|
||||
|
||||
@ -121,67 +121,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);
|
||||
@ -196,6 +135,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);
|
||||
@ -238,6 +238,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();
|
||||
@ -246,7 +254,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
||||
}
|
||||
disposables.clear();
|
||||
binding = null;
|
||||
activity.removeMenuProvider(menuProvider);
|
||||
menuProvider = null;
|
||||
}
|
||||
|
||||
|
||||
@ -7,3 +7,16 @@ import java.io.Serializable
|
||||
inline fun <reified T : Serializable> Bundle.serializable(key: String?): T? {
|
||||
return BundleCompat.getSerializable(this, key, T::class.java)
|
||||
}
|
||||
|
||||
fun Bundle?.toDebugString(): String {
|
||||
if (this == null) {
|
||||
return "null"
|
||||
}
|
||||
val string = StringBuilder("Bundle{")
|
||||
for (key in this.keySet()) {
|
||||
@Suppress("DEPRECATION") // we want this[key] to return items of any type
|
||||
string.append(" ").append(key).append(" => ").append(this[key]).append(";")
|
||||
}
|
||||
string.append(" }")
|
||||
return string.toString()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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());
|
||||
|
||||
@ -3,47 +3,64 @@ 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;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
import androidx.core.os.BundleCompat;
|
||||
import androidx.fragment.app.DialogFragment;
|
||||
import androidx.fragment.app.Fragment;
|
||||
import androidx.work.Constraints;
|
||||
import androidx.work.ExistingWorkPolicy;
|
||||
import androidx.work.NetworkType;
|
||||
import androidx.work.OneTimeWorkRequest;
|
||||
import androidx.work.OutOfQuotaPolicy;
|
||||
import androidx.work.WorkManager;
|
||||
|
||||
import com.evernote.android.state.State;
|
||||
import com.livefront.bridge.Bridge;
|
||||
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput;
|
||||
import org.schabi.newpipe.local.subscription.workers.SubscriptionImportWorker;
|
||||
|
||||
public class ImportConfirmationDialog extends DialogFragment {
|
||||
@State
|
||||
protected Intent resultServiceIntent;
|
||||
private static final String INPUT = "input";
|
||||
|
||||
public static void show(@NonNull final Fragment fragment,
|
||||
@NonNull final Intent resultServiceIntent) {
|
||||
final ImportConfirmationDialog confirmationDialog = new ImportConfirmationDialog();
|
||||
confirmationDialog.setResultServiceIntent(resultServiceIntent);
|
||||
public static void show(@NonNull final Fragment fragment, final SubscriptionImportInput input) {
|
||||
final var confirmationDialog = new ImportConfirmationDialog();
|
||||
final var arguments = new Bundle();
|
||||
arguments.putParcelable(INPUT, input);
|
||||
confirmationDialog.setArguments(arguments);
|
||||
confirmationDialog.show(fragment.getParentFragmentManager(), null);
|
||||
}
|
||||
|
||||
public void setResultServiceIntent(final Intent resultServiceIntent) {
|
||||
this.resultServiceIntent = resultServiceIntent;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@Override
|
||||
public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
|
||||
assureCorrectAppLanguage(getContext());
|
||||
return new AlertDialog.Builder(requireContext())
|
||||
final var context = requireContext();
|
||||
assureCorrectAppLanguage(context);
|
||||
return new AlertDialog.Builder(context)
|
||||
.setMessage(R.string.import_network_expensive_warning)
|
||||
.setCancelable(true)
|
||||
.setNegativeButton(R.string.cancel, null)
|
||||
.setPositiveButton(R.string.ok, (dialogInterface, i) -> {
|
||||
if (resultServiceIntent != null && getContext() != null) {
|
||||
getContext().startService(resultServiceIntent);
|
||||
}
|
||||
final var constraints = new Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.build();
|
||||
final var input = BundleCompat.getParcelable(requireArguments(), INPUT,
|
||||
SubscriptionImportInput.class);
|
||||
|
||||
final var req = new OneTimeWorkRequest.Builder(SubscriptionImportWorker.class)
|
||||
.setInputData(input.toData())
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
.setConstraints(constraints)
|
||||
.build();
|
||||
|
||||
WorkManager.getInstance(context)
|
||||
.enqueueUniqueWork(SubscriptionImportWorker.WORK_NAME,
|
||||
ExistingWorkPolicy.APPEND_OR_REPLACE, req);
|
||||
|
||||
dismiss();
|
||||
})
|
||||
.create();
|
||||
@ -53,10 +70,6 @@ public class ImportConfirmationDialog extends DialogFragment {
|
||||
public void onCreate(@Nullable final Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
if (resultServiceIntent == null) {
|
||||
throw new IllegalStateException("Result intent is null");
|
||||
}
|
||||
|
||||
Bridge.restoreInstanceState(this, savedInstanceState);
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,6 @@ package org.schabi.newpipe.local.subscription
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
import android.view.LayoutInflater
|
||||
@ -49,11 +48,8 @@ import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem
|
||||
import org.schabi.newpipe.local.subscription.item.GroupsHeader
|
||||
import org.schabi.newpipe.local.subscription.item.Header
|
||||
import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE
|
||||
import org.schabi.newpipe.local.subscription.workers.SubscriptionExportWorker
|
||||
import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput
|
||||
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper
|
||||
import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable
|
||||
@ -224,21 +220,17 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
||||
}
|
||||
|
||||
private fun requestExportResult(result: ActivityResult) {
|
||||
if (result.data != null && result.resultCode == Activity.RESULT_OK) {
|
||||
activity.startService(
|
||||
Intent(activity, SubscriptionsExportService::class.java)
|
||||
.putExtra(SubscriptionsExportService.KEY_FILE_PATH, result.data?.data)
|
||||
)
|
||||
val data = result.data?.data
|
||||
if (data != null && result.resultCode == Activity.RESULT_OK) {
|
||||
SubscriptionExportWorker.schedule(activity, data)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestImportResult(result: ActivityResult) {
|
||||
if (result.data != null && result.resultCode == Activity.RESULT_OK) {
|
||||
val data = result.data?.dataString
|
||||
if (data != null && result.resultCode == Activity.RESULT_OK) {
|
||||
ImportConfirmationDialog.show(
|
||||
this,
|
||||
Intent(activity, SubscriptionsImportService::class.java)
|
||||
.putExtra(KEY_MODE, PREVIOUS_EXPORT_MODE)
|
||||
.putExtra(KEY_VALUE, result.data?.data)
|
||||
this, SubscriptionImportInput.PreviousExportMode(data)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
package org.schabi.newpipe.local.subscription
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Pair
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Completable
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
@ -48,23 +47,16 @@ class SubscriptionManager(context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun upsertAll(infoList: List<Pair<ChannelInfo, List<ChannelTabInfo>>>): List<SubscriptionEntity> {
|
||||
val listEntities = subscriptionTable.upsertAll(
|
||||
infoList.map { SubscriptionEntity.from(it.first) }
|
||||
)
|
||||
fun upsertAll(infoList: List<Pair<ChannelInfo, ChannelTabInfo>>) {
|
||||
val listEntities = infoList.map { SubscriptionEntity.from(it.first) }
|
||||
subscriptionTable.upsertAll(listEntities)
|
||||
|
||||
database.runInTransaction {
|
||||
infoList.forEachIndexed { index, info ->
|
||||
info.second.forEach {
|
||||
feedDatabaseManager.upsertAll(
|
||||
listEntities[index].uid,
|
||||
it.relatedItems.filterIsInstance<StreamInfoItem>()
|
||||
)
|
||||
}
|
||||
val streams = info.second.relatedItems.filterIsInstance<StreamInfoItem>()
|
||||
feedDatabaseManager.upsertAll(listEntities[index].uid, streams)
|
||||
}
|
||||
}
|
||||
|
||||
return listEntities
|
||||
}
|
||||
|
||||
fun updateChannelInfo(info: ChannelInfo): Completable =
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
package org.schabi.newpipe.local.subscription;
|
||||
|
||||
import static org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.ContentSource.CHANNEL_URL;
|
||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.CHANNEL_URL_MODE;
|
||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.INPUT_STREAM_MODE;
|
||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE;
|
||||
import static org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_VALUE;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
@ -37,7 +33,7 @@ import org.schabi.newpipe.error.UserAction;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor;
|
||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService;
|
||||
import org.schabi.newpipe.local.subscription.workers.SubscriptionImportInput;
|
||||
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard;
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
@ -168,10 +164,8 @@ public class SubscriptionsImportFragment extends BaseFragment {
|
||||
}
|
||||
|
||||
public void onImportUrl(final String value) {
|
||||
ImportConfirmationDialog.show(this, new Intent(activity, SubscriptionsImportService.class)
|
||||
.putExtra(KEY_MODE, CHANNEL_URL_MODE)
|
||||
.putExtra(KEY_VALUE, value)
|
||||
.putExtra(Constants.KEY_SERVICE_ID, currentServiceId));
|
||||
ImportConfirmationDialog.show(this,
|
||||
new SubscriptionImportInput.ChannelUrlMode(currentServiceId, value));
|
||||
}
|
||||
|
||||
public void onImportFile() {
|
||||
@ -186,16 +180,10 @@ public class SubscriptionsImportFragment extends BaseFragment {
|
||||
}
|
||||
|
||||
private void requestImportFileResult(final ActivityResult result) {
|
||||
if (result.getData() == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.getResultCode() == Activity.RESULT_OK && result.getData().getData() != null) {
|
||||
final String data = result.getData() != null ? result.getData().getDataString() : null;
|
||||
if (result.getResultCode() == Activity.RESULT_OK && data != null) {
|
||||
ImportConfirmationDialog.show(this,
|
||||
new Intent(activity, SubscriptionsImportService.class)
|
||||
.putExtra(KEY_MODE, INPUT_STREAM_MODE)
|
||||
.putExtra(KEY_VALUE, result.getData().getData())
|
||||
.putExtra(Constants.KEY_SERVICE_ID, currentServiceId));
|
||||
new SubscriptionImportInput.InputStreamMode(currentServiceId, data));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,233 +0,0 @@
|
||||
/*
|
||||
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* BaseImportExportService.java is part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.local.subscription.services;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.Intent;
|
||||
import android.os.Build;
|
||||
import android.os.IBinder;
|
||||
import android.text.TextUtils;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.core.app.NotificationCompat;
|
||||
import androidx.core.app.NotificationManagerCompat;
|
||||
import androidx.core.app.ServiceCompat;
|
||||
|
||||
import org.reactivestreams.Publisher;
|
||||
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.extractor.subscription.SubscriptionExtractor;
|
||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||
|
||||
import java.io.FileNotFoundException;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
import io.reactivex.rxjava3.functions.Function;
|
||||
import io.reactivex.rxjava3.processors.PublishProcessor;
|
||||
|
||||
public abstract class BaseImportExportService extends Service {
|
||||
protected final String TAG = this.getClass().getSimpleName();
|
||||
|
||||
protected final CompositeDisposable disposables = new CompositeDisposable();
|
||||
protected final PublishProcessor<String> notificationUpdater = PublishProcessor.create();
|
||||
|
||||
protected NotificationManagerCompat notificationManager;
|
||||
protected NotificationCompat.Builder notificationBuilder;
|
||||
protected SubscriptionManager subscriptionManager;
|
||||
|
||||
private static final int NOTIFICATION_SAMPLING_PERIOD = 2500;
|
||||
|
||||
protected final AtomicInteger currentProgress = new AtomicInteger(-1);
|
||||
protected final AtomicInteger maxProgress = new AtomicInteger(-1);
|
||||
protected final ImportExportEventListener eventListener = new ImportExportEventListener() {
|
||||
@Override
|
||||
public void onSizeReceived(final int size) {
|
||||
maxProgress.set(size);
|
||||
currentProgress.set(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onItemCompleted(final String itemName) {
|
||||
currentProgress.incrementAndGet();
|
||||
notificationUpdater.onNext(itemName);
|
||||
}
|
||||
};
|
||||
|
||||
protected Toast toast;
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public IBinder onBind(final Intent intent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
super.onCreate();
|
||||
subscriptionManager = new SubscriptionManager(this);
|
||||
setupNotification();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
disposeAll();
|
||||
}
|
||||
|
||||
protected void disposeAll() {
|
||||
disposables.clear();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Notification Impl
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
protected abstract int getNotificationId();
|
||||
|
||||
@StringRes
|
||||
public abstract int getTitle();
|
||||
|
||||
protected void setupNotification() {
|
||||
notificationManager = NotificationManagerCompat.from(this);
|
||||
notificationBuilder = createNotification();
|
||||
startForeground(getNotificationId(), notificationBuilder.build());
|
||||
|
||||
final Function<Flowable<String>, Publisher<String>> throttleAfterFirstEmission = flow ->
|
||||
flow.take(1).concatWith(flow.skip(1)
|
||||
.throttleLast(NOTIFICATION_SAMPLING_PERIOD, TimeUnit.MILLISECONDS));
|
||||
|
||||
disposables.add(notificationUpdater
|
||||
.filter(s -> !s.isEmpty())
|
||||
.publish(throttleAfterFirstEmission)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(this::updateNotification));
|
||||
}
|
||||
|
||||
protected void updateNotification(final String text) {
|
||||
notificationBuilder
|
||||
.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1);
|
||||
|
||||
final String progressText = currentProgress + "/" + maxProgress;
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
if (!TextUtils.isEmpty(text)) {
|
||||
notificationBuilder.setContentText(text + " (" + progressText + ")");
|
||||
}
|
||||
} else {
|
||||
notificationBuilder.setContentInfo(progressText);
|
||||
notificationBuilder.setContentText(text);
|
||||
}
|
||||
|
||||
notificationManager.notify(getNotificationId(), notificationBuilder.build());
|
||||
}
|
||||
|
||||
protected void stopService() {
|
||||
postErrorResult(null, null);
|
||||
}
|
||||
|
||||
protected void stopAndReportError(final Throwable throwable, final String request) {
|
||||
stopService();
|
||||
ErrorUtil.createNotification(this, new ErrorInfo(
|
||||
throwable, UserAction.SUBSCRIPTION_IMPORT_EXPORT, request));
|
||||
}
|
||||
|
||||
protected void postErrorResult(final String title, final String text) {
|
||||
disposeAll();
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE);
|
||||
stopSelf();
|
||||
|
||||
if (title == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final String textOrEmpty = text == null ? "" : text;
|
||||
notificationBuilder = new NotificationCompat
|
||||
.Builder(this, getString(R.string.notification_channel_id))
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setContentTitle(title)
|
||||
.setStyle(new NotificationCompat.BigTextStyle().bigText(textOrEmpty))
|
||||
.setContentText(textOrEmpty);
|
||||
notificationManager.notify(getNotificationId(), notificationBuilder.build());
|
||||
}
|
||||
|
||||
protected NotificationCompat.Builder createNotification() {
|
||||
return new NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
|
||||
.setOngoing(true)
|
||||
.setProgress(-1, -1, true)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setContentTitle(getString(getTitle()));
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Toast
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
protected void showToast(@StringRes final int message) {
|
||||
showToast(getString(message));
|
||||
}
|
||||
|
||||
protected void showToast(final String message) {
|
||||
if (toast != null) {
|
||||
toast.cancel();
|
||||
}
|
||||
|
||||
toast = Toast.makeText(this, message, Toast.LENGTH_SHORT);
|
||||
toast.show();
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Error handling
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
protected void handleError(@StringRes final int errorTitle, @NonNull final Throwable error) {
|
||||
String message = getErrorMessage(error);
|
||||
|
||||
if (TextUtils.isEmpty(message)) {
|
||||
final String errorClassName = error.getClass().getName();
|
||||
message = getString(R.string.error_occurred_detail, errorClassName);
|
||||
}
|
||||
|
||||
showToast(errorTitle);
|
||||
postErrorResult(getString(errorTitle), message);
|
||||
}
|
||||
|
||||
protected String getErrorMessage(final Throwable error) {
|
||||
String message = null;
|
||||
if (error instanceof SubscriptionExtractor.InvalidSourceException) {
|
||||
message = getString(R.string.invalid_source);
|
||||
} else if (error instanceof FileNotFoundException) {
|
||||
message = getString(R.string.invalid_file);
|
||||
} else if (ExceptionUtils.isNetworkRelated(error)) {
|
||||
message = getString(R.string.network_error);
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
package org.schabi.newpipe.local.subscription.services;
|
||||
|
||||
public interface ImportExportEventListener {
|
||||
/**
|
||||
* Called when the size has been resolved.
|
||||
*
|
||||
* @param size how many items there are to import/export
|
||||
*/
|
||||
void onSizeReceived(int size);
|
||||
|
||||
/**
|
||||
* Called every time an item has been parsed/resolved.
|
||||
*
|
||||
* @param itemName the name of the subscription item
|
||||
*/
|
||||
void onItemCompleted(String itemName);
|
||||
}
|
||||
@ -1,158 +0,0 @@
|
||||
/*
|
||||
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* ImportExportJsonHelper.java is part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.local.subscription.services;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.grack.nanojson.JsonAppendableWriter;
|
||||
import com.grack.nanojson.JsonArray;
|
||||
import com.grack.nanojson.JsonObject;
|
||||
import com.grack.nanojson.JsonParser;
|
||||
import com.grack.nanojson.JsonWriter;
|
||||
|
||||
import org.schabi.newpipe.BuildConfig;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* A JSON implementation capable of importing and exporting subscriptions, it has the advantage
|
||||
* of being able to transfer subscriptions to any device.
|
||||
*/
|
||||
public final class ImportExportJsonHelper {
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Json implementation
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private static final String JSON_APP_VERSION_KEY = "app_version";
|
||||
private static final String JSON_APP_VERSION_INT_KEY = "app_version_int";
|
||||
|
||||
private static final String JSON_SUBSCRIPTIONS_ARRAY_KEY = "subscriptions";
|
||||
|
||||
private static final String JSON_SERVICE_ID_KEY = "service_id";
|
||||
private static final String JSON_URL_KEY = "url";
|
||||
private static final String JSON_NAME_KEY = "name";
|
||||
|
||||
private ImportExportJsonHelper() { }
|
||||
|
||||
/**
|
||||
* Read a JSON source through the input stream.
|
||||
*
|
||||
* @param in the input stream (e.g. a file)
|
||||
* @param eventListener listener for the events generated
|
||||
* @return the parsed subscription items
|
||||
*/
|
||||
public static List<SubscriptionItem> readFrom(
|
||||
final InputStream in, @Nullable final ImportExportEventListener eventListener)
|
||||
throws InvalidSourceException {
|
||||
if (in == null) {
|
||||
throw new InvalidSourceException("input is null");
|
||||
}
|
||||
|
||||
final List<SubscriptionItem> channels = new ArrayList<>();
|
||||
|
||||
try {
|
||||
final JsonObject parentObject = JsonParser.object().from(in);
|
||||
|
||||
if (!parentObject.has(JSON_SUBSCRIPTIONS_ARRAY_KEY)) {
|
||||
throw new InvalidSourceException("Channels array is null");
|
||||
}
|
||||
|
||||
final JsonArray channelsArray = parentObject.getArray(JSON_SUBSCRIPTIONS_ARRAY_KEY);
|
||||
|
||||
if (eventListener != null) {
|
||||
eventListener.onSizeReceived(channelsArray.size());
|
||||
}
|
||||
|
||||
for (final Object o : channelsArray) {
|
||||
if (o instanceof JsonObject) {
|
||||
final JsonObject itemObject = (JsonObject) o;
|
||||
final int serviceId = itemObject.getInt(JSON_SERVICE_ID_KEY, 0);
|
||||
final String url = itemObject.getString(JSON_URL_KEY);
|
||||
final String name = itemObject.getString(JSON_NAME_KEY);
|
||||
|
||||
if (url != null && name != null && !url.isEmpty() && !name.isEmpty()) {
|
||||
channels.add(new SubscriptionItem(serviceId, url, name));
|
||||
if (eventListener != null) {
|
||||
eventListener.onItemCompleted(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (final Throwable e) {
|
||||
throw new InvalidSourceException("Couldn't parse json", e);
|
||||
}
|
||||
|
||||
return channels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the subscriptions items list as JSON to the output.
|
||||
*
|
||||
* @param items the list of subscriptions items
|
||||
* @param out the output stream (e.g. a file)
|
||||
* @param eventListener listener for the events generated
|
||||
*/
|
||||
public static void writeTo(final List<SubscriptionItem> items, final OutputStream out,
|
||||
@Nullable final ImportExportEventListener eventListener) {
|
||||
final JsonAppendableWriter writer = JsonWriter.on(out);
|
||||
writeTo(items, writer, eventListener);
|
||||
writer.done();
|
||||
}
|
||||
|
||||
/**
|
||||
* @see #writeTo(List, OutputStream, ImportExportEventListener)
|
||||
* @param items the list of subscriptions items
|
||||
* @param writer the output {@link JsonAppendableWriter}
|
||||
* @param eventListener listener for the events generated
|
||||
*/
|
||||
public static void writeTo(final List<SubscriptionItem> items,
|
||||
final JsonAppendableWriter writer,
|
||||
@Nullable final ImportExportEventListener eventListener) {
|
||||
if (eventListener != null) {
|
||||
eventListener.onSizeReceived(items.size());
|
||||
}
|
||||
|
||||
writer.object();
|
||||
|
||||
writer.value(JSON_APP_VERSION_KEY, BuildConfig.VERSION_NAME);
|
||||
writer.value(JSON_APP_VERSION_INT_KEY, BuildConfig.VERSION_CODE);
|
||||
|
||||
writer.array(JSON_SUBSCRIPTIONS_ARRAY_KEY);
|
||||
for (final SubscriptionItem item : items) {
|
||||
writer.object();
|
||||
writer.value(JSON_SERVICE_ID_KEY, item.getServiceId());
|
||||
writer.value(JSON_URL_KEY, item.getUrl());
|
||||
writer.value(JSON_NAME_KEY, item.getName());
|
||||
writer.end();
|
||||
|
||||
if (eventListener != null) {
|
||||
eventListener.onItemCompleted(item.getName());
|
||||
}
|
||||
}
|
||||
writer.end();
|
||||
|
||||
writer.end();
|
||||
}
|
||||
}
|
||||
@ -1,171 +0,0 @@
|
||||
/*
|
||||
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* SubscriptionsExportService.java is part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.local.subscription.services;
|
||||
|
||||
import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.content.IntentCompat;
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
|
||||
import org.reactivestreams.Subscriber;
|
||||
import org.reactivestreams.Subscription;
|
||||
import org.schabi.newpipe.App;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
||||
import org.schabi.newpipe.streams.io.SharpOutputStream;
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.functions.Function;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public class SubscriptionsExportService extends BaseImportExportService {
|
||||
public static final String KEY_FILE_PATH = "key_file_path";
|
||||
|
||||
/**
|
||||
* A {@link LocalBroadcastManager local broadcast} will be made with this action
|
||||
* when the export is successfully completed.
|
||||
*/
|
||||
public static final String EXPORT_COMPLETE_ACTION = App.PACKAGE_NAME + ".local.subscription"
|
||||
+ ".services.SubscriptionsExportService.EXPORT_COMPLETE";
|
||||
|
||||
private Subscription subscription;
|
||||
private StoredFileHelper outFile;
|
||||
private OutputStream outputStream;
|
||||
|
||||
@Override
|
||||
public int onStartCommand(final Intent intent, final int flags, final int startId) {
|
||||
if (intent == null || subscription != null) {
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
final Uri path = IntentCompat.getParcelableExtra(intent, KEY_FILE_PATH, Uri.class);
|
||||
if (path == null) {
|
||||
stopAndReportError(new IllegalStateException(
|
||||
"Exporting to a file, but the path is null"),
|
||||
"Exporting subscriptions");
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
try {
|
||||
outFile = new StoredFileHelper(this, path, "application/json");
|
||||
// truncate the file before writing to it, otherwise if the new content is smaller than
|
||||
// the previous file size, the file will retain part of the previous content and be
|
||||
// corrupted
|
||||
outputStream = new SharpOutputStream(outFile.openAndTruncateStream());
|
||||
} catch (final IOException e) {
|
||||
handleError(e);
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
startExport();
|
||||
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getNotificationId() {
|
||||
return 4567;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTitle() {
|
||||
return R.string.export_ongoing;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void disposeAll() {
|
||||
super.disposeAll();
|
||||
if (subscription != null) {
|
||||
subscription.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private void startExport() {
|
||||
showToast(R.string.export_ongoing);
|
||||
|
||||
subscriptionManager.subscriptionTable().getAll().take(1)
|
||||
.map(subscriptionEntities -> {
|
||||
final List<SubscriptionItem> result =
|
||||
new ArrayList<>(subscriptionEntities.size());
|
||||
for (final SubscriptionEntity entity : subscriptionEntities) {
|
||||
result.add(new SubscriptionItem(entity.getServiceId(), entity.getUrl(),
|
||||
entity.getName()));
|
||||
}
|
||||
return result;
|
||||
})
|
||||
.map(exportToFile())
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(getSubscriber());
|
||||
}
|
||||
|
||||
private Subscriber<StoredFileHelper> getSubscriber() {
|
||||
return new Subscriber<StoredFileHelper>() {
|
||||
@Override
|
||||
public void onSubscribe(final Subscription s) {
|
||||
subscription = s;
|
||||
s.request(1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(final StoredFileHelper file) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "startExport() success: file = " + file);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(final Throwable error) {
|
||||
Log.e(TAG, "onError() called with: error = [" + error + "]", error);
|
||||
handleError(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
LocalBroadcastManager.getInstance(SubscriptionsExportService.this)
|
||||
.sendBroadcast(new Intent(EXPORT_COMPLETE_ACTION));
|
||||
showToast(R.string.export_complete_toast);
|
||||
stopService();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Function<List<SubscriptionItem>, StoredFileHelper> exportToFile() {
|
||||
return subscriptionItems -> {
|
||||
ImportExportJsonHelper.writeTo(subscriptionItems, outputStream, eventListener);
|
||||
return outFile;
|
||||
};
|
||||
}
|
||||
|
||||
protected void handleError(final Throwable error) {
|
||||
super.handleError(R.string.subscriptions_export_unsuccessful, error);
|
||||
}
|
||||
}
|
||||
@ -1,327 +0,0 @@
|
||||
/*
|
||||
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* SubscriptionsImportService.java is part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.local.subscription.services;
|
||||
|
||||
import static org.schabi.newpipe.MainActivity.DEBUG;
|
||||
import static org.schabi.newpipe.streams.io.StoredFileHelper.DEFAULT_MIME;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.text.TextUtils;
|
||||
import android.util.Log;
|
||||
import android.util.Pair;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.IntentCompat;
|
||||
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
|
||||
|
||||
import org.reactivestreams.Subscriber;
|
||||
import org.reactivestreams.Subscription;
|
||||
import org.schabi.newpipe.App;
|
||||
import org.schabi.newpipe.R;
|
||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||
import org.schabi.newpipe.extractor.NewPipe;
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfo;
|
||||
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo;
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
|
||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
||||
import org.schabi.newpipe.streams.io.SharpInputStream;
|
||||
import org.schabi.newpipe.streams.io.StoredFileHelper;
|
||||
import org.schabi.newpipe.util.Constants;
|
||||
import org.schabi.newpipe.util.ExtractorHelper;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Flowable;
|
||||
import io.reactivex.rxjava3.core.Notification;
|
||||
import io.reactivex.rxjava3.functions.Consumer;
|
||||
import io.reactivex.rxjava3.functions.Function;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
public class SubscriptionsImportService extends BaseImportExportService {
|
||||
public static final int CHANNEL_URL_MODE = 0;
|
||||
public static final int INPUT_STREAM_MODE = 1;
|
||||
public static final int PREVIOUS_EXPORT_MODE = 2;
|
||||
public static final String KEY_MODE = "key_mode";
|
||||
public static final String KEY_VALUE = "key_value";
|
||||
|
||||
/**
|
||||
* A {@link LocalBroadcastManager local broadcast} will be made with this action
|
||||
* when the import is successfully completed.
|
||||
*/
|
||||
public static final String IMPORT_COMPLETE_ACTION = App.PACKAGE_NAME + ".local.subscription"
|
||||
+ ".services.SubscriptionsImportService.IMPORT_COMPLETE";
|
||||
|
||||
/**
|
||||
* How many extractions running in parallel.
|
||||
*/
|
||||
public static final int PARALLEL_EXTRACTIONS = 8;
|
||||
|
||||
/**
|
||||
* Number of items to buffer to mass-insert in the subscriptions table,
|
||||
* this leads to a better performance as we can then use db transactions.
|
||||
*/
|
||||
public static final int BUFFER_COUNT_BEFORE_INSERT = 50;
|
||||
|
||||
private Subscription subscription;
|
||||
private int currentMode;
|
||||
private int currentServiceId;
|
||||
@Nullable
|
||||
private String channelUrl;
|
||||
@Nullable
|
||||
private InputStream inputStream;
|
||||
@Nullable
|
||||
private String inputStreamType;
|
||||
|
||||
@Override
|
||||
public int onStartCommand(final Intent intent, final int flags, final int startId) {
|
||||
if (intent == null || subscription != null) {
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
currentMode = intent.getIntExtra(KEY_MODE, -1);
|
||||
currentServiceId = intent.getIntExtra(Constants.KEY_SERVICE_ID, Constants.NO_SERVICE_ID);
|
||||
|
||||
if (currentMode == CHANNEL_URL_MODE) {
|
||||
channelUrl = intent.getStringExtra(KEY_VALUE);
|
||||
} else {
|
||||
final Uri uri = IntentCompat.getParcelableExtra(intent, KEY_VALUE, Uri.class);
|
||||
if (uri == null) {
|
||||
stopAndReportError(new IllegalStateException(
|
||||
"Importing from input stream, but file path is null"),
|
||||
"Importing subscriptions");
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
try {
|
||||
final StoredFileHelper fileHelper = new StoredFileHelper(this, uri, DEFAULT_MIME);
|
||||
inputStream = new SharpInputStream(fileHelper.getStream());
|
||||
inputStreamType = fileHelper.getType();
|
||||
|
||||
if (inputStreamType == null || inputStreamType.equals(DEFAULT_MIME)) {
|
||||
// mime type could not be determined, just take file extension
|
||||
final String name = fileHelper.getName();
|
||||
final int pointIndex = name.lastIndexOf('.');
|
||||
if (pointIndex == -1 || pointIndex >= name.length() - 1) {
|
||||
inputStreamType = DEFAULT_MIME; // no extension, will fail in the extractor
|
||||
} else {
|
||||
inputStreamType = name.substring(pointIndex + 1);
|
||||
}
|
||||
}
|
||||
} catch (final IOException e) {
|
||||
handleError(e);
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentMode == -1 || currentMode == CHANNEL_URL_MODE && channelUrl == null) {
|
||||
final String errorDescription = "Some important field is null or in illegal state: "
|
||||
+ "currentMode=[" + currentMode + "], "
|
||||
+ "channelUrl=[" + channelUrl + "], "
|
||||
+ "inputStream=[" + inputStream + "]";
|
||||
stopAndReportError(new IllegalStateException(errorDescription),
|
||||
"Importing subscriptions");
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
startImport();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int getNotificationId() {
|
||||
return 4568;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTitle() {
|
||||
return R.string.import_ongoing;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void disposeAll() {
|
||||
super.disposeAll();
|
||||
if (subscription != null) {
|
||||
subscription.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Imports
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
private void startImport() {
|
||||
showToast(R.string.import_ongoing);
|
||||
|
||||
Flowable<List<SubscriptionItem>> flowable = null;
|
||||
switch (currentMode) {
|
||||
case CHANNEL_URL_MODE:
|
||||
flowable = importFromChannelUrl();
|
||||
break;
|
||||
case INPUT_STREAM_MODE:
|
||||
flowable = importFromInputStream();
|
||||
break;
|
||||
case PREVIOUS_EXPORT_MODE:
|
||||
flowable = importFromPreviousExport();
|
||||
break;
|
||||
}
|
||||
|
||||
if (flowable == null) {
|
||||
final String message = "Flowable given by \"importFrom\" is null "
|
||||
+ "(current mode: " + currentMode + ")";
|
||||
stopAndReportError(new IllegalStateException(message), "Importing subscriptions");
|
||||
return;
|
||||
}
|
||||
|
||||
flowable.doOnNext(subscriptionItems ->
|
||||
eventListener.onSizeReceived(subscriptionItems.size()))
|
||||
.flatMap(Flowable::fromIterable)
|
||||
|
||||
.parallel(PARALLEL_EXTRACTIONS)
|
||||
.runOn(Schedulers.io())
|
||||
.map((Function<SubscriptionItem, Notification<Pair<ChannelInfo,
|
||||
List<ChannelTabInfo>>>>) subscriptionItem -> {
|
||||
try {
|
||||
final ChannelInfo channelInfo = ExtractorHelper
|
||||
.getChannelInfo(subscriptionItem.getServiceId(),
|
||||
subscriptionItem.getUrl(), true)
|
||||
.blockingGet();
|
||||
return Notification.createOnNext(new Pair<>(channelInfo,
|
||||
Collections.singletonList(
|
||||
ExtractorHelper.getChannelTab(
|
||||
subscriptionItem.getServiceId(),
|
||||
channelInfo.getTabs().get(0), true).blockingGet()
|
||||
)));
|
||||
} catch (final Throwable e) {
|
||||
return Notification.createOnError(e);
|
||||
}
|
||||
})
|
||||
.sequential()
|
||||
|
||||
.observeOn(Schedulers.io())
|
||||
.doOnNext(getNotificationsConsumer())
|
||||
|
||||
.buffer(BUFFER_COUNT_BEFORE_INSERT)
|
||||
.map(upsertBatch())
|
||||
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(getSubscriber());
|
||||
}
|
||||
|
||||
private Subscriber<List<SubscriptionEntity>> getSubscriber() {
|
||||
return new Subscriber<>() {
|
||||
@Override
|
||||
public void onSubscribe(final Subscription s) {
|
||||
subscription = s;
|
||||
s.request(Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onNext(final List<SubscriptionEntity> successfulInserted) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "startImport() " + successfulInserted.size()
|
||||
+ " items successfully inserted into the database");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(final Throwable error) {
|
||||
Log.e(TAG, "Got an error!", error);
|
||||
handleError(error);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onComplete() {
|
||||
LocalBroadcastManager.getInstance(SubscriptionsImportService.this)
|
||||
.sendBroadcast(new Intent(IMPORT_COMPLETE_ACTION));
|
||||
showToast(R.string.import_complete_toast);
|
||||
stopService();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Consumer<Notification<Pair<ChannelInfo,
|
||||
List<ChannelTabInfo>>>> getNotificationsConsumer() {
|
||||
return notification -> {
|
||||
if (notification.isOnNext()) {
|
||||
final String name = notification.getValue().first.getName();
|
||||
eventListener.onItemCompleted(!TextUtils.isEmpty(name) ? name : "");
|
||||
} else if (notification.isOnError()) {
|
||||
final Throwable error = notification.getError();
|
||||
final Throwable cause = error.getCause();
|
||||
if (error instanceof IOException) {
|
||||
throw error;
|
||||
} else if (cause instanceof IOException) {
|
||||
throw cause;
|
||||
} else if (ExceptionUtils.isNetworkRelated(error)) {
|
||||
throw new IOException(error);
|
||||
}
|
||||
|
||||
eventListener.onItemCompleted("");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private Function<List<Notification<Pair<ChannelInfo, List<ChannelTabInfo>>>>,
|
||||
List<SubscriptionEntity>> upsertBatch() {
|
||||
return notificationList -> {
|
||||
final List<Pair<ChannelInfo, List<ChannelTabInfo>>> infoList =
|
||||
new ArrayList<>(notificationList.size());
|
||||
for (final Notification<Pair<ChannelInfo, List<ChannelTabInfo>>> n : notificationList) {
|
||||
if (n.isOnNext()) {
|
||||
infoList.add(n.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
return subscriptionManager.upsertAll(infoList);
|
||||
};
|
||||
}
|
||||
|
||||
private Flowable<List<SubscriptionItem>> importFromChannelUrl() {
|
||||
return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId)
|
||||
.getSubscriptionExtractor()
|
||||
.fromChannelUrl(channelUrl));
|
||||
}
|
||||
|
||||
private Flowable<List<SubscriptionItem>> importFromInputStream() {
|
||||
Objects.requireNonNull(inputStream);
|
||||
Objects.requireNonNull(inputStreamType);
|
||||
|
||||
return Flowable.fromCallable(() -> NewPipe.getService(currentServiceId)
|
||||
.getSubscriptionExtractor()
|
||||
.fromInputStream(inputStream, inputStreamType));
|
||||
}
|
||||
|
||||
private Flowable<List<SubscriptionItem>> importFromPreviousExport() {
|
||||
return Flowable.fromCallable(() -> ImportExportJsonHelper.readFrom(inputStream, null));
|
||||
}
|
||||
|
||||
protected void handleError(@NonNull final Throwable error) {
|
||||
super.handleError(R.string.subscriptions_import_unsuccessful, error);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,72 @@
|
||||
/*
|
||||
* Copyright 2018 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* ImportExportJsonHelper.java is part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.local.subscription.workers
|
||||
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.decodeFromStream
|
||||
import kotlinx.serialization.json.encodeToStream
|
||||
import org.schabi.newpipe.extractor.subscription.SubscriptionExtractor.InvalidSourceException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
|
||||
/**
|
||||
* A JSON implementation capable of importing and exporting subscriptions, it has the advantage
|
||||
* of being able to transfer subscriptions to any device.
|
||||
*/
|
||||
object ImportExportJsonHelper {
|
||||
private val json = Json { encodeDefaults = true }
|
||||
|
||||
/**
|
||||
* Read a JSON source through the input stream.
|
||||
*
|
||||
* @param in the input stream (e.g. a file)
|
||||
* @return the parsed subscription items
|
||||
*/
|
||||
@JvmStatic
|
||||
@Throws(InvalidSourceException::class)
|
||||
fun readFrom(`in`: InputStream?): List<SubscriptionItem> {
|
||||
if (`in` == null) {
|
||||
throw InvalidSourceException("input is null")
|
||||
}
|
||||
|
||||
try {
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
return json.decodeFromStream<SubscriptionData>(`in`).subscriptions
|
||||
} catch (e: Throwable) {
|
||||
throw InvalidSourceException("Couldn't parse json", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the subscriptions items list as JSON to the output.
|
||||
*
|
||||
* @param items the list of subscriptions items
|
||||
* @param out the output stream (e.g. a file)
|
||||
*/
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@JvmStatic
|
||||
fun writeTo(
|
||||
items: List<SubscriptionItem>,
|
||||
out: OutputStream,
|
||||
) {
|
||||
json.encodeToStream(SubscriptionData(items), out)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,24 @@
|
||||
package org.schabi.newpipe.local.subscription.workers
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.schabi.newpipe.BuildConfig
|
||||
|
||||
@Serializable
|
||||
class SubscriptionData(
|
||||
val subscriptions: List<SubscriptionItem>
|
||||
) {
|
||||
@SerialName("app_version")
|
||||
private val appVersion = BuildConfig.VERSION_NAME
|
||||
|
||||
@SerialName("app_version_int")
|
||||
private val appVersionInt = BuildConfig.VERSION_CODE
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SubscriptionItem(
|
||||
@SerialName("service_id")
|
||||
val serviceId: Int,
|
||||
val url: String,
|
||||
val name: String
|
||||
)
|
||||
@ -0,0 +1,119 @@
|
||||
package org.schabi.newpipe.local.subscription.workers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingWorkPolicy
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.reactive.awaitFirst
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.schabi.newpipe.BuildConfig
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.R
|
||||
|
||||
class SubscriptionExportWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
// This is needed for API levels < 31 (Android S).
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||
return createForegroundInfo(applicationContext.getString(R.string.export_ongoing))
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
return try {
|
||||
val uri = inputData.getString(EXPORT_PATH)!!.toUri()
|
||||
val table = NewPipeDatabase.getInstance(applicationContext).subscriptionDAO()
|
||||
val subscriptions =
|
||||
table.all
|
||||
.awaitFirst()
|
||||
.map { SubscriptionItem(it.serviceId, it.url, it.name) }
|
||||
|
||||
val qty = subscriptions.size
|
||||
val title = applicationContext.resources.getQuantityString(R.plurals.export_subscriptions, qty, qty)
|
||||
setForeground(createForegroundInfo(title))
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
// Truncate file if it already exists
|
||||
applicationContext.contentResolver.openOutputStream(uri, "wt")?.use {
|
||||
ImportExportJsonHelper.writeTo(subscriptions, it)
|
||||
}
|
||||
}
|
||||
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.i(TAG, "Exported $qty subscriptions")
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast
|
||||
.makeText(applicationContext, R.string.export_complete_toast, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.e(TAG, "Error while exporting subscriptions", e)
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast
|
||||
.makeText(applicationContext, R.string.subscriptions_export_unsuccessful, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
|
||||
return Result.failure()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createForegroundInfo(title: String): ForegroundInfo {
|
||||
val notification =
|
||||
NotificationCompat
|
||||
.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setOngoing(true)
|
||||
.setProgress(-1, -1, true)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.setContentTitle(title)
|
||||
.build()
|
||||
val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0
|
||||
return ForegroundInfo(NOTIFICATION_ID, notification, serviceType)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SubscriptionExportWork"
|
||||
private const val NOTIFICATION_ID = 4567
|
||||
private const val NOTIFICATION_CHANNEL_ID = "newpipe"
|
||||
private const val WORK_NAME = "exportSubscriptions"
|
||||
private const val EXPORT_PATH = "exportPath"
|
||||
|
||||
fun schedule(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
) {
|
||||
val data = workDataOf(EXPORT_PATH to uri.toString())
|
||||
val workRequest =
|
||||
OneTimeWorkRequestBuilder<SubscriptionExportWorker>()
|
||||
.setInputData(data)
|
||||
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
|
||||
.build()
|
||||
|
||||
WorkManager
|
||||
.getInstance(context)
|
||||
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,237 @@
|
||||
package org.schabi.newpipe.local.subscription.workers
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.Parcelable
|
||||
import android.util.Log
|
||||
import android.webkit.MimeTypeMap
|
||||
import android.widget.Toast
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.net.toUri
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.Data
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.rx3.await
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.schabi.newpipe.BuildConfig
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.NewPipe
|
||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
|
||||
class SubscriptionImportWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters,
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
// This is needed for API levels < 31 (Android S).
|
||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||
return createForegroundInfo(applicationContext.getString(R.string.import_ongoing), null, 0, 0)
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val subscriptions =
|
||||
try {
|
||||
loadSubscriptionsFromInput(SubscriptionImportInput.fromData(inputData))
|
||||
} catch (e: Exception) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.e(TAG, "Error while loading subscriptions from path", e)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast
|
||||
.makeText(applicationContext, R.string.subscriptions_import_unsuccessful, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
val mutex = Mutex()
|
||||
var index = 1
|
||||
val qty = subscriptions.size
|
||||
var title =
|
||||
applicationContext.resources.getQuantityString(R.plurals.load_subscriptions, qty, qty)
|
||||
|
||||
val channelInfoList =
|
||||
try {
|
||||
withContext(Dispatchers.IO.limitedParallelism(PARALLEL_EXTRACTIONS)) {
|
||||
subscriptions
|
||||
.map {
|
||||
async {
|
||||
val channelInfo =
|
||||
ExtractorHelper.getChannelInfo(it.serviceId, it.url, true).await()
|
||||
val channelTab =
|
||||
ExtractorHelper.getChannelTab(it.serviceId, channelInfo.tabs[0], true).await()
|
||||
|
||||
val currentIndex = mutex.withLock { index++ }
|
||||
setForeground(createForegroundInfo(title, channelInfo.name, currentIndex, qty))
|
||||
|
||||
channelInfo to channelTab
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (BuildConfig.DEBUG) {
|
||||
Log.e(TAG, "Error while loading subscription data", e)
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(applicationContext, R.string.subscriptions_import_unsuccessful, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
return Result.failure()
|
||||
}
|
||||
|
||||
title = applicationContext.resources.getQuantityString(R.plurals.import_subscriptions, qty, qty)
|
||||
setForeground(createForegroundInfo(title, null, 0, 0))
|
||||
index = 0
|
||||
|
||||
val subscriptionManager = SubscriptionManager(applicationContext)
|
||||
for (chunk in channelInfoList.chunked(BUFFER_COUNT_BEFORE_INSERT)) {
|
||||
withContext(Dispatchers.IO) {
|
||||
subscriptionManager.upsertAll(chunk)
|
||||
}
|
||||
index += chunk.size
|
||||
setForeground(createForegroundInfo(title, null, index, qty))
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
Toast.makeText(applicationContext, R.string.import_complete_toast, Toast.LENGTH_SHORT)
|
||||
.show()
|
||||
}
|
||||
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
private suspend fun loadSubscriptionsFromInput(input: SubscriptionImportInput): List<SubscriptionItem> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
when (input) {
|
||||
is SubscriptionImportInput.ChannelUrlMode ->
|
||||
NewPipe.getService(input.serviceId).subscriptionExtractor
|
||||
.fromChannelUrl(input.url)
|
||||
.map { SubscriptionItem(it.serviceId, it.url, it.name) }
|
||||
|
||||
is SubscriptionImportInput.InputStreamMode ->
|
||||
applicationContext.contentResolver.openInputStream(input.url.toUri())?.use {
|
||||
val contentType =
|
||||
MimeTypeMap.getFileExtensionFromUrl(input.url).ifEmpty { DEFAULT_MIME }
|
||||
NewPipe.getService(input.serviceId).subscriptionExtractor
|
||||
.fromInputStream(it, contentType)
|
||||
.map { SubscriptionItem(it.serviceId, it.url, it.name) }
|
||||
}
|
||||
|
||||
is SubscriptionImportInput.PreviousExportMode ->
|
||||
applicationContext.contentResolver.openInputStream(input.url.toUri())?.use {
|
||||
ImportExportJsonHelper.readFrom(it)
|
||||
}
|
||||
} ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun createForegroundInfo(
|
||||
title: String,
|
||||
text: String?,
|
||||
currentProgress: Int,
|
||||
maxProgress: Int,
|
||||
): ForegroundInfo {
|
||||
val notification =
|
||||
NotificationCompat
|
||||
.Builder(applicationContext, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
|
||||
.setOngoing(true)
|
||||
.setProgress(maxProgress, currentProgress, currentProgress == 0)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.addAction(
|
||||
R.drawable.ic_close,
|
||||
applicationContext.getString(R.string.cancel),
|
||||
WorkManager.getInstance(applicationContext).createCancelPendingIntent(id),
|
||||
).apply {
|
||||
if (currentProgress > 0 && maxProgress > 0) {
|
||||
val progressText = "$currentProgress/$maxProgress"
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
setSubText(progressText)
|
||||
} else {
|
||||
setContentInfo(progressText)
|
||||
}
|
||||
}
|
||||
}.build()
|
||||
val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0
|
||||
|
||||
return ForegroundInfo(NOTIFICATION_ID, notification, serviceType)
|
||||
}
|
||||
|
||||
companion object {
|
||||
// Log tag length is limited to 23 characters on API levels < 24.
|
||||
private const val TAG = "SubscriptionImport"
|
||||
|
||||
private const val NOTIFICATION_ID = 4568
|
||||
private const val NOTIFICATION_CHANNEL_ID = "newpipe"
|
||||
private const val DEFAULT_MIME = "application/octet-stream"
|
||||
private const val PARALLEL_EXTRACTIONS = 8
|
||||
private const val BUFFER_COUNT_BEFORE_INSERT = 50
|
||||
|
||||
const val WORK_NAME = "SubscriptionImportWorker"
|
||||
}
|
||||
}
|
||||
|
||||
sealed class SubscriptionImportInput : Parcelable {
|
||||
@Parcelize
|
||||
data class ChannelUrlMode(val serviceId: Int, val url: String) : SubscriptionImportInput()
|
||||
@Parcelize
|
||||
data class InputStreamMode(val serviceId: Int, val url: String) : SubscriptionImportInput()
|
||||
@Parcelize
|
||||
data class PreviousExportMode(val url: String) : SubscriptionImportInput()
|
||||
|
||||
fun toData(): Data {
|
||||
val (mode, serviceId, url) = when (this) {
|
||||
is ChannelUrlMode -> Triple(CHANNEL_URL_MODE, serviceId, url)
|
||||
is InputStreamMode -> Triple(INPUT_STREAM_MODE, serviceId, url)
|
||||
is PreviousExportMode -> Triple(PREVIOUS_EXPORT_MODE, null, url)
|
||||
}
|
||||
return workDataOf("mode" to mode, "service_id" to serviceId, "url" to url)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
private const val CHANNEL_URL_MODE = 0
|
||||
private const val INPUT_STREAM_MODE = 1
|
||||
private const val PREVIOUS_EXPORT_MODE = 2
|
||||
|
||||
fun fromData(data: Data): SubscriptionImportInput {
|
||||
val mode = data.getInt("mode", PREVIOUS_EXPORT_MODE)
|
||||
when (mode) {
|
||||
CHANNEL_URL_MODE -> {
|
||||
val serviceId = data.getInt("service_id", -1)
|
||||
if (serviceId == -1) {
|
||||
throw IllegalArgumentException("No service id provided")
|
||||
}
|
||||
val url = data.getString("url")!!
|
||||
return ChannelUrlMode(serviceId, url)
|
||||
}
|
||||
INPUT_STREAM_MODE -> {
|
||||
val serviceId = data.getInt("service_id", -1)
|
||||
if (serviceId == -1) {
|
||||
throw IllegalArgumentException("No service id provided")
|
||||
}
|
||||
val url = data.getString("url")!!
|
||||
return InputStreamMode(serviceId, url)
|
||||
}
|
||||
PREVIOUS_EXPORT_MODE -> {
|
||||
val url = data.getString("url")!!
|
||||
return PreviousExportMode(url)
|
||||
}
|
||||
else -> throw IllegalArgumentException("Unknown mode: $mode")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -183,7 +183,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);
|
||||
@ -220,13 +223,15 @@ public final class PlayQueueActivity extends AppCompatActivity
|
||||
public void onServiceConnected(final ComponentName name, final IBinder binder) {
|
||||
Log.d(TAG, "Player service is connected");
|
||||
|
||||
if (binder instanceof PlayerService.LocalBinder localBinder) {
|
||||
final @Nullable PlayerService s = localBinder.getService();
|
||||
if (binder instanceof PlayerService.LocalBinder) {
|
||||
@Nullable final PlayerService s =
|
||||
((PlayerService.LocalBinder) binder).getService();
|
||||
if (s == null) {
|
||||
player = null;
|
||||
} else {
|
||||
player = s.getPlayer();
|
||||
throw new IllegalArgumentException(
|
||||
"PlayerService.LocalBinder.getService() must never be"
|
||||
+ "null after the service connects");
|
||||
}
|
||||
player = s.getPlayer();
|
||||
}
|
||||
|
||||
if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) {
|
||||
|
||||
@ -55,6 +55,7 @@ import android.content.IntentFilter;
|
||||
import android.content.SharedPreferences;
|
||||
import android.graphics.Bitmap;
|
||||
import android.media.AudioManager;
|
||||
import android.support.v4.media.session.MediaSessionCompat;
|
||||
import android.util.Log;
|
||||
import android.view.LayoutInflater;
|
||||
|
||||
@ -71,6 +72,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;
|
||||
@ -263,7 +265,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);
|
||||
@ -294,7 +305,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)
|
||||
);
|
||||
}
|
||||
@ -462,14 +473,15 @@ public final class Player implements PlaybackListener, Listener {
|
||||
}
|
||||
|
||||
private void initUIsForCurrentPlayerType() {
|
||||
if ((UIs.get(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN)
|
||||
|| (UIs.get(PopupPlayerUi.class).isPresent() && playerType == PlayerType.POPUP)) {
|
||||
if ((UIs.getOpt(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN)
|
||||
|| (UIs.getOpt(PopupPlayerUi.class).isPresent()
|
||||
&& playerType == PlayerType.POPUP)) {
|
||||
// correct UI already in place
|
||||
return;
|
||||
}
|
||||
|
||||
// try to reuse binding if possible
|
||||
final PlayerBinding binding = UIs.get(VideoPlayerUi.class).map(VideoPlayerUi::getBinding)
|
||||
final PlayerBinding binding = UIs.getOpt(VideoPlayerUi.class).map(VideoPlayerUi::getBinding)
|
||||
.orElseGet(() -> {
|
||||
if (playerType == PlayerType.AUDIO) {
|
||||
return null;
|
||||
@ -480,15 +492,15 @@ public final class Player implements PlaybackListener, Listener {
|
||||
|
||||
switch (playerType) {
|
||||
case MAIN:
|
||||
UIs.destroyAll(PopupPlayerUi.class);
|
||||
UIs.destroyAllOfType(PopupPlayerUi.class);
|
||||
UIs.addAndPrepare(new MainPlayerUi(this, binding));
|
||||
break;
|
||||
case POPUP:
|
||||
UIs.destroyAll(MainPlayerUi.class);
|
||||
UIs.destroyAllOfType(MainPlayerUi.class);
|
||||
UIs.addAndPrepare(new PopupPlayerUi(this, binding));
|
||||
break;
|
||||
case AUDIO:
|
||||
UIs.destroyAll(VideoPlayerUi.class);
|
||||
UIs.destroyAllOfType(VideoPlayerUi.class);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -579,9 +591,15 @@ public final class Player implements PlaybackListener, Listener {
|
||||
}
|
||||
}
|
||||
|
||||
public void destroy() {
|
||||
|
||||
/**
|
||||
* Shut down this player.
|
||||
* Saves the stream progress, sets recovery.
|
||||
* Then destroys the player in all UIs and destroys the UIs as well.
|
||||
*/
|
||||
public void saveAndShutdown() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "destroy() called");
|
||||
Log.d(TAG, "saveAndShutdown() called");
|
||||
}
|
||||
|
||||
saveStreamProgressState();
|
||||
@ -594,7 +612,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
databaseUpdateDisposable.clear();
|
||||
progressUpdateDisposable.set(null);
|
||||
|
||||
UIs.destroyAll(Object.class); // destroy every UI: obviously every UI extends Object
|
||||
UIs.destroyAllOfType(null);
|
||||
}
|
||||
|
||||
public void setRecovery() {
|
||||
@ -637,7 +655,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() {
|
||||
@ -709,7 +727,7 @@ public final class Player implements PlaybackListener, Listener {
|
||||
pause();
|
||||
break;
|
||||
case ACTION_CLOSE:
|
||||
service.stopService();
|
||||
service.destroyPlayerAndStopService();
|
||||
break;
|
||||
case ACTION_PLAY_PAUSE:
|
||||
playPause();
|
||||
@ -1356,6 +1374,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
|
||||
|
||||
|
||||
@ -1970,6 +2001,10 @@ public final class Player implements PlaybackListener, Listener {
|
||||
triggerProgressUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the listener, if it was set.
|
||||
* @param listener listener to remove
|
||||
* */
|
||||
public void removeFragmentListener(final PlayerServiceEventListener listener) {
|
||||
if (fragmentListener == listener) {
|
||||
fragmentListener = null;
|
||||
@ -1984,6 +2019,10 @@ public final class Player implements PlaybackListener, Listener {
|
||||
triggerProgressUpdate();
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the listener, if it was set.
|
||||
* @param listener listener to remove
|
||||
* */
|
||||
void removeActivityListener(final PlayerEventListener listener) {
|
||||
if (activityListener == listener) {
|
||||
activityListener = null;
|
||||
|
||||
@ -1,196 +0,0 @@
|
||||
/*
|
||||
* Copyright 2017 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* Part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.schabi.newpipe.player;
|
||||
|
||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||
|
||||
import android.app.Service;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.os.Binder;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
|
||||
import org.schabi.newpipe.player.notification.NotificationPlayerUi;
|
||||
import org.schabi.newpipe.util.ThemeHelper;
|
||||
|
||||
import java.lang.ref.WeakReference;
|
||||
|
||||
|
||||
/**
|
||||
* One background service for our player. Even though the player has multiple UIs
|
||||
* (e.g. the audio-only UI, the main UI, the pulldown-menu UI),
|
||||
* this allows us to keep playing even when switching between the different UIs.
|
||||
*/
|
||||
public final class PlayerService extends Service {
|
||||
private static final String TAG = PlayerService.class.getSimpleName();
|
||||
private static final boolean DEBUG = Player.DEBUG;
|
||||
|
||||
private Player player;
|
||||
|
||||
private final IBinder mBinder = new PlayerService.LocalBinder(this);
|
||||
|
||||
public Player getPlayer() {
|
||||
return player;
|
||||
}
|
||||
|
||||
/*//////////////////////////////////////////////////////////////////////////
|
||||
// Service's LifeCycle
|
||||
//////////////////////////////////////////////////////////////////////////*/
|
||||
|
||||
@Override
|
||||
public void onCreate() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreate() called");
|
||||
}
|
||||
assureCorrectAppLanguage(this);
|
||||
ThemeHelper.setTheme(this);
|
||||
|
||||
player = new Player(this);
|
||||
/*
|
||||
Create the player notification and start immediately the service in foreground,
|
||||
otherwise if nothing is played or initializing the player and its components (especially
|
||||
loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the
|
||||
service would never be put in the foreground while we said to the system we would do so
|
||||
*/
|
||||
player.UIs().get(NotificationPlayerUi.class)
|
||||
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int onStartCommand(final Intent intent, final int flags, final int startId) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onStartCommand() called with: intent = [" + intent
|
||||
+ "], flags = [" + flags + "], startId = [" + startId + "]");
|
||||
}
|
||||
|
||||
/*
|
||||
Be sure that the player notification is set and the service is started in foreground,
|
||||
otherwise, the app may crash on Android 8+ as the service would never be put in the
|
||||
foreground while we said to the system we would do so
|
||||
The service is always requested to be started in foreground, so always creating a
|
||||
notification if there is no one already and starting the service in foreground should
|
||||
not create any issues
|
||||
If the service is already started in foreground, requesting it to be started shouldn't
|
||||
do anything
|
||||
*/
|
||||
if (player != null) {
|
||||
player.UIs().get(NotificationPlayerUi.class)
|
||||
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
|
||||
}
|
||||
|
||||
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
|
||||
&& (player == null || player.getPlayQueue() == null)) {
|
||||
/*
|
||||
No need to process media button's actions if the player is not working, otherwise
|
||||
the player service would strangely start with nothing to play
|
||||
Stop the service in this case, which will be removed from the foreground and its
|
||||
notification cancelled in its destruction
|
||||
*/
|
||||
stopSelf();
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
if (player != null) {
|
||||
player.handleIntent(intent);
|
||||
player.UIs().get(MediaSessionPlayerUi.class)
|
||||
.ifPresent(ui -> ui.handleMediaButtonIntent(intent));
|
||||
}
|
||||
|
||||
return START_NOT_STICKY;
|
||||
}
|
||||
|
||||
public void stopForImmediateReusing() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "stopForImmediateReusing() called");
|
||||
}
|
||||
|
||||
if (player != null && !player.exoPlayerIsNull()) {
|
||||
// Releases wifi & cpu, disables keepScreenOn, etc.
|
||||
// We can't just pause the player here because it will make transition
|
||||
// from one stream to a new stream not smooth
|
||||
player.smoothStopForImmediateReusing();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onTaskRemoved(final Intent rootIntent) {
|
||||
super.onTaskRemoved(rootIntent);
|
||||
if (player != null && !player.videoPlayerSelected()) {
|
||||
return;
|
||||
}
|
||||
onDestroy();
|
||||
// Unload from memory completely
|
||||
Runtime.getRuntime().halt(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "destroy() called");
|
||||
}
|
||||
cleanup();
|
||||
}
|
||||
|
||||
private void cleanup() {
|
||||
if (player != null) {
|
||||
player.destroy();
|
||||
player = null;
|
||||
}
|
||||
}
|
||||
|
||||
public void stopService() {
|
||||
cleanup();
|
||||
stopSelf();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void attachBaseContext(final Context base) {
|
||||
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base));
|
||||
}
|
||||
|
||||
@Override
|
||||
public IBinder onBind(final Intent intent) {
|
||||
return mBinder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows us this {@link org.schabi.newpipe.player.PlayerService} over the Service boundary
|
||||
* back to our {@link org.schabi.newpipe.player.helper.PlayerHolder}.
|
||||
*/
|
||||
public static class LocalBinder extends Binder {
|
||||
private final WeakReference<PlayerService> playerService;
|
||||
|
||||
LocalBinder(final PlayerService playerService) {
|
||||
this.playerService = new WeakReference<>(playerService);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PlayerService object itself.
|
||||
* @return this
|
||||
* */
|
||||
public @Nullable PlayerService getService() {
|
||||
return playerService.get();
|
||||
}
|
||||
}
|
||||
}
|
||||
348
app/src/main/java/org/schabi/newpipe/player/PlayerService.kt
Normal file
348
app/src/main/java/org/schabi/newpipe/player/PlayerService.kt
Normal file
@ -0,0 +1,348 @@
|
||||
/*
|
||||
* Copyright 2017 Mauricio Colli <mauriciocolli@outlook.com>
|
||||
* Part of NewPipe
|
||||
*
|
||||
* License: GPL-3.0+
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.schabi.newpipe.player
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Binder
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.util.Log
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
import org.schabi.newpipe.ktx.toDebugString
|
||||
import org.schabi.newpipe.player.mediabrowser.MediaBrowserImpl
|
||||
import org.schabi.newpipe.player.mediabrowser.MediaBrowserPlaybackPreparer
|
||||
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi
|
||||
import org.schabi.newpipe.player.notification.NotificationPlayerUi
|
||||
import org.schabi.newpipe.util.Localization
|
||||
import org.schabi.newpipe.util.ThemeHelper
|
||||
import java.lang.ref.WeakReference
|
||||
import java.util.function.BiConsumer
|
||||
import java.util.function.Consumer
|
||||
|
||||
/**
|
||||
* One service for all players.
|
||||
*/
|
||||
class PlayerService : MediaBrowserServiceCompat() {
|
||||
// These objects are used to cleanly separate the Service implementation (in this file) and the
|
||||
// media browser and playback preparer implementations. At the moment the playback preparer is
|
||||
// only used in conjunction with the media browser.
|
||||
private var mediaBrowserImpl: MediaBrowserImpl? = null
|
||||
private var mediaBrowserPlaybackPreparer: MediaBrowserPlaybackPreparer? = null
|
||||
|
||||
// these are instantiated in onCreate() as per
|
||||
// https://developer.android.com/training/cars/media#browser_workflow
|
||||
private var mediaSession: MediaSessionCompat? = null
|
||||
private var sessionConnector: MediaSessionConnector? = null
|
||||
|
||||
/**
|
||||
* @return the current active player instance. May be null, since the player service can outlive
|
||||
* the player e.g. to respond to Android Auto media browser queries.
|
||||
*/
|
||||
var player: Player? = null
|
||||
private set
|
||||
|
||||
private val mBinder: IBinder = LocalBinder(this)
|
||||
|
||||
/**
|
||||
* The parameter taken by this [Consumer] can be null to indicate the player is being
|
||||
* stopped.
|
||||
*/
|
||||
private var onPlayerStartedOrStopped: Consumer<Player?>? = null
|
||||
|
||||
//region Service lifecycle
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onCreate() called")
|
||||
}
|
||||
Localization.assureCorrectAppLanguage(this)
|
||||
ThemeHelper.setTheme(this)
|
||||
|
||||
mediaBrowserImpl = MediaBrowserImpl(
|
||||
this,
|
||||
Consumer { parentId: String ->
|
||||
this.notifyChildrenChanged(
|
||||
parentId
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// see https://developer.android.com/training/cars/media#browser_workflow
|
||||
val session = MediaSessionCompat(this, "MediaSessionPlayerServ")
|
||||
mediaSession = session
|
||||
setSessionToken(session.sessionToken)
|
||||
val connector = MediaSessionConnector(session)
|
||||
sessionConnector = connector
|
||||
connector.setMetadataDeduplicationEnabled(true)
|
||||
|
||||
mediaBrowserPlaybackPreparer = MediaBrowserPlaybackPreparer(
|
||||
this,
|
||||
BiConsumer { message: String, code: Int ->
|
||||
connector.setCustomErrorMessage(
|
||||
message,
|
||||
code
|
||||
)
|
||||
},
|
||||
Runnable { connector.setCustomErrorMessage(null) },
|
||||
Consumer { playWhenReady: Boolean? ->
|
||||
player?.onPrepare()
|
||||
}
|
||||
)
|
||||
connector.setPlaybackPreparer(mediaBrowserPlaybackPreparer)
|
||||
|
||||
// Note: you might be tempted to create the player instance and call startForeground here,
|
||||
// but be aware that the Android system might start the service just to perform media
|
||||
// queries. In those cases creating a player instance is a waste of resources, and calling
|
||||
// startForeground means creating a useless empty notification. In case it's really needed
|
||||
// the player instance can be created here, but startForeground() should definitely not be
|
||||
// called here unless the service is actually starting in the foreground, to avoid the
|
||||
// useless notification.
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
||||
if (DEBUG) {
|
||||
Log.d(
|
||||
TAG,
|
||||
(
|
||||
"onStartCommand() called with: intent = [" + intent +
|
||||
"], extras = [" + intent.extras.toDebugString() +
|
||||
"], flags = [" + flags + "], startId = [" + startId + "]"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// All internal NewPipe intents used to interact with the player, that are sent to the
|
||||
// PlayerService using startForegroundService(), will have SHOULD_START_FOREGROUND_EXTRA,
|
||||
// to ensure startForeground() is called (otherwise Android will force-crash the app).
|
||||
if (intent.getBooleanExtra(SHOULD_START_FOREGROUND_EXTRA, false)) {
|
||||
val playerWasNull = (player == null)
|
||||
if (playerWasNull) {
|
||||
// make sure the player exists, in case the service was resumed
|
||||
player = Player(this, mediaSession!!, sessionConnector!!)
|
||||
}
|
||||
|
||||
// Be sure that the player notification is set and the service is started in foreground,
|
||||
// otherwise, the app may crash on Android 8+ as the service would never be put in the
|
||||
// foreground while we said to the system we would do so. The service is always
|
||||
// requested to be started in foreground, so always creating a notification if there is
|
||||
// no one already and starting the service in foreground should not create any issues.
|
||||
// If the service is already started in foreground, requesting it to be started
|
||||
// shouldn't do anything.
|
||||
player!!.UIs().get(NotificationPlayerUi::class.java)
|
||||
?.createNotificationAndStartForeground()
|
||||
|
||||
val startedOrStopped = onPlayerStartedOrStopped
|
||||
if (playerWasNull && startedOrStopped != 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()")
|
||||
startedOrStopped.accept(player)
|
||||
}
|
||||
}
|
||||
|
||||
val p = player
|
||||
if (Intent.ACTION_MEDIA_BUTTON == intent.action &&
|
||||
(p == null || p.playQueue == null)
|
||||
) {
|
||||
/*
|
||||
No need to process media button's actions if the player is not working, otherwise
|
||||
the player service would strangely start with nothing to play
|
||||
Stop the service in this case, which will be removed from the foreground and its
|
||||
notification cancelled in its destruction
|
||||
*/
|
||||
destroyPlayerAndStopService()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (p != null) {
|
||||
p.handleIntent(intent)
|
||||
p.UIs().get(MediaSessionPlayerUi::class.java)
|
||||
?.handleMediaButtonIntent(intent)
|
||||
}
|
||||
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
fun stopForImmediateReusing() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "stopForImmediateReusing() called")
|
||||
}
|
||||
|
||||
val p = player
|
||||
if (p != null && !p.exoPlayerIsNull()) {
|
||||
// Releases wifi & cpu, disables keepScreenOn, etc.
|
||||
// We can't just pause the player here because it will make transition
|
||||
// from one stream to a new stream not smooth
|
||||
p.smoothStopForImmediateReusing()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
super.onTaskRemoved(rootIntent)
|
||||
val p = player
|
||||
if (p != null && !p.videoPlayerSelected()) {
|
||||
return
|
||||
}
|
||||
onDestroy()
|
||||
// Unload from memory completely
|
||||
Runtime.getRuntime().halt(0)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "destroy() called")
|
||||
}
|
||||
super.onDestroy()
|
||||
|
||||
cleanup()
|
||||
|
||||
mediaBrowserPlaybackPreparer?.dispose()
|
||||
mediaSession?.release()
|
||||
mediaBrowserImpl?.dispose()
|
||||
}
|
||||
|
||||
private fun cleanup() {
|
||||
val p = player
|
||||
if (p != null) {
|
||||
// notify that the player is being destroyed
|
||||
onPlayerStartedOrStopped?.accept(null)
|
||||
p.saveAndShutdown()
|
||||
player = null
|
||||
}
|
||||
|
||||
// Should already be handled by MediaSessionPlayerUi, but just to be sure.
|
||||
mediaSession?.setActive(false)
|
||||
|
||||
// Should already be handled by NotificationUtil.cancelNotificationAndStopForeground() in
|
||||
// NotificationPlayerUi, but let's make sure that the foreground service is stopped.
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the player and allows the player instance to be garbage collected. Sets the media
|
||||
* session to inactive. Stops the foreground service and removes the player notification
|
||||
* associated with it. Tries to stop the [PlayerService] completely, but this step will
|
||||
* have no effect in case some service connection still uses the service (e.g. the Android Auto
|
||||
* system accesses the media browser even when no player is running).
|
||||
*/
|
||||
fun destroyPlayerAndStopService() {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "destroyPlayerAndStopService() called")
|
||||
}
|
||||
|
||||
cleanup()
|
||||
|
||||
// This only really stops the service if there are no other service connections (see docs):
|
||||
// for example the (Android Auto) media browser binder will block stopService().
|
||||
// This is why we also stopForeground() above, to make sure the notification is removed.
|
||||
// If we were to call stopSelf(), then the service would be surely stopped (regardless of
|
||||
// other service connections), but this would be a waste of resources since the service
|
||||
// would be immediately restarted by those same connections to perform the queries.
|
||||
stopService(Intent(this, PlayerService::class.java))
|
||||
}
|
||||
|
||||
override fun attachBaseContext(base: Context?) {
|
||||
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base))
|
||||
}
|
||||
|
||||
//endregion
|
||||
//region Bind
|
||||
override fun onBind(intent: Intent): IBinder? {
|
||||
if (DEBUG) {
|
||||
Log.d(
|
||||
TAG,
|
||||
(
|
||||
"onBind() called with: intent = [" + intent +
|
||||
"], extras = [" + intent.extras.toDebugString() + "]"
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (BIND_PLAYER_HOLDER_ACTION == intent.action) {
|
||||
// Note that this binder might be reused multiple times while the service is alive, even
|
||||
// after unbind() has been called: https://stackoverflow.com/a/8794930 .
|
||||
return mBinder
|
||||
} else if (SERVICE_INTERFACE == intent.action) {
|
||||
// MediaBrowserService also uses its own binder, so for actions related to the media
|
||||
// browser service, pass the onBind to the superclass.
|
||||
return super.onBind(intent)
|
||||
} else {
|
||||
// This is an unknown request, avoid returning any binder to not leak objects.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
class LocalBinder internal constructor(playerService: PlayerService) : Binder() {
|
||||
private val playerService = WeakReference<PlayerService?>(playerService)
|
||||
|
||||
val service: PlayerService?
|
||||
get() = playerService.get()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the listener that will be called when the player is started or stopped. If a
|
||||
* `null` listener is passed, then the current listener will be unset. The parameter taken
|
||||
* by the [Consumer] can be null to indicate that the player is stopping.
|
||||
* @param listener the listener to set or unset
|
||||
*/
|
||||
fun setPlayerListener(listener: Consumer<Player?>?) {
|
||||
this.onPlayerStartedOrStopped = listener
|
||||
listener?.accept(player)
|
||||
}
|
||||
|
||||
//endregion
|
||||
//region Media browser
|
||||
override fun onGetRoot(
|
||||
clientPackageName: String,
|
||||
clientUid: Int,
|
||||
rootHints: Bundle?
|
||||
): BrowserRoot? {
|
||||
// TODO check if the accessing package has permission to view data
|
||||
return mediaBrowserImpl?.onGetRoot(clientPackageName, clientUid, rootHints)
|
||||
}
|
||||
|
||||
override fun onLoadChildren(
|
||||
parentId: String,
|
||||
result: Result<List<MediaBrowserCompat.MediaItem>>
|
||||
) {
|
||||
mediaBrowserImpl?.onLoadChildren(parentId, result)
|
||||
}
|
||||
|
||||
override fun onSearch(
|
||||
query: String,
|
||||
extras: Bundle?,
|
||||
result: Result<List<MediaBrowserCompat.MediaItem>>
|
||||
) {
|
||||
mediaBrowserImpl?.onSearch(query, result)
|
||||
} //endregion
|
||||
|
||||
companion object {
|
||||
private val TAG: String = PlayerService::class.java.getSimpleName()
|
||||
private val DEBUG = Player.DEBUG
|
||||
|
||||
const val SHOULD_START_FOREGROUND_EXTRA: String = "should_start_foreground_extra"
|
||||
const val BIND_PLAYER_HOLDER_ACTION: String = "bind_player_holder_action"
|
||||
}
|
||||
}
|
||||
@ -1,10 +0,0 @@
|
||||
package org.schabi.newpipe.player.event;
|
||||
|
||||
import org.schabi.newpipe.player.PlayerService;
|
||||
|
||||
/** Gets signalled if our PlayerHolder (dis)connects from the PlayerService. */
|
||||
public interface PlayerHolderLifecycleEventListener {
|
||||
void onServiceConnected(PlayerService playerService,
|
||||
boolean playAfterConnect);
|
||||
void onServiceDisconnected();
|
||||
}
|
||||
@ -0,0 +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 {
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
@ -7,7 +7,6 @@ import android.content.ServiceConnection;
|
||||
import android.os.IBinder;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
@ -17,17 +16,17 @@ import com.google.android.exoplayer2.PlaybackParameters;
|
||||
import org.schabi.newpipe.App;
|
||||
import org.schabi.newpipe.MainActivity;
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||
import org.schabi.newpipe.player.Player;
|
||||
import org.schabi.newpipe.player.PlayerService;
|
||||
import org.schabi.newpipe.player.Player;
|
||||
import org.schabi.newpipe.player.PlayerType;
|
||||
import org.schabi.newpipe.player.event.PlayerServiceEventListener;
|
||||
import org.schabi.newpipe.player.event.PlayerHolderLifecycleEventListener;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Singleton that manages a `PlayerService`
|
||||
* and can be used to control the player instance through the service.
|
||||
*/
|
||||
public final class PlayerHolder {
|
||||
|
||||
private PlayerHolder() {
|
||||
@ -44,14 +43,21 @@ public final class PlayerHolder {
|
||||
private static final boolean DEBUG = MainActivity.DEBUG;
|
||||
private static final String TAG = PlayerHolder.class.getSimpleName();
|
||||
|
||||
@Nullable private PlayerServiceEventListener listener;
|
||||
@Nullable private PlayerHolderLifecycleEventListener holderListener;
|
||||
@Nullable private PlayerServiceExtendedEventListener listener;
|
||||
|
||||
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,
|
||||
@ -61,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();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -84,70 +84,57 @@ 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 isNotBoundYet() {
|
||||
return !bound;
|
||||
public boolean isBound() {
|
||||
return bound;
|
||||
}
|
||||
|
||||
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 unsetListeners() {
|
||||
listener = null;
|
||||
holderListener = null;
|
||||
}
|
||||
|
||||
public void setListener(@NonNull final PlayerServiceEventListener newListener,
|
||||
@NonNull final PlayerHolderLifecycleEventListener newHolderListener) {
|
||||
public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) {
|
||||
listener = newListener;
|
||||
holderListener = newHolderListener;
|
||||
|
||||
if (listener == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Force reload data from service
|
||||
if (player != null) {
|
||||
holderListener.onServiceConnected(playerService, false);
|
||||
player.setFragmentListener(internalListener);
|
||||
if (playerService != null) {
|
||||
listener.onServiceConnected(playerService);
|
||||
startPlayerListener();
|
||||
// ^ will call listener.onPlayerConnected() down the line if there is an active player
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to handle context in common place as using the same
|
||||
* context to bind/unbind a service is crucial.
|
||||
*
|
||||
* @return the common context
|
||||
* */
|
||||
// helper to handle context in common place as using the same
|
||||
// context to bind/unbind a service is crucial
|
||||
private Context getCommonContext() {
|
||||
return App.getInstance();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Connect to (and if needed start) the {@link PlayerService}
|
||||
* and bind {@link PlayerServiceConnection} to it.
|
||||
* If the service is already started, only set the listener.
|
||||
* @param playAfterConnect If the service is started, start playing immediately
|
||||
* @param playAfterConnect If this holder’s service was already started,
|
||||
* start playing immediately
|
||||
* @param newListener set this listener
|
||||
* @param newHolderListener set this listener
|
||||
* */
|
||||
public void startService(final boolean playAfterConnect,
|
||||
final PlayerServiceEventListener newListener,
|
||||
final PlayerHolderLifecycleEventListener newHolderListener
|
||||
) {
|
||||
final PlayerServiceExtendedEventListener newListener) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "startService() called with playAfterConnect=" + playAfterConnect);
|
||||
}
|
||||
final Context context = getCommonContext();
|
||||
setListener(newListener, newHolderListener);
|
||||
setListener(newListener);
|
||||
if (bound) {
|
||||
return;
|
||||
}
|
||||
@ -155,56 +142,35 @@ 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));
|
||||
serviceConnection.playAfterConnect = playAfterConnect;
|
||||
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "bind() called");
|
||||
}
|
||||
|
||||
final Intent serviceIntent = new Intent(context, PlayerService.class);
|
||||
bound = context.bindService(serviceIntent, serviceConnection,
|
||||
Context.BIND_AUTO_CREATE);
|
||||
if (!bound) {
|
||||
context.unbindService(serviceConnection);
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
* Call {@link Context#unbindService(ServiceConnection)} on our service
|
||||
* (does not necesarily stop the service right away).
|
||||
* Remove all our listeners and deinitialize them.
|
||||
* @param context shared context
|
||||
* */
|
||||
private void unbind(final Context context) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "unbind() called");
|
||||
}
|
||||
|
||||
if (bound) {
|
||||
context.unbindService(serviceConnection);
|
||||
bound = false;
|
||||
if (player != null) {
|
||||
player.removeFragmentListener(internalListener);
|
||||
}
|
||||
playerService = null;
|
||||
player = null;
|
||||
if (holderListener != null) {
|
||||
holderListener.onServiceDisconnected();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PlayerServiceConnection implements ServiceConnection {
|
||||
|
||||
private boolean playAfterConnect = false;
|
||||
|
||||
public void doPlayAfterConnect(final boolean playAfterConnection) {
|
||||
this.playAfterConnect = playAfterConnection;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onServiceDisconnected(final ComponentName compName) {
|
||||
if (DEBUG) {
|
||||
@ -222,23 +188,88 @@ public final class PlayerHolder {
|
||||
}
|
||||
final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service;
|
||||
|
||||
playerService = localBinder.getService();
|
||||
player = playerService != null ? playerService.getPlayer() : null;
|
||||
|
||||
if (holderListener != null) {
|
||||
holderListener.onServiceConnected(playerService, playAfterConnect);
|
||||
@Nullable final PlayerService s = localBinder.getService();
|
||||
if (s == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"PlayerService.LocalBinder.getService() must never be"
|
||||
+ "null after the service connects");
|
||||
}
|
||||
if (player != null) {
|
||||
player.setFragmentListener(internalListener);
|
||||
playerService = s;
|
||||
if (listener != null) {
|
||||
listener.onServiceConnected(s);
|
||||
getPlayer().ifPresent(p -> listener.onPlayerConnected(p, playAfterConnect));
|
||||
}
|
||||
startPlayerListener();
|
||||
// ^ will call listener.onPlayerConnected() down the line if there is an active player
|
||||
|
||||
// notify the main activity that binding the service has completed, so that it can
|
||||
// open the bottom mini-player
|
||||
NavigationHelper.sendPlayerStartedEvent(s);
|
||||
}
|
||||
}
|
||||
|
||||
private void bind(final Context context) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "bind() called");
|
||||
}
|
||||
// BIND_AUTO_CREATE starts the service if it's not already running
|
||||
bound = bind(context, Context.BIND_AUTO_CREATE);
|
||||
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");
|
||||
}
|
||||
|
||||
if (bound) {
|
||||
context.unbindService(serviceConnection);
|
||||
bound = false;
|
||||
stopPlayerListener();
|
||||
playerService = null;
|
||||
if (listener != null) {
|
||||
listener.onPlayerDisconnected();
|
||||
listener.onServiceDisconnected();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void startPlayerListener() {
|
||||
if (playerService != null) {
|
||||
// setting the player listener will take care of calling relevant callbacks if the
|
||||
// player in the service is (not) already active, also see playerStateListener below
|
||||
playerService.setPlayerListener(playerStateListener);
|
||||
}
|
||||
getPlayer().ifPresent(p -> p.setFragmentListener(internalListener));
|
||||
}
|
||||
|
||||
private void stopPlayerListener() {
|
||||
if (playerService != null) {
|
||||
playerService.setPlayerListener(null);
|
||||
}
|
||||
getPlayer().ifPresent(p -> p.removeFragmentListener(internalListener));
|
||||
}
|
||||
|
||||
/**
|
||||
* Delegate all {@link PlayerServiceEventListener} events to our current `listener` object.
|
||||
* Only difference is that if {@link PlayerServiceEventListener#onServiceStopped()} is called,
|
||||
* it also calls {@link PlayerHolder#unbind(Context)}.
|
||||
* */
|
||||
* This listener will be held by the players created by {@link PlayerService}.
|
||||
*/
|
||||
private final PlayerServiceEventListener internalListener =
|
||||
new PlayerServiceEventListener() {
|
||||
@Override
|
||||
@ -325,4 +356,23 @@ public final class PlayerHolder {
|
||||
unbind(getCommonContext());
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* This listener will be held by bound {@link PlayerService}s to notify of the player starting
|
||||
* or stopping. This is necessary since the service outlives the player e.g. to answer Android
|
||||
* Auto media browser queries.
|
||||
*/
|
||||
private final Consumer<Player> playerStateListener = (@Nullable final Player player) -> {
|
||||
if (listener != null) {
|
||||
if (player == null) {
|
||||
// player.fragmentListener=null is already done by player.stopActivityBinding(),
|
||||
// which is called by player.destroy(), which is in turn called by PlayerService
|
||||
// before setting its player to null
|
||||
listener.onPlayerDisconnected();
|
||||
} else {
|
||||
listener.onPlayerConnected(player, serviceConnection.playAfterConnect);
|
||||
player.setFragmentListener(internalListener);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,399 @@
|
||||
package org.schabi.newpipe.player.mediabrowser
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.util.Log
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.media.MediaBrowserServiceCompat
|
||||
import androidx.media.MediaBrowserServiceCompat.Result
|
||||
import androidx.media.utils.MediaConstants
|
||||
import io.reactivex.rxjava3.core.Flowable
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.MainActivity.DEBUG
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.database.history.model.StreamHistoryEntry
|
||||
import org.schabi.newpipe.database.playlist.PlaylistLocalItem
|
||||
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry
|
||||
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
|
||||
import org.schabi.newpipe.extractor.InfoItem
|
||||
import org.schabi.newpipe.extractor.InfoItem.InfoType
|
||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
|
||||
import org.schabi.newpipe.extractor.search.SearchInfo
|
||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||
import org.schabi.newpipe.local.bookmark.MergedPlaylistManager
|
||||
import org.schabi.newpipe.local.playlist.LocalPlaylistManager
|
||||
import org.schabi.newpipe.local.playlist.RemotePlaylistManager
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
import org.schabi.newpipe.util.ServiceHelper
|
||||
import org.schabi.newpipe.util.image.ImageStrategy
|
||||
import java.util.function.Consumer
|
||||
|
||||
/**
|
||||
* This class is used to cleanly separate the Service implementation (in
|
||||
* [org.schabi.newpipe.player.PlayerService]) and the media browser implementation (in this file).
|
||||
*
|
||||
* @param notifyChildrenChanged takes the parent id of the children that changed
|
||||
*/
|
||||
class MediaBrowserImpl(
|
||||
private val context: Context,
|
||||
notifyChildrenChanged: Consumer<String>, // parentId
|
||||
) {
|
||||
private val database = NewPipeDatabase.getInstance(context)
|
||||
private var disposables = CompositeDisposable()
|
||||
|
||||
init {
|
||||
// this will listen to changes in the bookmarks until this MediaBrowserImpl is dispose()d
|
||||
disposables.add(
|
||||
getMergedPlaylists().subscribe { notifyChildrenChanged.accept(ID_BOOKMARKS) }
|
||||
)
|
||||
}
|
||||
|
||||
//region Cleanup
|
||||
fun dispose() {
|
||||
disposables.dispose()
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region onGetRoot
|
||||
fun onGetRoot(
|
||||
clientPackageName: String,
|
||||
clientUid: Int,
|
||||
rootHints: Bundle?
|
||||
): MediaBrowserServiceCompat.BrowserRoot {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onGetRoot($clientPackageName, $clientUid, $rootHints)")
|
||||
}
|
||||
|
||||
val extras = Bundle()
|
||||
extras.putBoolean(
|
||||
MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true
|
||||
)
|
||||
return MediaBrowserServiceCompat.BrowserRoot(ID_ROOT, extras)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region onLoadChildren
|
||||
fun onLoadChildren(parentId: String, result: Result<List<MediaBrowserCompat.MediaItem>>) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onLoadChildren($parentId)")
|
||||
}
|
||||
|
||||
result.detach() // allows sendResult() to happen later
|
||||
disposables.add(
|
||||
onLoadChildren(parentId)
|
||||
.subscribe(
|
||||
{ result.sendResult(it) },
|
||||
{ throwable ->
|
||||
// null indicates an error, see the docs of MediaSessionCompat.onSearch()
|
||||
result.sendResult(null)
|
||||
Log.e(TAG, "onLoadChildren error for parentId=$parentId: $throwable")
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun onLoadChildren(parentId: String): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
try {
|
||||
val parentIdUri = Uri.parse(parentId)
|
||||
val path = ArrayList(parentIdUri.pathSegments)
|
||||
|
||||
if (path.isEmpty()) {
|
||||
return Single.just(
|
||||
listOf(
|
||||
createRootMediaItem(
|
||||
ID_BOOKMARKS,
|
||||
context.resources.getString(R.string.tab_bookmarks_short),
|
||||
R.drawable.ic_bookmark_white
|
||||
),
|
||||
createRootMediaItem(
|
||||
ID_HISTORY,
|
||||
context.resources.getString(R.string.action_history),
|
||||
R.drawable.ic_history_white
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
when (/*val uriType = */path.removeAt(0)) {
|
||||
ID_BOOKMARKS -> {
|
||||
if (path.isEmpty()) {
|
||||
return populateBookmarks()
|
||||
}
|
||||
if (path.size == 2) {
|
||||
val localOrRemote = path[0]
|
||||
val playlistId = path[1].toLong()
|
||||
if (localOrRemote == ID_LOCAL) {
|
||||
return populateLocalPlaylist(playlistId)
|
||||
} else if (localOrRemote == ID_REMOTE) {
|
||||
return populateRemotePlaylist(playlistId)
|
||||
}
|
||||
}
|
||||
Log.w(TAG, "Unknown playlist URI: $parentId")
|
||||
throw parseError(parentId)
|
||||
}
|
||||
|
||||
ID_HISTORY -> return populateHistory()
|
||||
|
||||
else -> throw parseError(parentId)
|
||||
}
|
||||
} catch (e: ContentNotAvailableException) {
|
||||
return Single.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createRootMediaItem(
|
||||
mediaId: String?,
|
||||
folderName: String?,
|
||||
@DrawableRes iconResId: Int
|
||||
): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder.setMediaId(mediaId)
|
||||
builder.setTitle(folderName)
|
||||
val resources = context.resources
|
||||
builder.setIconUri(
|
||||
Uri.Builder()
|
||||
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
|
||||
.authority(resources.getResourcePackageName(iconResId))
|
||||
.appendPath(resources.getResourceTypeName(iconResId))
|
||||
.appendPath(resources.getResourceEntryName(iconResId))
|
||||
.build()
|
||||
)
|
||||
|
||||
val extras = Bundle()
|
||||
extras.putString(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
|
||||
context.getString(R.string.app_name)
|
||||
)
|
||||
builder.setExtras(extras)
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun createPlaylistMediaItem(playlist: PlaylistLocalItem): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder
|
||||
.setMediaId(createMediaIdForInfoItem(playlist is PlaylistRemoteEntity, playlist.uid))
|
||||
.setTitle(playlist.orderingName)
|
||||
.setIconUri(playlist.thumbnailUrl?.let { Uri.parse(it) })
|
||||
|
||||
val extras = Bundle()
|
||||
extras.putString(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
|
||||
context.resources.getString(R.string.tab_bookmarks),
|
||||
)
|
||||
builder.setExtras(extras)
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun createInfoItemMediaItem(item: InfoItem): MediaBrowserCompat.MediaItem? {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder.setMediaId(createMediaIdForInfoItem(item))
|
||||
.setTitle(item.name)
|
||||
|
||||
when (item.infoType) {
|
||||
InfoType.STREAM -> builder.setSubtitle((item as StreamInfoItem).uploaderName)
|
||||
InfoType.PLAYLIST -> builder.setSubtitle((item as PlaylistInfoItem).uploaderName)
|
||||
InfoType.CHANNEL -> builder.setSubtitle((item as ChannelInfoItem).description)
|
||||
else -> return null
|
||||
}
|
||||
|
||||
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
|
||||
builder.setIconUri(Uri.parse(it))
|
||||
}
|
||||
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildMediaId(): Uri.Builder {
|
||||
return Uri.Builder().authority(ID_AUTHORITY)
|
||||
}
|
||||
|
||||
private fun buildPlaylistMediaId(playlistType: String?): Uri.Builder {
|
||||
return buildMediaId()
|
||||
.appendPath(ID_BOOKMARKS)
|
||||
.appendPath(playlistType)
|
||||
}
|
||||
|
||||
private fun buildLocalPlaylistItemMediaId(isRemote: Boolean, playlistId: Long): Uri.Builder {
|
||||
return buildPlaylistMediaId(if (isRemote) ID_REMOTE else ID_LOCAL)
|
||||
.appendPath(playlistId.toString())
|
||||
}
|
||||
|
||||
private fun buildInfoItemMediaId(item: InfoItem): Uri.Builder {
|
||||
return buildMediaId()
|
||||
.appendPath(ID_INFO_ITEM)
|
||||
.appendPath(infoItemTypeToString(item.infoType))
|
||||
.appendPath(item.serviceId.toString())
|
||||
.appendQueryParameter(ID_URL, item.url)
|
||||
}
|
||||
|
||||
private fun createMediaIdForInfoItem(isRemote: Boolean, playlistId: Long): String {
|
||||
return buildLocalPlaylistItemMediaId(isRemote, playlistId)
|
||||
.build().toString()
|
||||
}
|
||||
|
||||
private fun createLocalPlaylistStreamMediaItem(
|
||||
playlistId: Long,
|
||||
item: PlaylistStreamEntry,
|
||||
index: Int,
|
||||
): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index))
|
||||
.setTitle(item.streamEntity.title)
|
||||
.setSubtitle(item.streamEntity.uploader)
|
||||
.setIconUri(Uri.parse(item.streamEntity.thumbnailUrl))
|
||||
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun createRemotePlaylistStreamMediaItem(
|
||||
playlistId: Long,
|
||||
item: StreamInfoItem,
|
||||
index: Int,
|
||||
): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index))
|
||||
.setTitle(item.name)
|
||||
.setSubtitle(item.uploaderName)
|
||||
|
||||
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
|
||||
builder.setIconUri(Uri.parse(it))
|
||||
}
|
||||
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun createMediaIdForPlaylistIndex(
|
||||
isRemote: Boolean,
|
||||
playlistId: Long,
|
||||
index: Int,
|
||||
): String {
|
||||
return buildLocalPlaylistItemMediaId(isRemote, playlistId)
|
||||
.appendPath(index.toString())
|
||||
.build().toString()
|
||||
}
|
||||
|
||||
private fun createMediaIdForInfoItem(item: InfoItem): String {
|
||||
return buildInfoItemMediaId(item).build().toString()
|
||||
}
|
||||
|
||||
private fun populateHistory(): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
val history = database.streamHistoryDAO().getHistory().firstOrError()
|
||||
return history.map { items ->
|
||||
items.map { this.createHistoryMediaItem(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun createHistoryMediaItem(streamHistoryEntry: StreamHistoryEntry): MediaBrowserCompat.MediaItem {
|
||||
val builder = MediaDescriptionCompat.Builder()
|
||||
val mediaId = buildMediaId()
|
||||
.appendPath(ID_HISTORY)
|
||||
.appendPath(streamHistoryEntry.streamId.toString())
|
||||
.build().toString()
|
||||
builder.setMediaId(mediaId)
|
||||
.setTitle(streamHistoryEntry.streamEntity.title)
|
||||
.setSubtitle(streamHistoryEntry.streamEntity.uploader)
|
||||
.setIconUri(Uri.parse(streamHistoryEntry.streamEntity.thumbnailUrl))
|
||||
|
||||
return MediaBrowserCompat.MediaItem(
|
||||
builder.build(),
|
||||
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||
)
|
||||
}
|
||||
|
||||
private fun getMergedPlaylists(): Flowable<MutableList<PlaylistLocalItem>> {
|
||||
return MergedPlaylistManager.getMergedOrderedPlaylists(
|
||||
LocalPlaylistManager(database),
|
||||
RemotePlaylistManager(database)
|
||||
)
|
||||
}
|
||||
|
||||
private fun populateBookmarks(): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
val playlists = getMergedPlaylists().firstOrError()
|
||||
return playlists.map { playlist ->
|
||||
playlist.map { this.createPlaylistMediaItem(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun populateLocalPlaylist(playlistId: Long): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
val playlist = LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError()
|
||||
return playlist.map { items ->
|
||||
items.mapIndexed { index, item ->
|
||||
createLocalPlaylistStreamMediaItem(playlistId, item, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun populateRemotePlaylist(playlistId: Long): Single<List<MediaBrowserCompat.MediaItem>> {
|
||||
return RemotePlaylistManager(database).getPlaylist(playlistId).firstOrError()
|
||||
.flatMap { ExtractorHelper.getPlaylistInfo(it.serviceId, it.url, false) }
|
||||
.map {
|
||||
// ignore it.errors, i.e. ignore errors about specific items, since there would
|
||||
// be no way to show the error properly in Android Auto anyway
|
||||
it.relatedItems.mapIndexed { index, item ->
|
||||
createRemotePlaylistStreamMediaItem(playlistId, item, index)
|
||||
}
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Search
|
||||
fun onSearch(
|
||||
query: String,
|
||||
result: Result<List<MediaBrowserCompat.MediaItem>>
|
||||
) {
|
||||
if (DEBUG) {
|
||||
Log.d(TAG, "onSearch($query)")
|
||||
}
|
||||
|
||||
result.detach() // allows sendResult() to happen later
|
||||
disposables.add(
|
||||
searchMusicBySongTitle(query)
|
||||
// ignore it.errors, i.e. ignore errors about specific items, since there would
|
||||
// be no way to show the error properly in Android Auto anyway
|
||||
.map { it.relatedItems.mapNotNull(this::createInfoItemMediaItem) }
|
||||
.subscribeOn(Schedulers.io())
|
||||
.subscribe(
|
||||
{ result.sendResult(it) },
|
||||
{ throwable ->
|
||||
// null indicates an error, see the docs of MediaSessionCompat.onSearch()
|
||||
result.sendResult(null)
|
||||
Log.e(TAG, "Search error for query=\"$query\": $throwable")
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
private fun searchMusicBySongTitle(query: String?): Single<SearchInfo> {
|
||||
val serviceId = ServiceHelper.getSelectedServiceId(context)
|
||||
return ExtractorHelper.searchFor(serviceId, query, listOf(), "")
|
||||
}
|
||||
//endregion
|
||||
|
||||
companion object {
|
||||
private val TAG: String = MediaBrowserImpl::class.java.getSimpleName()
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,259 @@
|
||||
package org.schabi.newpipe.player.mediabrowser
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.ResultReceiver
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
import android.util.Log
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.PlaybackPreparer
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import io.reactivex.rxjava3.disposables.Disposable
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||
import org.schabi.newpipe.MainActivity
|
||||
import org.schabi.newpipe.NewPipeDatabase
|
||||
import org.schabi.newpipe.R
|
||||
import org.schabi.newpipe.extractor.InfoItem.InfoType
|
||||
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
||||
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler
|
||||
import org.schabi.newpipe.local.playlist.LocalPlaylistManager
|
||||
import org.schabi.newpipe.local.playlist.RemotePlaylistManager
|
||||
import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue
|
||||
import org.schabi.newpipe.player.playqueue.PlayQueue
|
||||
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue
|
||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue
|
||||
import org.schabi.newpipe.util.ChannelTabHelper
|
||||
import org.schabi.newpipe.util.ExtractorHelper
|
||||
import org.schabi.newpipe.util.NavigationHelper
|
||||
import java.util.function.BiConsumer
|
||||
import java.util.function.Consumer
|
||||
|
||||
/**
|
||||
* This class is used to cleanly separate the Service implementation (in
|
||||
* [org.schabi.newpipe.player.PlayerService]) and the playback preparer implementation (in this
|
||||
* file). We currently use the playback preparer only in conjunction with the media browser: the
|
||||
* playback preparer will receive the media URLs generated by [MediaBrowserImpl] and will start
|
||||
* playback of the corresponding streams or playlists.
|
||||
*
|
||||
* @param setMediaSessionError takes an error String and an error code from [PlaybackStateCompat],
|
||||
* calls `sessionConnector.setCustomErrorMessage(errorString, errorCode)`
|
||||
* @param clearMediaSessionError calls `sessionConnector.setCustomErrorMessage(null)`
|
||||
* @param onPrepare takes playWhenReady, calls `player.prepare()`; this is needed because
|
||||
* `MediaSessionConnector`'s `onPlay()` method calls this class' [onPrepare] instead of
|
||||
* `player.prepare()` if the playback preparer is not null, but we want the original behavior
|
||||
*/
|
||||
class MediaBrowserPlaybackPreparer(
|
||||
private val context: Context,
|
||||
private val setMediaSessionError: BiConsumer<String, Int>, // error string, error code
|
||||
private val clearMediaSessionError: Runnable,
|
||||
private val onPrepare: Consumer<Boolean>,
|
||||
) : PlaybackPreparer {
|
||||
private val database = NewPipeDatabase.getInstance(context)
|
||||
private var disposable: Disposable? = null
|
||||
|
||||
fun dispose() {
|
||||
disposable?.dispose()
|
||||
}
|
||||
|
||||
//region Overrides
|
||||
override fun getSupportedPrepareActions(): Long {
|
||||
return PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID
|
||||
}
|
||||
|
||||
override fun onPrepare(playWhenReady: Boolean) {
|
||||
onPrepare.accept(playWhenReady)
|
||||
}
|
||||
|
||||
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
|
||||
if (MainActivity.DEBUG) {
|
||||
Log.d(TAG, "onPrepareFromMediaId($mediaId, $playWhenReady, $extras)")
|
||||
}
|
||||
|
||||
disposable?.dispose()
|
||||
disposable = extractPlayQueueFromMediaId(mediaId)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
{ playQueue ->
|
||||
clearMediaSessionError.run()
|
||||
NavigationHelper.playOnBackgroundPlayer(context, playQueue, playWhenReady)
|
||||
},
|
||||
{ throwable ->
|
||||
Log.e(TAG, "Failed to start playback of media ID [$mediaId]", throwable)
|
||||
onPrepareError()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
|
||||
onUnsupportedError()
|
||||
}
|
||||
|
||||
override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) {
|
||||
onUnsupportedError()
|
||||
}
|
||||
|
||||
override fun onCommand(
|
||||
player: Player,
|
||||
command: String,
|
||||
extras: Bundle?,
|
||||
cb: ResultReceiver?
|
||||
): Boolean {
|
||||
return false
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Errors
|
||||
private fun onUnsupportedError() {
|
||||
setMediaSessionError.accept(
|
||||
context.getString(R.string.content_not_supported),
|
||||
PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED
|
||||
)
|
||||
}
|
||||
|
||||
private fun onPrepareError() {
|
||||
setMediaSessionError.accept(
|
||||
context.getString(R.string.error_snackbar_message),
|
||||
PlaybackStateCompat.ERROR_CODE_APP_ERROR
|
||||
)
|
||||
}
|
||||
//endregion
|
||||
|
||||
//region Building play queues from playlists and history
|
||||
private fun extractLocalPlayQueue(playlistId: Long, index: Int): Single<PlayQueue> {
|
||||
return LocalPlaylistManager(database).getPlaylistStreams(playlistId).firstOrError()
|
||||
.map { items -> SinglePlayQueue(items.map { it.toStreamInfoItem() }, index) }
|
||||
}
|
||||
|
||||
private fun extractRemotePlayQueue(playlistId: Long, index: Int): Single<PlayQueue> {
|
||||
return RemotePlaylistManager(database).getPlaylist(playlistId).firstOrError()
|
||||
.flatMap { ExtractorHelper.getPlaylistInfo(it.serviceId, it.url, false) }
|
||||
// ignore info.errors, i.e. ignore errors about specific items, since there would
|
||||
// be no way to show the error properly in Android Auto anyway
|
||||
.map { info -> PlaylistPlayQueue(info, index) }
|
||||
}
|
||||
|
||||
private fun extractPlayQueueFromMediaId(mediaId: String): Single<PlayQueue> {
|
||||
try {
|
||||
val mediaIdUri = Uri.parse(mediaId)
|
||||
val path = ArrayList(mediaIdUri.pathSegments)
|
||||
if (path.isEmpty()) {
|
||||
throw parseError(mediaId)
|
||||
}
|
||||
|
||||
return when (/*val uriType = */path.removeAt(0)) {
|
||||
ID_BOOKMARKS -> extractPlayQueueFromPlaylistMediaId(
|
||||
mediaId,
|
||||
path,
|
||||
mediaIdUri.getQueryParameter(ID_URL)
|
||||
)
|
||||
|
||||
ID_HISTORY -> extractPlayQueueFromHistoryMediaId(mediaId, path)
|
||||
|
||||
ID_INFO_ITEM -> extractPlayQueueFromInfoItemMediaId(
|
||||
mediaId,
|
||||
path,
|
||||
mediaIdUri.getQueryParameter(ID_URL) ?: throw parseError(mediaId)
|
||||
)
|
||||
|
||||
else -> throw parseError(mediaId)
|
||||
}
|
||||
} catch (e: ContentNotAvailableException) {
|
||||
return Single.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContentNotAvailableException::class)
|
||||
private fun extractPlayQueueFromPlaylistMediaId(
|
||||
mediaId: String,
|
||||
path: MutableList<String>,
|
||||
url: String?,
|
||||
): Single<PlayQueue> {
|
||||
if (path.isEmpty()) {
|
||||
throw parseError(mediaId)
|
||||
}
|
||||
|
||||
when (val playlistType = path.removeAt(0)) {
|
||||
ID_LOCAL, ID_REMOTE -> {
|
||||
if (path.size != 2) {
|
||||
throw parseError(mediaId)
|
||||
}
|
||||
val playlistId = path[0].toLong()
|
||||
val index = path[1].toInt()
|
||||
return if (playlistType == ID_LOCAL)
|
||||
extractLocalPlayQueue(playlistId, index)
|
||||
else
|
||||
extractRemotePlayQueue(playlistId, index)
|
||||
}
|
||||
|
||||
ID_URL -> {
|
||||
if (path.size != 1 || url == null) {
|
||||
throw parseError(mediaId)
|
||||
}
|
||||
|
||||
val serviceId = path[0].toInt()
|
||||
return ExtractorHelper.getPlaylistInfo(serviceId, url, false)
|
||||
.map { PlaylistPlayQueue(it) }
|
||||
}
|
||||
|
||||
else -> throw parseError(mediaId)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContentNotAvailableException::class)
|
||||
private fun extractPlayQueueFromHistoryMediaId(
|
||||
mediaId: String,
|
||||
path: List<String>,
|
||||
): Single<PlayQueue> {
|
||||
if (path.size != 1) {
|
||||
throw parseError(mediaId)
|
||||
}
|
||||
|
||||
val streamId = path[0].toLong()
|
||||
return database.streamHistoryDAO().getHistory()
|
||||
.firstOrError()
|
||||
.map { items ->
|
||||
val infoItems = items
|
||||
.filter { it.streamId == streamId }
|
||||
.map { it.toStreamInfoItem() }
|
||||
SinglePlayQueue(infoItems, 0)
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(ContentNotAvailableException::class)
|
||||
private fun extractPlayQueueFromInfoItemMediaId(
|
||||
mediaId: String,
|
||||
path: List<String>,
|
||||
url: String,
|
||||
): Single<PlayQueue> {
|
||||
if (path.size != 2) {
|
||||
throw parseError(mediaId)
|
||||
}
|
||||
|
||||
val serviceId = path[1].toInt()
|
||||
return when (/*val infoItemType = */infoItemTypeFromString(path[0])) {
|
||||
InfoType.STREAM -> ExtractorHelper.getStreamInfo(serviceId, url, false)
|
||||
.map { SinglePlayQueue(it) }
|
||||
|
||||
InfoType.PLAYLIST -> ExtractorHelper.getPlaylistInfo(serviceId, url, false)
|
||||
.map { PlaylistPlayQueue(it) }
|
||||
|
||||
InfoType.CHANNEL -> ExtractorHelper.getChannelInfo(serviceId, url, false)
|
||||
.map { info ->
|
||||
val playableTab = info.tabs
|
||||
.firstOrNull { ChannelTabHelper.isStreamsTab(it) }
|
||||
?: throw ContentNotAvailableException("No streams tab found")
|
||||
return@map ChannelTabPlayQueue(serviceId, ListLinkHandler(playableTab))
|
||||
}
|
||||
|
||||
else -> throw parseError(mediaId)
|
||||
}
|
||||
}
|
||||
//endregion
|
||||
|
||||
companion object {
|
||||
private val TAG = MediaBrowserPlaybackPreparer::class.simpleName
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -145,7 +138,7 @@ public class MediaSessionPlayerUi extends PlayerUi
|
||||
public void play() {
|
||||
player.play();
|
||||
// hide the player controls even if the play command came from the media session
|
||||
player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0));
|
||||
player.UIs().getOpt(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0));
|
||||
}
|
||||
|
||||
@Override
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -102,7 +102,7 @@ public final class NotificationUtil {
|
||||
mediaStyle.setShowActionsInCompactView(compactSlots);
|
||||
}
|
||||
player.UIs()
|
||||
.get(MediaSessionPlayerUi.class)
|
||||
.getOpt(MediaSessionPlayerUi.class)
|
||||
.flatMap(MediaSessionPlayerUi::getSessionToken)
|
||||
.ifPresent(mediaStyle::setMediaSession);
|
||||
|
||||
|
||||
@ -28,13 +28,17 @@ abstract class AbstractInfoPlayQueue<T extends ListInfo<? extends InfoItem>>
|
||||
private transient Disposable fetchReactor;
|
||||
|
||||
protected AbstractInfoPlayQueue(final T info) {
|
||||
this(info, 0);
|
||||
}
|
||||
|
||||
protected AbstractInfoPlayQueue(final T info, final int index) {
|
||||
this(info.getServiceId(), info.getUrl(), info.getNextPage(),
|
||||
info.getRelatedItems()
|
||||
.stream()
|
||||
.filter(StreamInfoItem.class::isInstance)
|
||||
.map(StreamInfoItem.class::cast)
|
||||
.collect(Collectors.toList()),
|
||||
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,
|
||||
|
||||
@ -1,90 +0,0 @@
|
||||
package org.schabi.newpipe.player.ui;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class PlayerUiList {
|
||||
final List<PlayerUi> playerUis = new ArrayList<>();
|
||||
|
||||
/**
|
||||
* Creates a {@link PlayerUiList} starting with the provided player uis. The provided player uis
|
||||
* will not be prepared like those passed to {@link #addAndPrepare(PlayerUi)}, because when
|
||||
* the {@link PlayerUiList} constructor is called, the player is still not running and it
|
||||
* wouldn't make sense to initialize uis then. Instead the player will initialize them by doing
|
||||
* proper calls to {@link #call(Consumer)}.
|
||||
*
|
||||
* @param initialPlayerUis the player uis this list should start with; the order will be kept
|
||||
*/
|
||||
public PlayerUiList(final PlayerUi... initialPlayerUis) {
|
||||
playerUis.addAll(List.of(initialPlayerUis));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the provided player ui to the list and calls on it the initialization functions that
|
||||
* apply based on the current player state. The preparation step needs to be done since when UIs
|
||||
* are removed and re-added, the player will not call e.g. initPlayer again since the exoplayer
|
||||
* is already initialized, but we need to notify the newly built UI that the player is ready
|
||||
* nonetheless.
|
||||
* @param playerUi the player ui to prepare and add to the list; its {@link
|
||||
* PlayerUi#getPlayer()} will be used to query information about the player
|
||||
* state
|
||||
*/
|
||||
public void addAndPrepare(final PlayerUi playerUi) {
|
||||
if (playerUi.getPlayer().getFragmentListener().isPresent()) {
|
||||
// make sure UIs know whether a service is connected or not
|
||||
playerUi.onFragmentListenerSet();
|
||||
}
|
||||
|
||||
if (!playerUi.getPlayer().exoPlayerIsNull()) {
|
||||
playerUi.initPlayer();
|
||||
if (playerUi.getPlayer().getPlayQueue() != null) {
|
||||
playerUi.initPlayback();
|
||||
}
|
||||
}
|
||||
|
||||
playerUis.add(playerUi);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys all matching player UIs and removes them from the list.
|
||||
* @param playerUiType the class of the player UI to destroy; the {@link
|
||||
* Class#isInstance(Object)} method will be used, so even subclasses will be
|
||||
* destroyed and removed
|
||||
* @param <T> the class type parameter
|
||||
*/
|
||||
public <T> void destroyAll(final Class<T> playerUiType) {
|
||||
playerUis.stream()
|
||||
.filter(playerUiType::isInstance)
|
||||
.forEach(playerUi -> {
|
||||
playerUi.destroyPlayer();
|
||||
playerUi.destroy();
|
||||
});
|
||||
playerUis.removeIf(playerUiType::isInstance);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param playerUiType the class of the player UI to return; the {@link
|
||||
* Class#isInstance(Object)} method will be used, so even subclasses could
|
||||
* be returned
|
||||
* @param <T> the class type parameter
|
||||
* @return the first player UI of the required type found in the list, or an empty {@link
|
||||
* Optional} otherwise
|
||||
*/
|
||||
public <T> Optional<T> get(final Class<T> playerUiType) {
|
||||
return playerUis.stream()
|
||||
.filter(playerUiType::isInstance)
|
||||
.map(playerUiType::cast)
|
||||
.findFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the provided consumer on all player UIs in the list, in order of addition.
|
||||
* @param consumer the consumer to call with player UIs
|
||||
*/
|
||||
public void call(final Consumer<PlayerUi> consumer) {
|
||||
//noinspection SimplifyStreamApiCallChains
|
||||
playerUis.stream().forEachOrdered(consumer);
|
||||
}
|
||||
}
|
||||
124
app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt
Normal file
124
app/src/main/java/org/schabi/newpipe/player/ui/PlayerUiList.kt
Normal file
@ -0,0 +1,124 @@
|
||||
package org.schabi.newpipe.player.ui
|
||||
|
||||
import org.schabi.newpipe.util.GuardedByMutex
|
||||
import java.util.Optional
|
||||
|
||||
class PlayerUiList(vararg initialPlayerUis: PlayerUi) {
|
||||
private val playerUis = GuardedByMutex(mutableListOf<PlayerUi>())
|
||||
|
||||
/**
|
||||
* Creates a [PlayerUiList] starting with the provided player uis. The provided player uis
|
||||
* will not be prepared like those passed to [.addAndPrepare], because when
|
||||
* the [PlayerUiList] constructor is called, the player is still not running and it
|
||||
* wouldn't make sense to initialize uis then. Instead the player will initialize them by doing
|
||||
* proper calls to [.call].
|
||||
*
|
||||
* @param initialPlayerUis the player uis this list should start with; the order will be kept
|
||||
*/
|
||||
init {
|
||||
playerUis.runWithLockSync {
|
||||
lockData.addAll(listOf(*initialPlayerUis))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the provided player ui to the list and calls on it the initialization functions that
|
||||
* apply based on the current player state. The preparation step needs to be done since when UIs
|
||||
* are removed and re-added, the player will not call e.g. initPlayer again since the exoplayer
|
||||
* is already initialized, but we need to notify the newly built UI that the player is ready
|
||||
* nonetheless.
|
||||
* @param playerUi the player ui to prepare and add to the list; its [PlayerUi.getPlayer]
|
||||
* will be used to query information about the player state
|
||||
*/
|
||||
fun addAndPrepare(playerUi: PlayerUi) {
|
||||
if (playerUi.getPlayer().fragmentListener.isPresent) {
|
||||
// make sure UIs know whether a service is connected or not
|
||||
playerUi.onFragmentListenerSet()
|
||||
}
|
||||
|
||||
if (!playerUi.getPlayer().exoPlayerIsNull()) {
|
||||
playerUi.initPlayer()
|
||||
if (playerUi.getPlayer().playQueue != null) {
|
||||
playerUi.initPlayback()
|
||||
}
|
||||
}
|
||||
|
||||
playerUis.runWithLockSync {
|
||||
lockData.add(playerUi)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys all matching player UIs and removes them from the list.
|
||||
* @param playerUiType the class of the player UI to destroy, everything if `null`.
|
||||
* The [Class.isInstance] method will be used, so even subclasses will be
|
||||
* destroyed and removed
|
||||
* @param T the class type parameter </T>
|
||||
* */
|
||||
fun <T : PlayerUi> destroyAllOfType(playerUiType: Class<T>? = null) {
|
||||
val toDestroy = mutableListOf<PlayerUi>()
|
||||
|
||||
// short blocking removal from class to prevent interfering from other threads
|
||||
playerUis.runWithLockSync {
|
||||
val new = mutableListOf<PlayerUi>()
|
||||
for (ui in lockData) {
|
||||
if (playerUiType == null || playerUiType.isInstance(ui)) {
|
||||
toDestroy.add(ui)
|
||||
} else {
|
||||
new.add(ui)
|
||||
}
|
||||
}
|
||||
lockData = new
|
||||
}
|
||||
// then actually destroy the UIs
|
||||
for (ui in toDestroy) {
|
||||
ui.destroyPlayer()
|
||||
ui.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param playerUiType the class of the player UI to return;
|
||||
* the [Class.isInstance] method will be used, so even subclasses could be returned
|
||||
* @param T the class type parameter
|
||||
* @return the first player UI of the required type found in the list, or null
|
||||
</T> */
|
||||
fun <T : PlayerUi> get(playerUiType: Class<T>): T? =
|
||||
playerUis.runWithLockSync {
|
||||
for (ui in lockData) {
|
||||
if (playerUiType.isInstance(ui)) {
|
||||
when (val r = playerUiType.cast(ui)) {
|
||||
// try all UIs before returning null
|
||||
null -> continue
|
||||
else -> return@runWithLockSync r
|
||||
}
|
||||
}
|
||||
}
|
||||
return@runWithLockSync null
|
||||
}
|
||||
|
||||
/**
|
||||
* @param playerUiType the class of the player UI to return;
|
||||
* the [Class.isInstance] method will be used, so even subclasses could be returned
|
||||
* @param T the class type parameter
|
||||
* @return the first player UI of the required type found in the list, or an empty
|
||||
* [Optional] otherwise
|
||||
</T> */
|
||||
@Deprecated("use get", ReplaceWith("get(playerUiType)"))
|
||||
fun <T : PlayerUi> getOpt(playerUiType: Class<T>): Optional<T> =
|
||||
Optional.ofNullable(get(playerUiType))
|
||||
|
||||
/**
|
||||
* Calls the provided consumer on all player UIs in the list, in order of addition.
|
||||
* @param consumer the consumer to call with player UIs
|
||||
*/
|
||||
fun call(consumer: java.util.function.Consumer<PlayerUi>) {
|
||||
// copy the list out of the mutex before calling the consumer which might block
|
||||
val new = playerUis.runWithLockSync {
|
||||
lockData.toMutableList()
|
||||
}
|
||||
for (ui in new) {
|
||||
consumer.accept(ui)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.nextResizeModeAndSaveToPrefs;
|
||||
import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences;
|
||||
|
||||
import android.annotation.SuppressLint;
|
||||
import android.content.Intent;
|
||||
import android.content.res.Resources;
|
||||
import android.graphics.Bitmap;
|
||||
@ -761,7 +762,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the play/pause button ({@link R.id.playPauseButton}) to reflect the action
|
||||
* Update the play/pause button (`R.id.playPauseButton`) to reflect the action
|
||||
* that will be performed when the button is clicked..
|
||||
* @param action the action that is performed when the play/pause button is clicked
|
||||
*/
|
||||
@ -947,6 +948,8 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
|
||||
player.toggleShuffleModeEnabled();
|
||||
}
|
||||
|
||||
// TODO: don’t reference internal exoplayer2 resources
|
||||
@SuppressLint("PrivateResource")
|
||||
@Override
|
||||
public void onRepeatModeChanged(@RepeatMode final int repeatMode) {
|
||||
super.onRepeatModeChanged(repeatMode);
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
package org.schabi.newpipe.settings;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.preference.Preference;
|
||||
|
||||
import org.schabi.newpipe.DownloaderImpl;
|
||||
@ -15,13 +20,13 @@ import org.schabi.newpipe.extractor.localization.Localization;
|
||||
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||
import org.schabi.newpipe.util.image.PreferredImageQuality;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import coil3.SingletonImageLoader;
|
||||
|
||||
public class ContentSettingsFragment extends BasePreferenceFragment {
|
||||
private String youtubeRestrictedModeEnabledKey;
|
||||
|
||||
private Localization initialSelectedLocalization;
|
||||
private ContentCountry initialSelectedContentCountry;
|
||||
private String initialLanguage;
|
||||
|
||||
@Override
|
||||
@ -30,12 +35,28 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
|
||||
|
||||
addPreferencesFromResourceRegistry();
|
||||
|
||||
initialSelectedLocalization = org.schabi.newpipe.util.Localization
|
||||
.getPreferredLocalization(requireContext());
|
||||
initialSelectedContentCountry = org.schabi.newpipe.util.Localization
|
||||
.getPreferredContentCountry(requireContext());
|
||||
initialLanguage = defaultPreferences.getString(getString(R.string.app_language_key), "en");
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
requirePreference(R.string.app_language_key).setVisible(false);
|
||||
final Preference newAppLanguagePref =
|
||||
requirePreference(R.string.app_language_android_13_and_up_key);
|
||||
newAppLanguagePref.setSummaryProvider(preference -> {
|
||||
final Locale customLocale = AppCompatDelegate.getApplicationLocales().get(0);
|
||||
if (customLocale != null) {
|
||||
return customLocale.getDisplayName();
|
||||
}
|
||||
return getString(R.string.systems_language);
|
||||
});
|
||||
newAppLanguagePref.setOnPreferenceClickListener(preference -> {
|
||||
final Intent intent = new Intent(Settings.ACTION_APP_LOCALE_SETTINGS)
|
||||
.setData(Uri.fromParts("package", requireContext().getPackageName(), null));
|
||||
startActivity(intent);
|
||||
return true;
|
||||
});
|
||||
newAppLanguagePref.setVisible(true);
|
||||
}
|
||||
|
||||
final Preference imageQualityPreference = requirePreference(R.string.image_quality_key);
|
||||
imageQualityPreference.setOnPreferenceChangeListener(
|
||||
(preference, newValue) -> {
|
||||
@ -70,19 +91,21 @@ 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();
|
||||
|
||||
if (!selectedLanguage.equals(initialLanguage)) {
|
||||
if (Build.VERSION.SDK_INT < 33) {
|
||||
Toast.makeText(
|
||||
requireContext(),
|
||||
R.string.localization_changes_requires_app_restart,
|
||||
Toast.LENGTH_LONG
|
||||
).show();
|
||||
}
|
||||
final Localization selectedLocalization = org.schabi.newpipe.util.Localization
|
||||
.getPreferredLocalization(requireContext());
|
||||
final ContentCountry selectedContentCountry = org.schabi.newpipe.util.Localization
|
||||
.getPreferredContentCountry(requireContext());
|
||||
NewPipe.setupLocalization(selectedLocalization, selectedContentCountry);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
@ -74,7 +75,12 @@ fun RelatedItems(info: StreamInfo) {
|
||||
}
|
||||
if (info.relatedItems.isEmpty()) {
|
||||
item {
|
||||
EmptyStateComposable(EmptyStateSpec.NoVideos)
|
||||
EmptyStateComposable(
|
||||
spec = EmptyStateSpec.NoVideos,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 128.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -99,10 +99,12 @@ fun Comment(comment: CommentsInfoItem, onCommentAuthorOpened: () -> Unit) {
|
||||
}
|
||||
|
||||
val nameAndDate = remember(comment) {
|
||||
val date = Localization.relativeTimeOrTextual(
|
||||
context, comment.uploadDate, comment.textualUploadDate
|
||||
Localization.concatenateStrings(
|
||||
Localization.localizeUserName(comment.uploaderName),
|
||||
Localization.relativeTimeOrTextual(
|
||||
context, comment.uploadDate, comment.textualUploadDate
|
||||
)
|
||||
)
|
||||
Localization.concatenateStrings(comment.uploaderName, date)
|
||||
}
|
||||
Text(
|
||||
text = nameAndDate,
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package org.schabi.newpipe.ui.components.video.comment
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@ -126,14 +128,22 @@ private fun CommentRepliesDialog(
|
||||
} else if (refresh is LoadState.Error) {
|
||||
// TODO use error panel instead
|
||||
EmptyStateComposable(
|
||||
EmptyStateSpec.DisabledComments.copy(
|
||||
spec = EmptyStateSpec.DisabledComments.copy(
|
||||
descriptionText = {
|
||||
stringResource(R.string.error_unable_to_load_comments)
|
||||
},
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 128.dp)
|
||||
)
|
||||
} else {
|
||||
EmptyStateComposable(EmptyStateSpec.NoComments)
|
||||
EmptyStateComposable(
|
||||
spec = EmptyStateSpec.NoComments,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 128.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package org.schabi.newpipe.ui.components.video.comment
|
||||
|
||||
import android.content.res.Configuration
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@ -68,11 +70,22 @@ private fun CommentSection(
|
||||
|
||||
if (commentInfo.isCommentsDisabled) {
|
||||
item {
|
||||
EmptyStateComposable(EmptyStateSpec.DisabledComments)
|
||||
EmptyStateComposable(
|
||||
spec = EmptyStateSpec.DisabledComments,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 128.dp)
|
||||
|
||||
)
|
||||
}
|
||||
} else if (count == 0) {
|
||||
item {
|
||||
EmptyStateComposable(EmptyStateSpec.NoComments)
|
||||
EmptyStateComposable(
|
||||
spec = EmptyStateSpec.NoComments,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 128.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// do not show anything if the comment count is unknown
|
||||
@ -121,11 +134,14 @@ private fun CommentSection(
|
||||
item {
|
||||
// TODO use error panel instead
|
||||
EmptyStateComposable(
|
||||
EmptyStateSpec.DisabledComments.copy(
|
||||
spec = EmptyStateSpec.DisabledComments.copy(
|
||||
descriptionText = {
|
||||
stringResource(R.string.error_unable_to_load_comments)
|
||||
}
|
||||
)
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 128.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,7 +3,6 @@ package org.schabi.newpipe.ui.emptystate
|
||||
import android.graphics.Color
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@ -24,9 +23,9 @@ fun EmptyStateComposable(
|
||||
spec: EmptyStateSpec,
|
||||
modifier: Modifier = Modifier,
|
||||
) = EmptyStateComposable(
|
||||
modifier = spec.modifier(modifier),
|
||||
emojiText = spec.emojiText(),
|
||||
descriptionText = spec.descriptionText(),
|
||||
modifier = modifier
|
||||
)
|
||||
|
||||
@Composable
|
||||
@ -61,7 +60,12 @@ private fun EmptyStateComposable(
|
||||
@Composable
|
||||
fun EmptyStateComposableGenericErrorPreview() {
|
||||
AppTheme {
|
||||
EmptyStateComposable(EmptyStateSpec.GenericError)
|
||||
EmptyStateComposable(
|
||||
spec = EmptyStateSpec.GenericError,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 128.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,12 +73,16 @@ fun EmptyStateComposableGenericErrorPreview() {
|
||||
@Composable
|
||||
fun EmptyStateComposableNoCommentPreview() {
|
||||
AppTheme {
|
||||
EmptyStateComposable(EmptyStateSpec.NoComments)
|
||||
EmptyStateComposable(
|
||||
spec = EmptyStateSpec.NoComments,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 128.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class EmptyStateSpec(
|
||||
val modifier: (Modifier) -> Modifier,
|
||||
val emojiText: @Composable () -> String,
|
||||
val descriptionText: @Composable () -> String,
|
||||
) {
|
||||
@ -82,33 +90,19 @@ data class EmptyStateSpec(
|
||||
|
||||
val GenericError =
|
||||
EmptyStateSpec(
|
||||
modifier = {
|
||||
it
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 128.dp)
|
||||
},
|
||||
emojiText = { "¯\\_(ツ)_/¯" },
|
||||
descriptionText = { stringResource(id = R.string.empty_list_subtitle) },
|
||||
)
|
||||
|
||||
val NoVideos =
|
||||
EmptyStateSpec(
|
||||
modifier = {
|
||||
it
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 128.dp)
|
||||
},
|
||||
emojiText = { "(╯°-°)╯" },
|
||||
descriptionText = { stringResource(id = R.string.no_videos) },
|
||||
)
|
||||
|
||||
val NoComments =
|
||||
EmptyStateSpec(
|
||||
modifier = {
|
||||
it
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 128.dp)
|
||||
},
|
||||
|
||||
emojiText = { "¯\\_(╹x╹)_/¯" },
|
||||
descriptionText = { stringResource(id = R.string.no_comments) },
|
||||
)
|
||||
@ -120,33 +114,27 @@ data class EmptyStateSpec(
|
||||
|
||||
val NoSearchResult =
|
||||
NoComments.copy(
|
||||
modifier = { it },
|
||||
emojiText = { "╰(°●°╰)" },
|
||||
descriptionText = { stringResource(id = R.string.search_no_results) }
|
||||
)
|
||||
|
||||
val NoSearchMaxSizeResult =
|
||||
NoSearchResult.copy(
|
||||
modifier = { it.fillMaxSize() },
|
||||
)
|
||||
NoSearchResult
|
||||
|
||||
val ContentNotSupported =
|
||||
NoComments.copy(
|
||||
modifier = { it.padding(top = 90.dp) },
|
||||
emojiText = { "(︶︹︺)" },
|
||||
descriptionText = { stringResource(id = R.string.content_not_supported) },
|
||||
)
|
||||
|
||||
val NoBookmarkedPlaylist =
|
||||
EmptyStateSpec(
|
||||
modifier = { it },
|
||||
emojiText = { "(╥﹏╥)" },
|
||||
descriptionText = { stringResource(id = R.string.no_playlist_bookmarked_yet) },
|
||||
)
|
||||
|
||||
val NoSubscriptionsHint =
|
||||
EmptyStateSpec(
|
||||
modifier = { it },
|
||||
emojiText = { "(꩜ᯅ꩜)" },
|
||||
descriptionText = { stringResource(id = R.string.import_subscriptions_hint) },
|
||||
)
|
||||
|
||||
@ -2,12 +2,16 @@
|
||||
|
||||
package org.schabi.newpipe.ui.emptystate
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.contentColorFor
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.ComposeView
|
||||
import androidx.compose.ui.platform.ViewCompositionStrategy
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.schabi.newpipe.ui.theme.AppTheme
|
||||
|
||||
@JvmOverloads
|
||||
@ -22,7 +26,11 @@ fun ComposeView.setEmptyStateComposable(
|
||||
LocalContentColor provides contentColorFor(MaterialTheme.colorScheme.background)
|
||||
) {
|
||||
EmptyStateComposable(
|
||||
spec = spec
|
||||
spec = spec,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 128.dp)
|
||||
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
47
app/src/main/java/org/schabi/newpipe/util/GuardedByMutex.kt
Normal file
47
app/src/main/java/org/schabi/newpipe/util/GuardedByMutex.kt
Normal file
@ -0,0 +1,47 @@
|
||||
package org.schabi.newpipe.util
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
/** Guard the given data so that it can only be accessed by locking the mutex first.
|
||||
*
|
||||
* Inspired by [this blog post](https://jonnyzzz.com/blog/2017/03/01/guarded-by-lock/)
|
||||
* */
|
||||
class GuardedByMutex<T>(
|
||||
private var data: T,
|
||||
private val lock: Mutex = Mutex(locked = false),
|
||||
) {
|
||||
|
||||
/** Lock the mutex and access the data, blocking the current thread.
|
||||
* @param action to run with locked mutex
|
||||
* */
|
||||
fun <Y> runWithLockSync(
|
||||
action: MutexData<T>.() -> Y
|
||||
) =
|
||||
runBlocking {
|
||||
lock.withLock {
|
||||
MutexData(data, { d -> data = d }).action()
|
||||
}
|
||||
}
|
||||
|
||||
/** Lock the mutex and access the data, suspending the coroutine.
|
||||
* @param action to run with locked mutex
|
||||
* */
|
||||
suspend fun <Y> runWithLock(action: MutexData<T>.() -> Y) =
|
||||
lock.withLock {
|
||||
MutexData(data, { d -> data = d }).action()
|
||||
}
|
||||
}
|
||||
|
||||
/** The data inside a [GuardedByMutex], which can be accessed via [lockData].
|
||||
* [lockData] is a `var`, so you can `set` it as well.
|
||||
* */
|
||||
class MutexData<T>(data: T, val setFun: (T) -> Unit) {
|
||||
/** The data inside this [GuardedByMutex] */
|
||||
var lockData: T = data
|
||||
set(t) {
|
||||
setFun(t)
|
||||
field = t
|
||||
}
|
||||
}
|
||||
@ -11,13 +11,17 @@ import android.icu.text.CompactDecimalFormat;
|
||||
import android.os.Build;
|
||||
import android.text.TextUtils;
|
||||
import android.text.format.DateUtils;
|
||||
import android.text.BidiFormatter;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.annotation.PluralsRes;
|
||||
import androidx.annotation.StringRes;
|
||||
import androidx.appcompat.app.AppCompatDelegate;
|
||||
import androidx.core.math.MathUtils;
|
||||
import androidx.core.os.LocaleListCompat;
|
||||
import androidx.preference.PreferenceManager;
|
||||
|
||||
import org.ocpsoft.prettytime.PrettyTime;
|
||||
@ -39,6 +43,7 @@ import java.time.format.FormatStyle;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
|
||||
@ -63,6 +68,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 +86,25 @@ public final class Localization {
|
||||
.collect(Collectors.joining(delimiter));
|
||||
}
|
||||
|
||||
/**
|
||||
* Localize a user name like <code>@foobar</code>.
|
||||
*
|
||||
* Will correctly handle right-to-left usernames by using a {@link BidiFormatter}.
|
||||
*
|
||||
* @param plainName username, with an optional leading <code>@</code>
|
||||
* @return a usernames that can include RTL-characters
|
||||
*/
|
||||
@NonNull
|
||||
public static String localizeUserName(final String plainName) {
|
||||
final BidiFormatter bidi = BidiFormatter.getInstance();
|
||||
|
||||
if (plainName.startsWith("@")) {
|
||||
return "@" + bidi.unicodeWrap(plainName.substring(1));
|
||||
} else {
|
||||
return bidi.unicodeWrap(plainName);
|
||||
}
|
||||
}
|
||||
|
||||
public static org.schabi.newpipe.extractor.localization.Localization getPreferredLocalization(
|
||||
final Context context) {
|
||||
return org.schabi.newpipe.extractor.localization.Localization
|
||||
@ -101,6 +126,10 @@ public final class Localization {
|
||||
}
|
||||
|
||||
public static Locale getAppLocale(@NonNull final Context context) {
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
final Locale customLocale = AppCompatDelegate.getApplicationLocales().get(0);
|
||||
return Objects.requireNonNullElseGet(customLocale, Locale::getDefault);
|
||||
}
|
||||
return getLocaleFromPrefs(context, R.string.app_language_key);
|
||||
}
|
||||
|
||||
@ -303,7 +332,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
|
||||
@ -313,7 +342,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 {
|
||||
@ -422,4 +451,32 @@ public final class Localization {
|
||||
final int safeCount = (int) MathUtils.clamp(count, Integer.MIN_VALUE, Integer.MAX_VALUE);
|
||||
return context.getResources().getQuantityString(pluralId, safeCount, formattedCount);
|
||||
}
|
||||
|
||||
public static void migrateAppLanguageSettingIfNecessary(@NonNull final Context context) {
|
||||
// Starting with pull request #12093, NewPipe on Android 13+ exclusively uses Android's
|
||||
// public per-app language APIs to read and set the UI language for NewPipe.
|
||||
// If running on Android 13+, the following code will migrate any existing custom
|
||||
// app language in SharedPreferences to use the public per-app language APIs instead.
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
|
||||
final String appLanguageKey = context.getString(R.string.app_language_key);
|
||||
final String appLanguageValue = sp.getString(appLanguageKey, null);
|
||||
if (appLanguageValue != null) {
|
||||
sp.edit().remove(appLanguageKey).apply();
|
||||
final String appLanguageDefaultValue =
|
||||
context.getString(R.string.default_localization_key);
|
||||
if (!appLanguageValue.equals(appLanguageDefaultValue)) {
|
||||
try {
|
||||
AppCompatDelegate.setApplicationLocales(
|
||||
LocaleListCompat.forLanguageTags(appLanguageValue)
|
||||
);
|
||||
} catch (final RuntimeException e) {
|
||||
Log.e(TAG, "Failed to migrate previous custom app language "
|
||||
+ "setting to public per-app language APIs"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -98,6 +98,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>
|
||||
@ -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"
|
||||
|
||||
1
app/src/main/res/resources.properties
Normal file
1
app/src/main/res/resources.properties
Normal file
@ -0,0 +1 @@
|
||||
unqualifiedResLocale=en-US
|
||||
@ -854,6 +854,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>
|
||||
@ -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>
|
||||
@ -558,7 +558,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>
|
||||
@ -854,7 +854,6 @@
|
||||
<string name="share_playlist_content_details">%1$s
|
||||
\n%2$s</string>
|
||||
<string name="share_playlist">مشاركة قائمة التشغيل</string>
|
||||
<string name="share_playlist_with_titles_message">شارك تفاصيل قائمة التشغيل مثل اسم قائمة التشغيل وعناوين الفيديو أو كقائمة بسيطة من عناوين URL للفيديو</string>
|
||||
<string name="video_details_list_item">- %1$s: %2$s</string>
|
||||
<plurals name="replies">
|
||||
<item quantity="zero">رد %s</item>
|
||||
|
||||
@ -785,7 +785,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
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -207,7 +208,7 @@
|
||||
<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>
|
||||
@ -254,13 +255,13 @@
|
||||
<string name="play_queue_audio_settings">Налады аўдыя</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>
|
||||
@ -269,7 +270,7 @@
|
||||
<string name="rename_playlist">Перайменаваць</string>
|
||||
<string name="name">Імя</string>
|
||||
<string name="add_to_playlist">Дадаць у плэйліст</string>
|
||||
<string name="set_as_playlist_thumbnail">Усталяваць як мініяцюру плэйліста</string>
|
||||
<string name="set_as_playlist_thumbnail">Зрабіць мініяцюрай плэйліста</string>
|
||||
<string name="bookmark_playlist">Дадаць плэйліст у закладкі</string>
|
||||
<string name="unbookmark_playlist">Выдаліць закладку</string>
|
||||
<string name="delete_playlist_prompt">Выдаліць плэйліст\?</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>
|
||||
@ -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>
|
||||
@ -386,7 +387,7 @@
|
||||
<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="error_timeout">Скончыўся час злучэння</string>
|
||||
<string name="confirm_prompt">Вы хочаце ачысціць гісторыю спампоўвання ці выдаліць спампаваныя файлы?</string>
|
||||
<string name="enable_queue_limit">Абмежаваць чаргу спампоўвання</string>
|
||||
<string name="enable_queue_limit_desc">Толькі адно адначасовае спампоўванне</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>
|
||||
@ -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>
|
||||
@ -491,8 +492,8 @@
|
||||
<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,7 +504,7 @@
|
||||
<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="description_select_note">Цяпер можна вылучаць тэкст у апісанні. Звярніце ўвагу, што ў рэжыме вылучэння старонка можа мільгаць, а спасылкі не націскацца.</string>
|
||||
<string name="start_main_player_fullscreen_title">Запускаць галоўны прайгравальнік у поўнаэкранным рэжыме</string>
|
||||
<string name="show_channel_details">Паказаць дэталі канала</string>
|
||||
<string name="low_quality_smaller">Нізкая якасць (менш)</string>
|
||||
@ -562,8 +563,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>
|
||||
@ -603,8 +604,8 @@
|
||||
<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>
|
||||
@ -613,7 +614,7 @@
|
||||
<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="unset_playlist_thumbnail">Прыбраць пастаянную мініяцюру</string>
|
||||
<string name="show_image_indicators_title">Паказваць індыкатары выяў</string>
|
||||
<string name="show_image_indicators_summary">Паказваць каляровыя стужкі Пікаса на выявах, якія пазначаюць іх крыніцу: чырвоная для сеткі, сіняя для дыска і зялёная для памяці</string>
|
||||
<string name="feed_processing_message">Апрацоўка стужкі…</string>
|
||||
@ -624,7 +625,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>
|
||||
@ -645,12 +646,12 @@
|
||||
<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_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>
|
||||
@ -693,7 +694,7 @@
|
||||
<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
|
||||
@ -710,14 +711,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="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="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,9 +748,7 @@
|
||||
<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="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>
|
||||
@ -771,7 +770,7 @@
|
||||
<string name="next_stream">Наступны паток</string>
|
||||
<string name="disable_media_tunneling_automatic_info">Прадвызначана на вашай прыладзе адключана медыятунэляванне, бо гэтая мадэль прылады яго не падтрымлівае.</string>
|
||||
<string name="metadata_subchannel_avatars">Аватары падканалаў</string>
|
||||
<string name="open_play_queue">Адкрыйце чаргу прайгравання</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>
|
||||
@ -781,13 +780,12 @@
|
||||
<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="image_quality_medium">Сярэдняя якасць</string>
|
||||
<string name="metadata_uploader_avatars">Загрузнік аватараў</string>
|
||||
<string name="metadata_banners">Банеры</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="play">Прайграць</string>
|
||||
@ -813,7 +811,7 @@
|
||||
<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>
|
||||
|
||||
@ -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>
|
||||
@ -519,7 +519,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
|
||||
@ -557,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>
|
||||
@ -713,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>
|
||||
|
||||
@ -726,4 +726,11 @@
|
||||
<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>
|
||||
</resources>
|
||||
|
||||
@ -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>
|
||||
@ -783,7 +783,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>
|
||||
@ -804,7 +804,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>
|
||||
|
||||
@ -770,7 +770,6 @@
|
||||
<string name="image_quality_none">Indlæs ikke billeder</string>
|
||||
<string name="image_quality_low">Lav kvalitet</string>
|
||||
<string name="share_playlist">Del Playliste</string>
|
||||
<string name="share_playlist_with_titles_message">Del playliste med detajler såsom playlistenavn og videotitler eller som en simpel liste over video-URL\'er</string>
|
||||
<string name="share_playlist_with_titles">Del med Titler</string>
|
||||
<string name="share_playlist_with_list">Del URL-liste</string>
|
||||
<plurals name="replies">
|
||||
|
||||
@ -63,7 +63,7 @@
|
||||
<string name="error_snackbar_message">Entschuldigung, etwas ist schiefgelaufen.</string>
|
||||
<string name="your_comment">Dein Kommentar (auf englisch):</string>
|
||||
<string name="duration_live">Live</string>
|
||||
<string name="main_bg_subtitle">Tippe auf die Lupe, um zu beginnen.</string>
|
||||
<string name="main_bg_subtitle">Tippe auf die Lupe, um zu suchen.</string>
|
||||
<string name="downloads">Downloads</string>
|
||||
<string name="downloads_title">Downloads</string>
|
||||
<string name="error_report_title">Fehlerbericht</string>
|
||||
@ -425,7 +425,7 @@
|
||||
<string name="no_one_watching">Niemand schaut zu</string>
|
||||
<plurals name="watching">
|
||||
<item quantity="one">%s Zuschauer</item>
|
||||
<item quantity="other">%s Zuschauende</item>
|
||||
<item quantity="other">%s Zuschauer</item>
|
||||
</plurals>
|
||||
<string name="no_one_listening">Niemand hört zu</string>
|
||||
<plurals name="listening">
|
||||
@ -802,7 +802,6 @@
|
||||
<string name="share_playlist_content_details">%1$s
|
||||
\n%2$s</string>
|
||||
<string name="share_playlist">Wiedergabeliste teilen</string>
|
||||
<string name="share_playlist_with_titles_message">Teile die Wiedergabeliste mit Details wie dem Namen der Wiedergabeliste und den Videotiteln oder als einfache Liste von Video-URLs</string>
|
||||
<string name="video_details_list_item">- %1$s: %2$s</string>
|
||||
<plurals name="replies">
|
||||
<item quantity="one">%s Antwort</item>
|
||||
|
||||
@ -802,7 +802,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="one">%s απάντηση</item>
|
||||
|
||||
@ -616,7 +616,6 @@
|
||||
<string name="no_video_streams_available_for_external_players">Neniu filmofluo ludeblas por ekstera ludilo</string>
|
||||
<string name="channel_tab_videos">Filmetoj</string>
|
||||
<string name="remove_watched_popup_warning">Filmetoj kiuj spektiĝis antaŭ aŭ post sia aldoniĝo al la ludlisto foriĝus.. \nĈu vi certas? Ĉi tio nemalfareblus!</string>
|
||||
<string name="share_playlist_with_titles_message">Kunhavigus ludliston inkluzivante informojn kiel la nomoj de listeroj, aŭ kiel simpla listo de ligiloj</string>
|
||||
<string name="reset_settings_summary">Restarigi implicitajn agordojn</string>
|
||||
<string name="remove_watched_popup_yes_and_partially_watched_videos">Jes, kaj ankaŭ parte spektitajn filmetojn</string>
|
||||
</resources>
|
||||
|
||||
@ -816,7 +816,6 @@
|
||||
<string name="share_playlist_content_details">%1$s
|
||||
\n%2$s</string>
|
||||
<string name="share_playlist">Compartir la lista de reproducción</string>
|
||||
<string name="share_playlist_with_titles_message">Compartir las listas de reproducción con los detalles como el nombre de la lista y los títulos de los vídeos o como una simple lista de una dirección URL con los vídeos</string>
|
||||
<string name="video_details_list_item">- %1$s: %2$s</string>
|
||||
<plurals name="replies">
|
||||
<item quantity="one">%s respuesta</item>
|
||||
|
||||
@ -99,8 +99,8 @@
|
||||
<string name="always">Alati</string>
|
||||
<string name="just_once">Üks kord</string>
|
||||
<string name="file">Fail</string>
|
||||
<string name="notification_channel_name">NewPipe teavitus</string>
|
||||
<string name="notification_channel_description">Teavitused NewPipe pleierile</string>
|
||||
<string name="notification_channel_name">NewPipe\'i teavitus</string>
|
||||
<string name="notification_channel_description">NewPipe\'i meediaesitaja teavitused</string>
|
||||
<string name="unknown_content">[Tundmatu]</string>
|
||||
<string name="switch_to_background">Lülita taustale</string>
|
||||
<string name="switch_to_popup">Lülita hüpikpleierile</string>
|
||||
@ -183,7 +183,7 @@
|
||||
<string name="msg_name">Failinimi</string>
|
||||
<string name="msg_threads">Lõimed</string>
|
||||
<string name="msg_error">Viga</string>
|
||||
<string name="msg_running">NewPipe allalaadimine</string>
|
||||
<string name="msg_running">NewPipe\'i on allalaadimisel</string>
|
||||
<string name="msg_running_detail">Üksikasjade nägemiseks toksa</string>
|
||||
<string name="msg_wait">Palun oota…</string>
|
||||
<string name="msg_copied">Kopeeriti lõikepuhvrisse</string>
|
||||
@ -197,18 +197,18 @@
|
||||
<string name="settings_file_replacement_character_title">Asendustähemärk</string>
|
||||
<string name="charset_letters_and_digits">Tähed ja numbrid</string>
|
||||
<string name="charset_most_special_characters">Erimärgid</string>
|
||||
<string name="title_activity_about">NewPipe rakendusest</string>
|
||||
<string name="title_activity_about">Rakenduse teave: NewPipe</string>
|
||||
<string name="title_licenses">Kolmanda osapoole litsentsid</string>
|
||||
<string name="tab_about">Rakenduse teave ja KKK</string>
|
||||
<string name="tab_licenses">Litsentsid</string>
|
||||
<string name="contribution_title">Panusta</string>
|
||||
<string name="view_on_github">Vaata GitHubis</string>
|
||||
<string name="donation_title">Anneta</string>
|
||||
<string name="website_title">Veebileht</string>
|
||||
<string name="website_encouragement">Enama info saamiseks külasta NewPipe veebilehte.</string>
|
||||
<string name="privacy_policy_title">NewPipe privaatsuspoliitika</string>
|
||||
<string name="website_title">Veebisait</string>
|
||||
<string name="website_encouragement">Täiendava info ja uudiste lugemiseks külasta NewPipe\'i veebisaiti.</string>
|
||||
<string name="privacy_policy_title">NewPipe\'i privaatsuspoliitika</string>
|
||||
<string name="read_privacy_policy">Loe privaatsuspoliitikat</string>
|
||||
<string name="app_license_title">NewPipe litsents</string>
|
||||
<string name="app_license_title">NewPipe\'i litsents</string>
|
||||
<string name="read_full_license">Loe litsentsi</string>
|
||||
<string name="title_activity_history">Ajalugu</string>
|
||||
<string name="action_history">Ajalugu</string>
|
||||
@ -303,11 +303,10 @@
|
||||
<string name="copyright" formatted="true">© %1$s %2$s %3$s alla</string>
|
||||
<string name="app_description">Vaba ja lihtne voogesitus Androidis.</string>
|
||||
<string name="contribution_encouragement">Kui sul on ideid kujunduse muutmisest, koodi puhastamisest või suurtest koodi muudatustest - abi on alati teretulnud. Mida rohkem tehtud, seda paremaks läheb!</string>
|
||||
<string name="donation_encouragement">NewPipe arendajad on vabatahtlikud, kes kulutavad oma vaba aega, toomaks sulle parimat kasutamise kogemust. On aeg anda tagasi aidates arendajaid ja muuta NewPipe veel paremaks, nautides ise tassi kohvi.</string>
|
||||
<string name="donation_encouragement">NewPipe\'i arendajad on vabatahtlikud, kes kulutavad oma vaba aega, toomaks sulle parimat kasutuskogemust. On aeg anda tagasi aidates arendajaid ja muuta NewPipe veel paremaks, nautides ise tassi kohvi.</string>
|
||||
<string name="give_back">Anneta</string>
|
||||
<string name="privacy_policy_encouragement">NewPipe võtab privaatsust väga tõsiselt. Seetõttu ei kogu rakendus ilma nõusolekuta mingeid andmeid.
|
||||
\nNewPipe privaatsuspoliitika selgitab üksikasjalikult, milliseid andmeid saadetakse ja kogutakse veateate saatmisel.</string>
|
||||
<string name="app_license">NewPipe vaba avatud koodiga tarkvara. Seada võid kasutada, uurida, jagada ja parandada nii, nagu õigemaks pead. Täpsemalt - seda võid levitada ja/või muuta vastavalt Vaba Tarkvara Sihtasutuse avaldatud GNU Üldise Avaliku Litsentsi v.3 (või sinu valikul hilisema versiooni) tingimustele.</string>
|
||||
<string name="privacy_policy_encouragement">NewPipe võtab privaatsust väga tõsiselt. Seetõttu ei kogu rakendus ilma nõusolekuta mingeid andmeid. \nNewPipe\'i privaatsuspoliitika selgitab üksikasjalikult, milliseid andmeid saadetakse ja kogutakse veateate saatmisel.</string>
|
||||
<string name="app_license">NewPipe on vaba ja avatud lähtekoodiga tarkvara. Seada võid kasutada, uurida, jagada ja parandada nii, nagu õigemaks pead. Täpsemalt - seda võid levitada ja/või muuta vastavalt Vaba Tarkvara Sihtasutuse avaldatud GNU Üldise Avaliku Litsentsi v.3 (või sinu valikul hilisema versiooni) tingimustele.</string>
|
||||
<string name="enable_disposed_exceptions_title">Teavita elutsüklist väljas vigadest</string>
|
||||
<string name="import_soundcloud_instructions">Impordi SoundCloudi profiil trükkides URL või oma ID:
|
||||
\n
|
||||
@ -321,8 +320,7 @@
|
||||
<string name="skip_silence_checkbox">Keri helitu koht edasi</string>
|
||||
<string name="playback_step">Samm</string>
|
||||
<string name="playback_reset">Lähtesta</string>
|
||||
<string name="start_accept_privacy_policy">Selleks, et täita Euroopa Üldist Andmekaitse Määrust (GDPR), juhime tähelepanu NewPipe\'i privaatsuspoliitikale. Palun lugege seda hoolikalt.
|
||||
\nMeile veateate saatmiseks pead sellega nõustuma.</string>
|
||||
<string name="start_accept_privacy_policy">Selleks, et täita Euroopa Üldist Andmekaitse Määrust (GDPR), juhime tähelepanu NewPipe\'i privaatsuspoliitikale. Palun loe seda hoolikalt. \nMeile veateate saatmiseks pead sellega nõustuma.</string>
|
||||
<string name="minimize_on_exit_title">Minimeeri, kui kasutad teisi rakendusi</string>
|
||||
<string name="minimize_on_exit_summary">Tegevus lülitusel peamiselt videopleierilt teisele rakendusele — %s</string>
|
||||
<string name="minimize_on_exit_none_description">Pole</string>
|
||||
@ -335,7 +333,7 @@
|
||||
<string name="events">Sündmused</string>
|
||||
<string name="file_deleted">Fail kustutati</string>
|
||||
<string name="app_update_notification_channel_name">Rakenduse värskenduse teavitus</string>
|
||||
<string name="app_update_notification_channel_description">Teavitus NewPipe uuetest versioonidest</string>
|
||||
<string name="app_update_notification_channel_description">Teavitus NewPipe\'i uuetest versioonidest</string>
|
||||
<string name="download_to_sdcard_error_title">Väline andmekandja pole saadaval</string>
|
||||
<string name="download_to_sdcard_error_message">Allalaadimine välisele SD-kaardile ei ole võimalik. Kas lähtestada allalaadimiste kataloogi asukoht\?</string>
|
||||
<string name="saved_tabs_invalid_json">Tõrge salvestatud vahekaaride lugemisel; kasutatakse vaikeväärtusi</string>
|
||||
@ -350,7 +348,7 @@
|
||||
<string name="list">Nimekiri</string>
|
||||
<string name="grid">Võrgustik</string>
|
||||
<string name="auto">Auto</string>
|
||||
<string name="app_update_available_notification_title">NewPipe värskendus on saadaval!</string>
|
||||
<string name="app_update_available_notification_title">NewPipe\'i värskendus on saadaval!</string>
|
||||
<string name="missions_header_finished">Lõpetatud</string>
|
||||
<string name="missions_header_pending">Ootel</string>
|
||||
<string name="paused">peatatud</string>
|
||||
@ -453,7 +451,7 @@
|
||||
<item quantity="other">%s kuulajat</item>
|
||||
</plurals>
|
||||
<string name="hash_channel_description">Teavitused video räsimise edenemise kohta</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">Võta kasutusele YouTube\'i „Piiratud režiim“\\</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">Võta kasutusele YouTube\'i „Piiratud režiim“</string>
|
||||
<string name="missing_file">Faili asukoht on muutunud või on ta kustutatud</string>
|
||||
<string name="watch_history_states_deleted">Taasesituste asukohad on kustutatud</string>
|
||||
<string name="delete_playback_states_alert">Kas kustutame kõik taasesituste asukohad\?</string>
|
||||
@ -556,7 +554,7 @@
|
||||
<string name="enable_queue_limit_desc">Luba korraga vaid üks allalaadimine</string>
|
||||
<string name="enable_queue_limit">Piira allalaadimiste järjekorda</string>
|
||||
<string name="error_progress_lost">Faili kustutamisega läks ka tööjärg kautsi</string>
|
||||
<string name="error_postprocessing_stopped">Faili töötlemisel NewPipe lõpetas töö</string>
|
||||
<string name="error_postprocessing_stopped">NewPipe lõpetas faili töötlemisel töö</string>
|
||||
<string name="disable_media_tunneling_summary">Lülita meedia tunneldamine välja juhul, kui esitamisel tekib must ekraan või pildi kuvamine on katkendlik.</string>
|
||||
<string name="disable_media_tunneling_title">Lülita meedia tunneldamine välja</string>
|
||||
<string name="drawer_header_description">Vaheta teenust, hetkel on kasutusel:</string>
|
||||
@ -609,9 +607,7 @@
|
||||
<string name="feed_use_dedicated_fetch_method_enable_button">Luba kiire režiim</string>
|
||||
<string name="feed_use_dedicated_fetch_method_title">Hangi võimalusel spetsiaalsest voost</string>
|
||||
<string name="feed_load_error_fast_unknown">Kiirvoo režiim ei paku selle kohta täiendavat teavet.</string>
|
||||
<string name="feed_load_error_terminated">Autori konto on lõpetatud.
|
||||
\nTulevikus ei saa NewPipe seda voogu laadida.
|
||||
\nKas soovid tühistada selle kanali tellimuse\?</string>
|
||||
<string name="feed_load_error_terminated">Autori konto on suletud. \nTulevikus ei saa NewPipe seda meediavoogu laadida. \nKas soovid tühistada selle kanali tellimuse?</string>
|
||||
<string name="feed_load_error_account_info">Voo \'%s\' laadimine nurjus.</string>
|
||||
<string name="feed_load_error">Via voo laadimisel</string>
|
||||
<string name="feed_update_threshold_option_always_update">Värskenda alati</string>
|
||||
@ -622,17 +618,7 @@
|
||||
<string name="downloads_storage_use_saf_summary_api_29">Android 10st alates on toetatud ainult salvestusjuurdepääsu raamistik \'Storage Access Framework\'</string>
|
||||
<string name="downloads_storage_ask_summary_no_saf_notice">Sinult küsitakse iga kord, kuhu alla laadimine salvestada</string>
|
||||
<string name="detail_heart_img_view_description">Südamlik autor</string>
|
||||
<string name="feed_use_dedicated_fetch_method_help_text">Kas sinu meelest on voo laadimine aeglane\? Sel juhul proovi lubada kiire laadimine (seda saad muuta seadetes või vajutades allolevat nuppu).
|
||||
\n
|
||||
\nNewPipe pakub kahte voo laadimise strateegiat:
|
||||
\n• Tellitud kanali täielik, kuid aeglane hankimine.
|
||||
\n• Teenuse spetsiaalse lõpp-punkti kasutamine, mis on kiire, kuid tavaliselt mittetäielik.
|
||||
\n
|
||||
\nErinevus nende kahe vahel seisneb selles, et kiirel puudub tavaliselt teave, näiteks üksuse pikkus või tüüp (ei saa eristada reaalajas videoid tavalistest) ja see võib tagastada vähem üksusi.
|
||||
\n
|
||||
\nYouTube on näide teenusest, mis pakub seda kiirmeetodit oma RSS-vooga.
|
||||
\n
|
||||
\nNii et valik taandub sellele, mida eelistad: kiirus või täpne teave.</string>
|
||||
<string name="feed_use_dedicated_fetch_method_help_text">Kas sinu meelest on voo laadimine aeglane? Sel juhul proovi lubada kiire laadimine (seda saad muuta seadetes või vajutades allolevat nuppu). \n \nNewPipe pakub kahte voo laadimise strateegiat: \n• Tellitud kanali täielik, kuid aeglane hankimine. \n• Teenuse spetsiaalse otspunkti kasutamine, mis on kiire, kuid tavaliselt mittetäielik. \n \nErinevus nende kahe vahel seisneb selles, et kiirel puudub tavaliselt teave, näiteks üksuse pikkus või tüüp (ei saa eristada reaalajas videoid tavalistest) ja see võib tagastada vähem üksusi. \n \nYouTube on näide teenusest, mis pakub seda kiirmeetodit oma RSS-vooga. \n \nNii et valik taandub sellele, mida eelistad: kiirus või täpne teave.</string>
|
||||
<string name="mark_as_watched">Märgi vaadatuks</string>
|
||||
<string name="remote_search_suggestions">Kaugotsingu soovitused</string>
|
||||
<string name="local_search_suggestions">Kohaliku otsingu soovitused</string>
|
||||
@ -654,9 +640,9 @@
|
||||
<string name="check_for_updates">Kontrolli uuendusi</string>
|
||||
<string name="manual_update_description">Kontrolli uuendusi käsitsi</string>
|
||||
<string name="feed_new_items">Uued andmevoo kirjed</string>
|
||||
<string name="show_crash_the_player_title">Näita „Jooksuta meediamängija kokku“ nupukest\\</string>
|
||||
<string name="show_crash_the_player_title">Näita „Jooksuta meediamängija kokku“ nupukest</string>
|
||||
<string name="show_crash_the_player_summary">Näitab valikut meediamängija kokkujooksutamiseks</string>
|
||||
<string name="error_report_notification_title">NewPipe töös tekkis viga, sellest teavitamiseks toksa</string>
|
||||
<string name="error_report_notification_title">NewPipe\'i töös tekkis viga, sellest teavitamiseks toksa</string>
|
||||
<string name="crash_the_player">Jooksuta meediamängija kokku</string>
|
||||
<string name="show_error_snackbar">Näita veateate akent</string>
|
||||
<string name="error_report_channel_name">Teavitus vigadest</string>
|
||||
@ -802,7 +788,6 @@
|
||||
<string name="share_playlist_content_details">%1$s
|
||||
\n%2$s</string>
|
||||
<string name="share_playlist">Jaga esitusloendit</string>
|
||||
<string name="share_playlist_with_titles_message">Jaga esitusloendit kas väga detailse teabega palade kohta või lihtsa url\'ide loendina</string>
|
||||
<string name="video_details_list_item">- %1$s: %2$s</string>
|
||||
<string name="show_more">Näita veel</string>
|
||||
<plurals name="replies">
|
||||
@ -812,8 +797,7 @@
|
||||
<string name="show_less">Näita vähem</string>
|
||||
<string name="notification_actions_summary_android13">Muuda iga teavituse tegevust sellel toksates. Kolm esimest tegevust (esita/peata esitus, eelmine video, järgmine video) on süsteemsed ja neid ei saa muuta.</string>
|
||||
<string name="settings_category_backup_restore_title">Varundus ja taastamine</string>
|
||||
<string name="auto_update_check_description">NewPipe võib aeg-ajalt automaatselt kontrollida uute versioonide olemasolu ning sind vastavalt teavitada.
|
||||
\nKas sa soovid sellist võimalust kasuutada?</string>
|
||||
<string name="auto_update_check_description">NewPipe võib aeg-ajalt automaatselt kontrollida uute versioonide olemasolu ning sind vastavalt teavitada. \nKas sa soovid sellist võimalust kasutada?</string>
|
||||
<string name="reset_settings_title">Lähtesta seadistused</string>
|
||||
<string name="reset_settings_summary">Lähtesta kõik seadistused nende vaikimisi väärtusteks</string>
|
||||
<string name="error_insufficient_storage">Seadmes pole enam piisavalt vaba ruumi</string>
|
||||
@ -822,6 +806,6 @@
|
||||
\nKas sa soovid jätkata?</string>
|
||||
<string name="yes">Jah</string>
|
||||
<string name="no">Ei</string>
|
||||
<string name="import_settings_vulnerable_format">Imporditavad andmed kasutavad turvaprobleemidega vormingut, mida alates versioonist 0.27.0 NewPipe enam luua ei suuda. Palun kontrolli, et impordifail on loodud usaldusväärse osapoole poolt ning edaspidi loo ekspordifailid NewPipe versiooniga 0.27.0 või uuemaga. Tugi sellise vana vormingu kasutamisele kaob õige pea ja seejärel NewPipe uuemad ja vanemad versioonid ei saa omavahel andmeid enam vahetada.</string>
|
||||
<string name="import_settings_vulnerable_format">Imporditavad andmed kasutavad turvaprobleemidega vormingut, mida alates versioonist 0.27.0 NewPipe enam kasutada ei suuda. Palun kontrolli, et impordifail on loodud usaldusväärse osapoole poolt ning eelista ekspordifaile, mis on loodud NewPipe\'i versiooniga 0.27.0 või uuemaga. Tugi sellise vana vormingu kasutamisele kaob õige pea ja seejärel NewPipe\'i uuemad ja vanemad versioonid ei saa omavahel andmeid enam vahetada.</string>
|
||||
<string name="audio_track_type_secondary">täiendav</string>
|
||||
</resources>
|
||||
|
||||
@ -772,7 +772,6 @@
|
||||
<string name="notification_actions_summary_android13">Editatu beheko jakinarazpen ekintza bakoitza gainean sakatuz. Lehen hiru ekintzak (erreproduzitu/pausatu, aurrekoa eta hurrengoa) sistemarengatik ezarrita daude eta ezin dira pertsonalizatu.</string>
|
||||
<string name="rewind">Atzera egin</string>
|
||||
<string name="image_quality_title">Irudiaren kalitatea</string>
|
||||
<string name="share_playlist_with_titles_message">Partekatu erreprodukzio-zerrenda xehetasunekin, esate baterako, erreprodukzio-zerrendaren izena eta bideo-izenburuak edo bideo-URLen zerrenda soil gisa</string>
|
||||
<string name="more_options">Aukera gehiago</string>
|
||||
<string name="duration">Iraupena</string>
|
||||
<string name="forward">Aurrera egin</string>
|
||||
|
||||
@ -762,4 +762,6 @@
|
||||
<string name="duration">مدّت</string>
|
||||
<string name="rewind">پسروی</string>
|
||||
<string name="question_mark">؟</string>
|
||||
<string name="settings_category_backup_restore_title">پشتیبانگیری و بازیابی</string>
|
||||
<string name="no_live_streams">بدون جریان زنده</string>
|
||||
</resources>
|
||||
|
||||
@ -757,7 +757,6 @@
|
||||
<string name="rewind">Kelaa taaksepäin</string>
|
||||
<string name="feed_fetch_channel_tabs_summary">Noudettavat välilehdet syötettä päivitettäessä. Tällä valinnalla ei ole vaikutusta, jos kanava päivitetään käyttämällä nopeaa tilaa.</string>
|
||||
<string name="delete_downloaded_files_confirm">Poistetaanko kaikki ladatut tiedostot levyltä\?</string>
|
||||
<string name="share_playlist_with_titles_message">Jaa soittolista, jossa on tietoja, kuten soittolistan nimi ja videon nimi, tai yksinkertainen luettelo videoiden URL-osoitteista</string>
|
||||
<string name="image_quality_medium">Keskilaatu</string>
|
||||
<string name="metadata_uploader_avatars">Lataajan avatarit</string>
|
||||
<string name="percent">Prosentti</string>
|
||||
|
||||
@ -178,7 +178,7 @@
|
||||
<string name="play_queue_stream_detail">Détails</string>
|
||||
<string name="play_queue_audio_settings">Paramètres audios</string>
|
||||
<string name="show_hold_to_append_title">Afficher l\'astuce « Maintenir pour ajouter à la file »</string>
|
||||
<string name="show_hold_to_append_summary">Affiche l’astuce lors de l’appui des boutons « Arrière-plan » ou « Flottant » sur la page de détails d’une vidéo</string>
|
||||
<string name="show_hold_to_append_summary">Affiche l’astuce lors de l’appui des boutons « Arrière-plan » ou « Flottant » sur la page de détails d’une vidéo</string>
|
||||
<string name="unknown_content">[Inconnu]</string>
|
||||
<string name="player_recoverable_failure">Récupération depuis l’erreur du lecteur</string>
|
||||
<string name="kiosk_page_summary">Kiosque</string>
|
||||
@ -534,7 +534,7 @@
|
||||
<string name="channel_created_by">Créé par %s</string>
|
||||
<string name="show_original_time_ago_summary">Les textes originaux des services vont être visibles dans les items</string>
|
||||
<string name="show_original_time_ago_title">Afficher la date originelle sur les items</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">Activer le « Mode restreint » de YouTube</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">Activer le « Mode restreint » de YouTube</string>
|
||||
<string name="feed_group_show_only_ungrouped_subscriptions">Afficher uniquement les abonnements non groupés</string>
|
||||
<string name="playlist_page_summary">Page des listes de lecture</string>
|
||||
<string name="no_playlist_bookmarked_yet">Aucune liste de lecture encore enregistrée</string>
|
||||
@ -669,7 +669,7 @@
|
||||
<string name="check_for_updates">Vérifier les mises à jour</string>
|
||||
<string name="feed_new_items">Nouveaux éléments du flux</string>
|
||||
<string name="crash_the_player">Faire planter le lecteur</string>
|
||||
<string name="show_crash_the_player_title">Afficher « Faire planter le lecteur »</string>
|
||||
<string name="show_crash_the_player_title">Afficher « Faire planter le lecteur »</string>
|
||||
<string name="show_crash_the_player_summary">Montrer une option de plantage lors de l\'utilisation du lecteur</string>
|
||||
<string name="error_report_channel_name">Notification de rapport d\'erreur</string>
|
||||
<string name="error_report_channel_description">Notifications pour signaler les erreurs</string>
|
||||
@ -815,7 +815,6 @@
|
||||
<string name="forward">Avancer</string>
|
||||
<string name="rewind">Rembobiner</string>
|
||||
<string name="replay">Rejouer</string>
|
||||
<string name="share_playlist_with_titles_message">Partager la liste de lecture avec des détails tel que son nom et le titre de ses vidéos ou simplement la liste des URLs des vidéos</string>
|
||||
<string name="metadata_uploader_avatars">Avatars du téléverseur</string>
|
||||
<string name="image_quality_summary">Sélectionnez la qualité des images et si les images doivent être chargées, pour réduire l\'utilisation de la mémoire et de données. Les modifications vident à la fois le cache des images en mémoire et sur le disque — %s</string>
|
||||
<string name="play">Lire</string>
|
||||
@ -840,4 +839,4 @@
|
||||
<string name="error_insufficient_storage">Pas assez d\'espace disponible sur l\'appareil</string>
|
||||
<string name="import_settings_vulnerable_format">Les paramètres de l\'export en cours d\'importation utilisent un format vulnérable qui a été déprécié depuis NewPipe 0.27.0. Assurez-vous que l\'export en cours d\'importation provient d\'une source fiable. Privilégiez les exports obtenues à partir de NewPipe 0.27.0 ou des versions plus récentes à l\'avenir. Le support pour l\'importation des paramètres dans ce format vulnérable sera bientôt complètement supprimé et les anciennes versions de NewPipe ne pourront plus importer les paramètres des exports des nouvelles versions.</string>
|
||||
<string name="audio_track_type_secondary">secondaire</string>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@ -813,7 +813,6 @@
|
||||
<string name="metadata_banners">Encabezados</string>
|
||||
<string name="show_channel_tabs_summary">Lapelas a mostrar nas páxinas das canles</string>
|
||||
<string name="image_quality_summary">Escolla da calidade das imaxes e se cargar as imaxes na súa totalidade, para reducir o uso de datos e memoria. Os cambios limpan a caché das imaxes na memoria e no disco - %s</string>
|
||||
<string name="share_playlist_with_titles_message">Compartir a lista de reprodución con detalles como o nome da lista e os títulos dos videos ou como unha lista sinxela cos enlaces URL dos videos</string>
|
||||
<string name="share_playlist_with_list">Compartir lista de URLs</string>
|
||||
<string name="import_settings_vulnerable_format">A configuración da exportación a ser importada emprega un formato vulnerable que fica obsoleto dende NewPipe 0.27.0. Comprobe que a exportación que está a importar proveña dunha fonte fiable e preferibelmente empregue exportacións de NewPipe 0.27.0 ou posterior. A compatibilidade coa importación deste formato vulnerable será eliminada por completo próximamente e as versión antigas de NewPipe non poderán importar configuracións de exportacións dende novas versións.</string>
|
||||
<string name="channel_tab_tracks">Pistas</string>
|
||||
|
||||
@ -23,7 +23,7 @@
|
||||
<string name="notification_action_0_title">પ્રથમ ક્રિયા બટન</string>
|
||||
<string name="notification_scale_to_square_image_summary">સૂચનામાં બતાવેલ વિડિઓ થંબનેલને ૧૬:૯ થી ૧:૧ સાપેક્ષ ગુણોત્તરમાં કાપો</string>
|
||||
<string name="notification_scale_to_square_image_title">થંબનેલને ૧:૧ સાપેક્ષ ગુણોત્તરમાં કાપો</string>
|
||||
<string name="show_play_with_kodi_summary">કોડી મીડિયા સેન્ટર દ્વારા વિડિઓ ચલાવવાનો વિકલ્પ દર્શાવો</string>
|
||||
<string name="show_play_with_kodi_summary">કોડિ મીડિયા સેન્ટર દ્વારા વિડિઓ ચલાવવાનો વિકલ્પ દર્શાવો</string>
|
||||
<string name="kore_not_found">અનુપસ્થિત Kore અનુપ્રયોગ સ્થાપિત કરીએ?</string>
|
||||
<string name="show_higher_resolutions_summary">ફક્ત થોડા ઉપકરણો 2K / 4K વિડિઓઝ ચલાવી શકે છે</string>
|
||||
<string name="show_higher_resolutions_title">ઉચ્ચ રીઝોલ્યુશન બતાવો</string>
|
||||
@ -40,7 +40,7 @@
|
||||
<string name="controls_background_title">પૃષ્ઠભૂમિ</string>
|
||||
<string name="tab_choose">ટેબ પસંદ કરો</string>
|
||||
<string name="tab_bookmarks">બુકમાર્ક કરેલ પ્લેલિસ્ટ્સ</string>
|
||||
<string name="tab_subscriptions">સબ્સ્ક્રિપ્શન્સ</string>
|
||||
<string name="tab_subscriptions">લવાજમઓ</string>
|
||||
<string name="show_info">માહિતી બતાવો</string>
|
||||
<string name="subscription_update_failed">સબ્સ્ક્રિપ્શન અપડેટ કરી શકાયું નથી</string>
|
||||
<string name="subscription_change_failed">સબ્સ્ક્રિપ્શન બદલી શકાયું નહીં</string>
|
||||
@ -72,13 +72,15 @@
|
||||
<string name="ok">ઠીક છે</string>
|
||||
<string name="yes">હા</string>
|
||||
<string name="no">ના</string>
|
||||
<string name="trending">વલણમાં છે</string>
|
||||
<string name="trending">વલણમાંનાં</string>
|
||||
<string name="auto_queue_toggle">આપોઆપ કતારબદ્ધતા</string>
|
||||
<string name="crash_the_player">પ્લેયરને ક્રેશ કરો</string>
|
||||
<string name="action_history">ઇતિહાસ</string>
|
||||
<string name="play_with_kodi_title">કોટિથી ચલાવો</string>
|
||||
<string name="show_play_with_kodi_title">કોટિથી ચલાવવાનો વિકલ્પ દેખાટો</string>
|
||||
<string name="show_play_with_kodi_title">કોડિથી ચલાવવાનો વિકલ્પ દેખાડો</string>
|
||||
<string name="download_dialog_title">ડાઉનલોડ કરો</string>
|
||||
<string name="autoplay_title">આપમેળે ચલાવો</string>
|
||||
<string name="fragment_feed_title">નવું શું છે</string>
|
||||
</resources>
|
||||
<string name="downloads">ડાઉનલોડ્સ</string>
|
||||
<string name="downloads_title">ડાઉનલોડ્સ</string>
|
||||
</resources>
|
||||
|
||||
@ -828,7 +828,6 @@
|
||||
<string name="share_playlist_content_details">%1$s
|
||||
\n%2$s</string>
|
||||
<string name="share_playlist">שיתוף רשימת נגינה</string>
|
||||
<string name="share_playlist_with_titles_message">שיתוף רשימת נגינה עם פרטים כגון שם רשימת נגינה וכותרות סרטונים או כרשימה פשוטה של כתובות סרטונים</string>
|
||||
<string name="video_details_list_item">- %1$s: %2$s</string>
|
||||
<string name="show_more">להציג עוד</string>
|
||||
<string name="show_less">להציג פחות</string>
|
||||
@ -850,4 +849,5 @@
|
||||
\nלהמשיך?</string>
|
||||
<string name="error_insufficient_storage">אין מספיק מקום פנוי במכשיר</string>
|
||||
<string name="import_settings_vulnerable_format">ההגדרות בייצוא המיובא משתמשות בתסדיר פגיע שהוצא משימוש מאז NewPipe 0.27.0. יש לוודא שהייצוא המיובא הוא ממקור מהימן, ועדיף להשתמש רק בייצוא שהושג מ־NewPipe 0.27.0 ומעלה בעתיד. תמיכה בייבוא הגדרות בתסדיר פגיע זה תוסר בקרוב לחלוטין, ואז גרסאות ישנות של NewPipe לא יוכלו לייבא עוד הגדרות של ייצוא מגרסאות חדשות.</string>
|
||||
<string name="audio_track_type_secondary">משני</string>
|
||||
</resources>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="upload_date_text">%1$s पे प्रकाशित हुआ</string>
|
||||
<string name="no_player_found">स्ट्रीमिंग के लिए प्लेयर नहीं मिला। क्या आप वीएलसी इंस्टॉल करना चाहेंगे\?</string>
|
||||
<string name="no_player_found">स्ट्रीमिंग के लिए प्लेयर नहीं मिला। क्या आप VLC इंस्टॉल करना चाहेंगे?</string>
|
||||
<string name="install">इंस्टॉल करें</string>
|
||||
<string name="open_in_browser">ब्राउज़र में खोलें</string>
|
||||
<string name="open_in_popup_mode">पॉपअप मोड में खोलें</string>
|
||||
@ -183,7 +183,7 @@
|
||||
<string name="hold_to_append">कतार में जोड़ने के लिए दबाकर रखें</string>
|
||||
<string name="start_here_on_background">बैकग्राउंड में चलाना शुरू करें</string>
|
||||
<string name="start_here_on_popup">पॉपअप में चलाना शुरू करें</string>
|
||||
<string name="no_player_found_toast">स्ट्रीमिंग करने के लिए प्लेयर नहीं मिला (आप इसे चलाने के लिए वीएलसी प्लेयर इंस्टॉल कर सकते हैं)।</string>
|
||||
<string name="no_player_found_toast">स्ट्रीमिंग करने के लिए प्लेयर नहीं मिला (आप इसे चलाने के लिए VLC प्लेयर इंस्टॉल कर सकते हैं)।</string>
|
||||
<string name="controls_download_desc">स्ट्रीम फाइल डाउनलोड करें</string>
|
||||
<string name="show_info">जानकारी दिखाएं</string>
|
||||
<string name="tab_bookmarks">बुकमार्क की गई प्लेलिस्टें</string>
|
||||
@ -802,7 +802,6 @@
|
||||
<string name="share_playlist_content_details">%1$s
|
||||
\n%2$s</string>
|
||||
<string name="share_playlist">प्लेलिस्ट साझा करें</string>
|
||||
<string name="share_playlist_with_titles_message">प्लेलिस्ट को प्लेलिस्ट नाम और वीडियो शीर्षक जैसे विवरण के साथ या वीडियो यूआरएल की एक सरल सूची के रूप में साझा करें</string>
|
||||
<string name="video_details_list_item">- %1$s: %2$s</string>
|
||||
<plurals name="replies">
|
||||
<item quantity="one">%s जवाब</item>
|
||||
|
||||
@ -424,8 +424,8 @@
|
||||
<string name="feed_group_dialog_delete_message">Želiš li izbrisati ovu grupu\?</string>
|
||||
<string name="feed_create_new_group_button_title">Nova</string>
|
||||
<string name="feed_update_threshold_option_always_update">Uvijek aktualiziraj</string>
|
||||
<string name="feed_use_dedicated_fetch_method_enable_button">Uključi brzi način</string>
|
||||
<string name="feed_use_dedicated_fetch_method_disable_button">Isključi brzi način</string>
|
||||
<string name="feed_use_dedicated_fetch_method_enable_button">Uključi brzi modus</string>
|
||||
<string name="feed_use_dedicated_fetch_method_disable_button">Isključi brzi modus</string>
|
||||
<string name="error_insufficient_storage_left">Memorija uređaja je popunjena</string>
|
||||
<string name="most_liked">Najomiljeniji</string>
|
||||
<string name="subtitle_activity_recaptcha">Pritisni „Gotovo” kad je riješeno</string>
|
||||
@ -639,7 +639,7 @@
|
||||
<string name="no_dir_yet">Mapa za preuzimanje još nije postavljena, odaberi standardnu mapu za preuzimanje</string>
|
||||
<string name="comments_are_disabled">Komentari su isključeni</string>
|
||||
<string name="mark_as_watched">Označi kao pogledano</string>
|
||||
<string name="feed_load_error_fast_unknown">Način rada brzog feeda ne pruža više informacija o ovome.</string>
|
||||
<string name="feed_load_error_fast_unknown">Brzi modus feeda ne pruža više informacija o ovome.</string>
|
||||
<string name="metadata_privacy_internal">Interno</string>
|
||||
<string name="metadata_privacy">Privatnost</string>
|
||||
<string name="description_select_note">Sada možeš odabrati tekst u opisu. Napomena: stranica će možda treperiti i možda nećeš moći kliknuti poveznice u načinu rada za odabir teksta.</string>
|
||||
@ -798,7 +798,6 @@
|
||||
<string name="image_quality_medium">Srednja kvaliteta</string>
|
||||
<string name="image_quality_high">Visoka kvaliteta</string>
|
||||
<string name="question_mark">\?</string>
|
||||
<string name="share_playlist_with_titles_message">Dijeli playlistu s detaljima kao što su ime playliste i naslovi videa ili kao jednostavan popis URL-ova videa</string>
|
||||
<string name="share_playlist_with_titles">Dijeli s naslovima</string>
|
||||
<string name="share_playlist_with_list">Dijeli popis URL-ova</string>
|
||||
<string name="video_details_list_item">– %1$s: %2$s</string>
|
||||
@ -831,4 +830,7 @@
|
||||
<string name="reset_all_settings">Obnavljanje svih postavki odbacit će sve tvoje postavljene postavke i aplikacija će se ponovo pokrenuti.
|
||||
\n
|
||||
\nStvarno želiš nastaviti?</string>
|
||||
<string name="always_use_exoplayer_set_output_surface_workaround_title">Uvijek koristi ExoPlayer postavku zaobilaženja videa za izlaznu površinu</string>
|
||||
<string name="feed_fetch_channel_tabs_summary">Kartice za dohvaćanje prilikom aktualiziranja feeda. Ova opcija nema učinka ako se kanal aktualizira pomoću brzog modusa.</string>
|
||||
<string name="audio_track_type_secondary">sekundarno</string>
|
||||
</resources>
|
||||
|
||||
@ -324,7 +324,7 @@
|
||||
<string name="app_update_notification_channel_name">Alkalmazásfrissítés értesítése</string>
|
||||
<string name="file_deleted">Fájl törölve</string>
|
||||
<string name="settings_category_updates_title">Frissítések</string>
|
||||
<string name="show_hold_to_append_summary">Tipp megjelenítése a háttér vagy a felugró gomb megnyomásakor a videó „Részletek:\\” lehetőségnél</string>
|
||||
<string name="show_hold_to_append_summary">Tipp megjelenítése a háttér vagy a felugró gomb megnyomásakor a videó „Részletek:” lehetőségnél</string>
|
||||
<string name="autoplay_title">Automatikus lejátszás</string>
|
||||
<string name="settings_category_clear_data_title">Adatok törlése</string>
|
||||
<string name="enable_playback_state_lists_summary">Lejátszási pozíciók megjelenítése a listákban</string>
|
||||
@ -534,7 +534,7 @@
|
||||
<string name="hash_channel_description">Értesítések a videók ujjlenyomatkészítési folyamatához</string>
|
||||
<string name="hash_channel_name">Videó ujjlenyomat-készítési értesítése</string>
|
||||
<string name="youtube_restricted_mode_enabled_summary">A YouTube biztosít egy „Korlátozott módot”, amely elrejti a lehetséges felnőtteknek szóló tartalmat</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">A YouTube „Korlátozott mód\\” bekapcsolása</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">A YouTube „Korlátozott mód” bekapcsolása</string>
|
||||
<string name="peertube_instance_add_exists">A példány már létezik</string>
|
||||
<string name="peertube_instance_add_fail">A példány érvényesítése nem sikerült</string>
|
||||
<string name="peertube_instance_add_help">Adja meg a példány webcímét</string>
|
||||
@ -657,7 +657,7 @@
|
||||
<string name="show_original_time_ago_summary">A szolgáltatásokból származó eredeti szövegek láthatók lesznek a közvetítési elemeken</string>
|
||||
<string name="crash_the_player">Lejátszó összeomlasztása</string>
|
||||
<string name="show_image_indicators_title">Képjelölők megjelenítése</string>
|
||||
<string name="show_crash_the_player_title">A „lejátszó összeomlasztása\\” lehetőség megjelenítése</string>
|
||||
<string name="show_crash_the_player_title">A „Lejátszó összeomlasztása” lehetőség megjelenítése</string>
|
||||
<string name="show_crash_the_player_summary">Megjeleníti az összeomlasztási lehetőséget a lejátszó használatakor</string>
|
||||
<string name="unhook_checkbox">Hangmagasság megtartása (torzítást okozhat)</string>
|
||||
<string name="check_for_updates">Frissítések keresése</string>
|
||||
@ -795,7 +795,6 @@
|
||||
<string name="image_quality_high">Magas minőségű</string>
|
||||
<string name="question_mark">\?</string>
|
||||
<string name="share_playlist">Lejátszási lista megosztása</string>
|
||||
<string name="share_playlist_with_titles_message">Lejátszási lista megosztása olyan részletekkel, mint például a lejátszási lista neve és a videó címe, vagy a videó webcímek egyszerű listájaként</string>
|
||||
<string name="share_playlist_with_titles">Megosztás címekkel</string>
|
||||
<string name="share_playlist_content_details">%1$s
|
||||
\n%2$s</string>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="main_bg_subtitle">Սեղմեք որոնման կոճակը որ սկսել</string>
|
||||
<string name="main_bg_subtitle">Սեղմեք խոշորացույցը որ սկսեք</string>
|
||||
<string name="search">Որոնել</string>
|
||||
<string name="downloads">Բեռնված</string>
|
||||
<string name="downloads_title">Բեռնված</string>
|
||||
@ -228,7 +228,7 @@
|
||||
<string name="sort">Դասավորել</string>
|
||||
<string name="detail_pinned_comment_view_description">Գամված մեկնաբանություն</string>
|
||||
<string name="account_terminated">Հաշիվը կասեցված է</string>
|
||||
<string name="channel_tab_about"></string>
|
||||
<string name="channel_tab_about"/>
|
||||
<string name="channel_tab_albums">Ալբոմներ</string>
|
||||
<string name="yes">Այո</string>
|
||||
<string name="no">Ոչ</string>
|
||||
@ -241,4 +241,6 @@
|
||||
<string name="channel_tab_channels">Ալիքներ</string>
|
||||
<string name="channel_tab_livestreams">Ուղիղ</string>
|
||||
<string name="unknown_audio_track">Անհայտ</string>
|
||||
</resources>
|
||||
<string name="did_you_mean">Նկատի ունե՞ս «%1$s»</string>
|
||||
<string name="volume">Բարձրություն</string>
|
||||
</resources>
|
||||
|
||||
@ -138,7 +138,7 @@
|
||||
<string name="auto_queue_summary">Melanjutkan akhir dari antrean pemutaran (tak berulang) dengan menambahkan video terkait</string>
|
||||
<string name="enable_watch_history_summary">Simpan daftar video yang telah ditonton</string>
|
||||
<string name="show_hold_to_append_title">Tip \"Tahan untuk menambahkan\"</string>
|
||||
<string name="show_hold_to_append_summary">Tampilkan tip ketika menekan tombol latar belakang atau popup di dalam video \"Detail:\\</string>
|
||||
<string name="show_hold_to_append_summary">Tampilkan tip ketika menekan tombol latar belakang atau popup di dalam video \"Detail:\"</string>
|
||||
<string name="default_content_country_title">Lokasi Konten</string>
|
||||
<string name="settings_category_player_title">Pemutar</string>
|
||||
<string name="settings_category_player_behavior_title">Perilaku</string>
|
||||
@ -508,7 +508,7 @@
|
||||
\nJadi pilihlah yang sesuai yang Anda inginkan: kecepatan atau kelengkapan informasi.</string>
|
||||
<string name="show_original_time_ago_summary">Teks asli dari layanan akan ditampilkan di dalam video</string>
|
||||
<string name="show_original_time_ago_title">Tampilkan waktu yang lalu sebenarnya pada item</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">Aktifkan \"Mode Terbatas\\</string>
|
||||
<string name="youtube_restricted_mode_enabled_title">Aktifkan \"Mode Terbatas\"</string>
|
||||
<string name="video_detail_by">Oleh %s</string>
|
||||
<string name="channel_created_by">Dibuat oleh %s</string>
|
||||
<string name="detail_sub_channel_thumbnail_view_description">Thumbnail avatar channel</string>
|
||||
@ -788,7 +788,6 @@
|
||||
<string name="share_playlist_content_details">%1$s
|
||||
\n%2$s</string>
|
||||
<string name="share_playlist">Bagikan Daftar Putar</string>
|
||||
<string name="share_playlist_with_titles_message">Bagikan daftar putar dengan detail seperti nama daftar putar dan judul video atau sebagai daftar video URL yang sederhana</string>
|
||||
<string name="metadata_banners">Panji</string>
|
||||
<string name="video_details_list_item">- %1$s: %2$s</string>
|
||||
<string name="notification_actions_summary_android13">Sentuh untuk menyunting tindakan notifikasi di bawah. Tiga tindakan pertama (mainkan/jeda, sebelumnya dan selanjutnya) disetel oleh sistem dan tidak bisa dikustomisasi.</string>
|
||||
|
||||
@ -607,7 +607,7 @@
|
||||
<string name="enable_playback_state_lists_title">Spilunarstöður í listum</string>
|
||||
<string name="enable_playback_state_lists_summary">Sýna spilunarstöður í listum</string>
|
||||
<string name="show_hold_to_append_title">Sýna ábendinguna „Haltu niðri til að bæta við spilunarröð“</string>
|
||||
<string name="show_hold_to_append_summary">Sýna ábendingu þegar ýtt er á bakgrunninn eða sprettihnappinn á myndskeiðinu í „Nánar:\\</string>
|
||||
<string name="show_hold_to_append_summary">Sýna ábendingu þegar ýtt er á bakgrunninn eða sprettihnappinn á myndskeiðinu í „Nánar:\"</string>
|
||||
<string name="unsupported_url_dialog_message">Óþekkt slóð. Opna með öðru forriti\?</string>
|
||||
<string name="peertube_instance_url_summary">Veldu uppáhalds PeerTube tilvik þín</string>
|
||||
<string name="peertube_instance_url_help">Þú mátt finna tilviki á %s</string>
|
||||
@ -790,7 +790,6 @@
|
||||
<string name="unset_playlist_thumbnail">Losa varanlega smámynd</string>
|
||||
<string name="notification_actions_summary_android13">Breyttu hverri tilkynningu hér fyrir neðan með því að ýta á hana. Fyrstu þrjár aðgerðirnar (spila/bíða, fyrra og næsta) eru skilgreindar af kerfinu og er því ekki hægt að sérsníða.</string>
|
||||
<string name="feed_fetch_channel_tabs_summary">Flipar sem á að sækja við uppfærslu þessa streymis. Þetta hefur engin áhrif ef rás er uppfærð með hraðstreymisham.</string>
|
||||
<string name="share_playlist_with_titles_message">Deildu spilunarlista með atriðum eins og heiti spilunarlistans og titlum myndskeiða eða sem einföldum lista yfir slóðir á myndskeið</string>
|
||||
<string name="use_exoplayer_decoder_fallback_title">Nota varaeiginleika ExoPlayer-afkóðarans</string>
|
||||
<string name="new_seek_duration_toast">Vegna takmarkana í ExoPlayer-spilaranum var tímalengd hoppa sett á %d sekúndur</string>
|
||||
<string name="disable_media_tunneling_automatic_info">Margmiðlunargöng (media tunneling) voru gerð óvirk á tækinu þínu þar sem þessi gerð tækja er þekkt fyrir að styðja ekki þennan eiginleika.</string>
|
||||
|
||||
@ -815,7 +815,6 @@
|
||||
<string name="share_playlist_content_details">%1$s
|
||||
\n%2$s</string>
|
||||
<string name="share_playlist">Condividi playlist</string>
|
||||
<string name="share_playlist_with_titles_message">Condividi la playlist con dettagli come il suo nome e i titoli video o come un semplice elenco di URL video</string>
|
||||
<string name="video_details_list_item">- %1$s: %2$s</string>
|
||||
<plurals name="replies">
|
||||
<item quantity="one">%s risposta</item>
|
||||
|
||||
@ -789,7 +789,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="other">%sの返信</item>
|
||||
|
||||
@ -774,7 +774,6 @@
|
||||
<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="image_quality_medium">중간 품질</string>
|
||||
<string name="metadata_uploader_avatars">업로더 아바타</string>
|
||||
<string name="metadata_banners">배너</string>
|
||||
|
||||
@ -823,7 +823,6 @@
|
||||
<string name="channel_tab_videos">Vaizdo įrašai</string>
|
||||
<string name="channel_tab_tracks">Takeliai</string>
|
||||
<string name="image_quality_summary">Pasirinkite paveikslėlių kokybę ir ar apskritai įkelti paveikslėlius, kad sumažintumėte duomenų ir atminties naudojimą. Pakeitimai išvalo atmintyje ir diske esančių vaizdų talpyklą - %s</string>
|
||||
<string name="share_playlist_with_titles_message">Dalintis grojaraščiu su tokia informacija kaip grojaraščio pavadinimas ir vaizdo įrašo pavadinimas arba paprastas vaizdo įrašų nuorodų sąrašas</string>
|
||||
<string name="share_playlist_with_titles">Dalintis su pavadinimais</string>
|
||||
<string name="share_playlist">Dalintis grojaraščiu</string>
|
||||
<string name="share_playlist_with_list">Dalintis nuorodų sąrašu</string>
|
||||
|
||||
@ -822,7 +822,6 @@
|
||||
<string name="audio_track_name">%1$s %2$s</string>
|
||||
<string name="channel_tab_tracks">Skaņdarbi</string>
|
||||
<string name="channel_tab_shorts">Īsie video</string>
|
||||
<string name="share_playlist_with_titles_message">Kopīgot atskaņošanas saraksta nosaukumu un to video nosaukumus vai tikai atskaņošanas sarakstā iekļauto video URL saites</string>
|
||||
<string name="share_playlist">Kopīgot atskaņošanas sarakstu</string>
|
||||
<string name="share_playlist_with_titles">Kopīgot nosaukumus</string>
|
||||
<string name="import_settings_vulnerable_format">Importētā eksporta iestatījumi izmanto ievainojamo formātu, kas tika pārtraukts kopš NewPipe 0.27.0 versijas. Pārliecinieties, ka importētie dati ir no uzticama avota, un turpmāk ir vēlams izmantot tikai datus, kas veikti NewPipe 0.27.0 vai jaunākās versijās. Iestatījumu importēšanas atbalsts šajā neaizsargātajā formātā drīzumā tiks pilnībā aizvākts, un tad vecās NewPipe versijas vairs nevarēs importēt iestatījumus, kas veikti jaunajās versijās.</string>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user