Merge pull request #2335 from nv95/feature/notifications

New streams notifications
This commit is contained in:
Stypox 2022-03-20 10:48:48 +01:00 committed by GitHub
commit 2623f0e360
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
113 changed files with 2465 additions and 396 deletions

View File

@ -102,6 +102,7 @@ ext {
androidxLifecycleVersion = '2.3.1'
androidxRoomVersion = '2.4.2'
androidxWorkVersion = '2.7.1'
icepickVersion = '3.2.0'
exoPlayerVersion = '2.14.2'
@ -220,8 +221,10 @@ dependencies {
// https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation 'androidx.webkit:webkit:1.4.0'
implementation 'androidx.work:work-runtime:2.7.1'
implementation 'com.google.android.material:material:1.5.0'
implementation "androidx.work:work-runtime:${androidxWorkVersion}"
implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}"
implementation "androidx.work:work-rxjava3:${androidxWorkVersion}"
/** Third-party libraries **/
// Instance state boilerplate elimination

View File

@ -0,0 +1,719 @@
{
"formatVersion": 1,
"database": {
"version": 5,
"identityHash": "096731b513bb71dd44517639f4a2c1e3",
"entities": [
{
"tableName": "subscriptions",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "service_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "avatarUrl",
"columnName": "avatar_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "subscriberCount",
"columnName": "subscriber_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "description",
"columnName": "description",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "notificationMode",
"columnName": "notification_mode",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"uid"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_subscriptions_service_id_url",
"unique": true,
"columnNames": [
"service_id",
"url"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
}
],
"foreignKeys": []
},
{
"tableName": "search_history",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "creationDate",
"columnName": "creation_date",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "serviceId",
"columnName": "service_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "search",
"columnName": "search",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_search_history_search",
"unique": false,
"columnNames": [
"search"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)"
}
],
"foreignKeys": []
},
{
"tableName": "streams",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "service_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "streamType",
"columnName": "stream_type",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "duration",
"columnName": "duration",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "uploaderUrl",
"columnName": "uploader_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnail_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "viewCount",
"columnName": "view_count",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "textualUploadDate",
"columnName": "textual_upload_date",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploadDate",
"columnName": "upload_date",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "isUploadDateApproximation",
"columnName": "is_upload_date_approximation",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"uid"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_streams_service_id_url",
"unique": true,
"columnNames": [
"service_id",
"url"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
}
],
"foreignKeys": []
},
{
"tableName": "stream_history",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "streamUid",
"columnName": "stream_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "accessDate",
"columnName": "access_date",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "repeatCount",
"columnName": "repeat_count",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"stream_id",
"access_date"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_stream_history_stream_id",
"unique": false,
"columnNames": [
"stream_id"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
}
],
"foreignKeys": [
{
"table": "streams",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"stream_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "stream_state",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "streamUid",
"columnName": "stream_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "progressMillis",
"columnName": "progress_time",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"stream_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": [
{
"table": "streams",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"stream_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnail_url",
"affinity": "TEXT",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"uid"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_playlists_name",
"unique": false,
"columnNames": [
"name"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`name`)"
}
],
"foreignKeys": []
},
{
"tableName": "playlist_stream_join",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
"fields": [
{
"fieldPath": "playlistUid",
"columnName": "playlist_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "streamUid",
"columnName": "stream_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "index",
"columnName": "join_index",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"playlist_id",
"join_index"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_playlist_stream_join_playlist_id_join_index",
"unique": true,
"columnNames": [
"playlist_id",
"join_index"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)"
},
{
"name": "index_playlist_stream_join_stream_id",
"unique": false,
"columnNames": [
"stream_id"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
}
],
"foreignKeys": [
{
"table": "playlists",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"playlist_id"
],
"referencedColumns": [
"uid"
]
},
{
"table": "streams",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"stream_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "remote_playlists",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "serviceId",
"columnName": "service_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "url",
"columnName": "url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnail_url",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "uploader",
"columnName": "uploader",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "streamCount",
"columnName": "stream_count",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"uid"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_remote_playlists_name",
"unique": false,
"columnNames": [
"name"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)"
},
{
"name": "index_remote_playlists_service_id_url",
"unique": true,
"columnNames": [
"service_id",
"url"
],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
}
],
"foreignKeys": []
},
{
"tableName": "feed",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
"fields": [
{
"fieldPath": "streamId",
"columnName": "stream_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "subscriptionId",
"columnName": "subscription_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"stream_id",
"subscription_id"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_feed_subscription_id",
"unique": false,
"columnNames": [
"subscription_id"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
}
],
"foreignKeys": [
{
"table": "streams",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"stream_id"
],
"referencedColumns": [
"uid"
]
},
{
"table": "subscriptions",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"subscription_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "feed_group",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "icon",
"columnName": "icon_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "sortOrder",
"columnName": "sort_order",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"uid"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_feed_group_sort_order",
"unique": false,
"columnNames": [
"sort_order"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
}
],
"foreignKeys": []
},
{
"tableName": "feed_group_subscription_join",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
"fields": [
{
"fieldPath": "feedGroupId",
"columnName": "group_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "subscriptionId",
"columnName": "subscription_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"group_id",
"subscription_id"
],
"autoGenerate": false
},
"indices": [
{
"name": "index_feed_group_subscription_join_subscription_id",
"unique": false,
"columnNames": [
"subscription_id"
],
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
}
],
"foreignKeys": [
{
"table": "feed_group",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"group_id"
],
"referencedColumns": [
"uid"
]
},
{
"table": "subscriptions",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"subscription_id"
],
"referencedColumns": [
"uid"
]
}
]
},
{
"tableName": "feed_last_updated",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
"fields": [
{
"fieldPath": "subscriptionId",
"columnName": "subscription_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "lastUpdated",
"columnName": "last_updated",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"columnNames": [
"subscription_id"
],
"autoGenerate": false
},
"indices": [],
"foreignKeys": [
{
"table": "subscriptions",
"onDelete": "CASCADE",
"onUpdate": "CASCADE",
"columns": [
"subscription_id"
],
"referencedColumns": [
"uid"
]
}
]
}
],
"views": [],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '096731b513bb71dd44517639f4a2c1e3')"
]
}
}

View File

@ -0,0 +1,130 @@
package org.schabi.newpipe.database
import android.content.ContentValues
import android.database.sqlite.SQLiteDatabase
import androidx.room.Room
import androidx.room.testing.MigrationTestHelper
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.schabi.newpipe.extractor.stream.StreamType
@RunWith(AndroidJUnit4::class)
class DatabaseMigrationTest {
companion object {
private const val DEFAULT_SERVICE_ID = 0
private const val DEFAULT_URL = "https://www.youtube.com/watch?v=cDphUib5iG4"
private const val DEFAULT_TITLE = "Test Title"
private val DEFAULT_TYPE = StreamType.VIDEO_STREAM
private const val DEFAULT_DURATION = 480L
private const val DEFAULT_UPLOADER_NAME = "Uploader Test"
private const val DEFAULT_THUMBNAIL = "https://example.com/example.jpg"
private const val DEFAULT_SECOND_SERVICE_ID = 0
private const val DEFAULT_SECOND_URL = "https://www.youtube.com/watch?v=ncQU6iBn5Fc"
}
@get:Rule
val testHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java.canonicalName, FrameworkSQLiteOpenHelperFactory()
)
@Test
fun migrateDatabaseFrom2to3() {
val databaseInV2 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_2)
databaseInV2.run {
insert(
"streams", SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", DEFAULT_SERVICE_ID)
put("url", DEFAULT_URL)
put("title", DEFAULT_TITLE)
put("stream_type", DEFAULT_TYPE.name)
put("duration", DEFAULT_DURATION)
put("uploader", DEFAULT_UPLOADER_NAME)
put("thumbnail_url", DEFAULT_THUMBNAIL)
}
)
insert(
"streams", SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", DEFAULT_SECOND_SERVICE_ID)
put("url", DEFAULT_SECOND_URL)
}
)
insert(
"streams", SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", DEFAULT_SERVICE_ID)
}
)
close()
}
testHelper.runMigrationsAndValidate(
AppDatabase.DATABASE_NAME, Migrations.DB_VER_3,
true, Migrations.MIGRATION_2_3
)
testHelper.runMigrationsAndValidate(
AppDatabase.DATABASE_NAME, Migrations.DB_VER_4,
true, Migrations.MIGRATION_3_4
)
testHelper.runMigrationsAndValidate(
AppDatabase.DATABASE_NAME, Migrations.DB_VER_5,
true, Migrations.MIGRATION_4_5
)
val migratedDatabaseV3 = getMigratedDatabase()
val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst()
// Only expect 2, the one with the null url will be ignored
assertEquals(2, listFromDB.size)
val streamFromMigratedDatabase = listFromDB[0]
assertEquals(DEFAULT_SERVICE_ID, streamFromMigratedDatabase.serviceId)
assertEquals(DEFAULT_URL, streamFromMigratedDatabase.url)
assertEquals(DEFAULT_TITLE, streamFromMigratedDatabase.title)
assertEquals(DEFAULT_TYPE, streamFromMigratedDatabase.streamType)
assertEquals(DEFAULT_DURATION, streamFromMigratedDatabase.duration)
assertEquals(DEFAULT_UPLOADER_NAME, streamFromMigratedDatabase.uploader)
assertEquals(DEFAULT_THUMBNAIL, streamFromMigratedDatabase.thumbnailUrl)
assertNull(streamFromMigratedDatabase.viewCount)
assertNull(streamFromMigratedDatabase.textualUploadDate)
assertNull(streamFromMigratedDatabase.uploadDate)
assertNull(streamFromMigratedDatabase.isUploadDateApproximation)
val secondStreamFromMigratedDatabase = listFromDB[1]
assertEquals(DEFAULT_SECOND_SERVICE_ID, secondStreamFromMigratedDatabase.serviceId)
assertEquals(DEFAULT_SECOND_URL, secondStreamFromMigratedDatabase.url)
assertEquals("", secondStreamFromMigratedDatabase.title)
// Should fallback to VIDEO_STREAM
assertEquals(StreamType.VIDEO_STREAM, secondStreamFromMigratedDatabase.streamType)
assertEquals(0, secondStreamFromMigratedDatabase.duration)
assertEquals("", secondStreamFromMigratedDatabase.uploader)
assertEquals("", secondStreamFromMigratedDatabase.thumbnailUrl)
assertNull(secondStreamFromMigratedDatabase.viewCount)
assertNull(secondStreamFromMigratedDatabase.textualUploadDate)
assertNull(secondStreamFromMigratedDatabase.uploadDate)
assertNull(secondStreamFromMigratedDatabase.isUploadDateApproximation)
}
private fun getMigratedDatabase(): AppDatabase {
val database: AppDatabase = Room.databaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java, AppDatabase.DATABASE_NAME
)
.build()
testHelper.closeWhenFinished(database)
return database
}
}

View File

@ -27,7 +27,7 @@ import org.schabi.newpipe.util.StateSaver;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.net.SocketException;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@ -213,37 +213,44 @@ public class App extends MultiDexApplication {
private void initNotificationChannels() {
// Keep the importance below DEFAULT to avoid making noise on every notification update for
// the main and update channels
final NotificationChannelCompat mainChannel = new NotificationChannelCompat
final List<NotificationChannelCompat> notificationChannelCompats = new ArrayList<>();
notificationChannelCompats.add(new NotificationChannelCompat
.Builder(getString(R.string.notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.notification_channel_name))
.setDescription(getString(R.string.notification_channel_description))
.build();
.build());
final NotificationChannelCompat appUpdateChannel = new NotificationChannelCompat
notificationChannelCompats.add(new NotificationChannelCompat
.Builder(getString(R.string.app_update_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.app_update_notification_channel_name))
.setDescription(getString(R.string.app_update_notification_channel_description))
.build();
.build());
final NotificationChannelCompat hashChannel = new NotificationChannelCompat
notificationChannelCompats.add(new NotificationChannelCompat
.Builder(getString(R.string.hash_channel_id),
NotificationManagerCompat.IMPORTANCE_HIGH)
.setName(getString(R.string.hash_channel_name))
.setDescription(getString(R.string.hash_channel_description))
.build();
.build());
final NotificationChannelCompat errorReportChannel = new NotificationChannelCompat
notificationChannelCompats.add(new NotificationChannelCompat
.Builder(getString(R.string.error_report_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.error_report_channel_name))
.setDescription(getString(R.string.error_report_channel_description))
.build();
.build());
notificationChannelCompats.add(new NotificationChannelCompat
.Builder(getString(R.string.streams_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setName(getString(R.string.streams_notification_channel_name))
.setDescription(getString(R.string.streams_notification_channel_description))
.build());
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
notificationManager.createNotificationChannelsCompat(Arrays.asList(mainChannel,
appUpdateChannel, hashChannel, errorReportChannel));
notificationManager.createNotificationChannelsCompat(notificationChannelCompats);
}
protected boolean isDisposedRxExceptionsReported() {

View File

@ -71,6 +71,7 @@ import org.schabi.newpipe.fragments.BackPressable;
import org.schabi.newpipe.fragments.MainFragment;
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
import org.schabi.newpipe.fragments.list.search.SearchFragment;
import org.schabi.newpipe.local.feed.notifications.NotificationWorker;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.event.OnKeyDownListener;
import org.schabi.newpipe.player.helper.PlayerHolder;
@ -158,11 +159,14 @@ public class MainActivity extends AppCompatActivity {
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(this, "Setting up drawer", e);
}
if (DeviceUtils.isTv(this)) {
FocusOverlayView.setupFocusObserver(this);
}
openMiniPlayerUponPlayerStarted();
// Schedule worker for checking for new streams and creating corresponding notifications
// if this is enabled by the user.
NotificationWorker.initialize(this);
}
@Override

View File

@ -1,5 +1,11 @@
package org.schabi.newpipe;
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
import static org.schabi.newpipe.database.Migrations.MIGRATION_4_5;
import android.content.Context;
import android.database.Cursor;
@ -8,11 +14,6 @@ import androidx.room.Room;
import org.schabi.newpipe.database.AppDatabase;
import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
public final class NewPipeDatabase {
private static volatile AppDatabase databaseInstance;
@ -23,7 +24,7 @@ public final class NewPipeDatabase {
private static AppDatabase getDatabase(final Context context) {
return Room
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
.build();
}

View File

@ -1,5 +1,7 @@
package org.schabi.newpipe.database;
import static org.schabi.newpipe.database.Migrations.DB_VER_5;
import androidx.room.Database;
import androidx.room.RoomDatabase;
import androidx.room.TypeConverters;
@ -27,8 +29,6 @@ import org.schabi.newpipe.database.stream.model.StreamStateEntity;
import org.schabi.newpipe.database.subscription.SubscriptionDAO;
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import static org.schabi.newpipe.database.Migrations.DB_VER_4;
@TypeConverters({Converters.class})
@Database(
entities = {
@ -38,7 +38,7 @@ import static org.schabi.newpipe.database.Migrations.DB_VER_4;
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
FeedLastUpdatedEntity.class
},
version = DB_VER_4
version = DB_VER_5
)
public abstract class AppDatabase extends RoomDatabase {
public static final String DATABASE_NAME = "newpipe.db";

View File

@ -22,6 +22,7 @@ public final class Migrations {
public static final int DB_VER_2 = 2;
public static final int DB_VER_3 = 3;
public static final int DB_VER_4 = 4;
public static final int DB_VER_5 = 5;
private static final String TAG = Migrations.class.getName();
public static final boolean DEBUG = MainActivity.DEBUG;
@ -179,5 +180,14 @@ public final class Migrations {
}
};
private Migrations() { }
public static final Migration MIGRATION_4_5 = new Migration(DB_VER_4, DB_VER_5) {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` "
+ "INTEGER NOT NULL DEFAULT 0");
}
};
private Migrations() {
}
}

View File

@ -12,6 +12,7 @@ import org.schabi.newpipe.database.feed.model.FeedEntity
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
import org.schabi.newpipe.database.stream.StreamWithState
import org.schabi.newpipe.database.stream.model.StreamStateEntity
import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import java.time.OffsetDateTime
@ -252,4 +253,21 @@ abstract class FeedDAO {
"""
)
abstract fun getAllOutdatedForGroup(groupId: Long, outdatedThreshold: OffsetDateTime): Flowable<List<SubscriptionEntity>>
@Query(
"""
SELECT s.* FROM subscriptions s
LEFT JOIN feed_last_updated lu
ON s.uid = lu.subscription_id
WHERE
(lu.last_updated IS NULL OR lu.last_updated < :outdatedThreshold)
AND s.notification_mode = :notificationMode
"""
)
abstract fun getOutdatedWithNotificationMode(
outdatedThreshold: OffsetDateTime,
@NotificationMode notificationMode: Int
): Flowable<List<SubscriptionEntity>>
}

View File

@ -39,6 +39,9 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
@Insert(onConflict = OnConflictStrategy.IGNORE)
internal abstract fun silentInsertAllInternal(streams: List<StreamEntity>): List<Long>
@Query("SELECT COUNT(*) != 0 FROM streams WHERE url = :url AND service_id = :serviceId")
internal abstract fun exists(serviceId: Int, url: String): Boolean
@Query(
"""
SELECT uid, stream_type, textual_upload_date, upload_date, is_upload_date_approximation, duration

View File

@ -0,0 +1,14 @@
package org.schabi.newpipe.database.subscription;
import androidx.annotation.IntDef;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@IntDef({NotificationMode.DISABLED, NotificationMode.ENABLED})
@Retention(RetentionPolicy.SOURCE)
public @interface NotificationMode {
int DISABLED = 0;
int ENABLED = 1;
//other values reserved for the future
}

View File

@ -26,6 +26,7 @@ public class SubscriptionEntity {
public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url";
public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
public static final String SUBSCRIPTION_DESCRIPTION = "description";
public static final String SUBSCRIPTION_NOTIFICATION_MODE = "notification_mode";
@PrimaryKey(autoGenerate = true)
private long uid = 0;
@ -48,6 +49,9 @@ public class SubscriptionEntity {
@ColumnInfo(name = SUBSCRIPTION_DESCRIPTION)
private String description;
@ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE)
private int notificationMode;
@Ignore
public static SubscriptionEntity from(@NonNull final ChannelInfo info) {
final SubscriptionEntity result = new SubscriptionEntity();
@ -114,6 +118,15 @@ public class SubscriptionEntity {
this.description = description;
}
@NotificationMode
public int getNotificationMode() {
return notificationMode;
}
public void setNotificationMode(@NotificationMode final int notificationMode) {
this.notificationMode = notificationMode;
}
@Ignore
public void setData(final String n, final String au, final String d, final Long sc) {
this.setName(n);

View File

@ -26,6 +26,7 @@ public enum UserAction {
DOWNLOAD_OPEN_DIALOG("download open dialog"),
DOWNLOAD_POSTPROCESSING("download post-processing"),
DOWNLOAD_FAILED("download failed"),
NEW_STREAMS_NOTIFICATIONS("new streams notifications"),
PREFERENCES_MIGRATION("migration of preferences"),
SHARE_TO_NEWPIPE("share to newpipe"),
CHECK_FOR_NEW_APP_VERSION("check for new app version"),

View File

@ -5,6 +5,7 @@ import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor;
import android.content.Context;
import android.graphics.Color;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
@ -22,9 +23,11 @@ import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.core.content.ContextCompat;
import com.google.android.material.snackbar.Snackbar;
import com.jakewharton.rxbinding4.view.RxView;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.NotificationMode;
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.databinding.ChannelHeaderBinding;
import org.schabi.newpipe.databinding.FragmentChannelBinding;
@ -39,6 +42,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.local.subscription.SubscriptionManager;
import org.schabi.newpipe.local.feed.notifications.NotificationHelper;
import org.schabi.newpipe.player.MainPlayer.PlayerType;
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueue;
@ -84,6 +88,7 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
private PlaylistControlBinding playlistControlBinding;
private MenuItem menuRssButton;
private MenuItem menuNotifyButton;
public static ChannelFragment getInstance(final int serviceId, final String url,
final String name) {
@ -179,6 +184,7 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
+ "menu = [" + menu + "], inflater = [" + inflater + "]");
}
menuRssButton = menu.findItem(R.id.menu_item_rss);
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
}
}
@ -188,6 +194,11 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
case R.id.action_settings:
NavigationHelper.openSettings(requireContext());
break;
case R.id.menu_item_notify:
final boolean value = !item.isChecked();
item.setEnabled(false);
setNotify(value);
break;
case R.id.menu_item_rss:
if (currentInfo != null) {
ShareUtils.openUrlInBrowser(
@ -232,15 +243,22 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
.subscribe(getSubscribeUpdateMonitor(info), onError));
disposables.add(observable
// Some updates are very rapid
// (for example when calling the updateSubscription(info))
// so only update the UI for the latest emission
// ("sync" the subscribe button's state)
.debounce(100, TimeUnit.MILLISECONDS)
.map(List::isEmpty)
.distinctUntilChanged()
.observeOn(AndroidSchedulers.mainThread())
.subscribe((List<SubscriptionEntity> subscriptionEntities) ->
updateSubscribeButton(!subscriptionEntities.isEmpty()), onError));
.subscribe(isEmpty -> updateSubscribeButton(!isEmpty), onError));
disposables.add(observable
.map(List::isEmpty)
.distinctUntilChanged()
.skip(1) // channel has just been opened
.filter(x -> NotificationHelper.areNewStreamsNotificationsEnabled(requireContext()))
.observeOn(AndroidSchedulers.mainThread())
.subscribe(isEmpty -> {
if (!isEmpty) {
showNotifySnackbar();
}
}, onError));
}
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription,
@ -320,6 +338,7 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
info.getAvatarUrl(),
info.getDescription(),
info.getSubscriberCount());
updateNotifyButton(null);
subscribeButtonMonitor = monitorSubscribeButton(
headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info));
} else {
@ -327,6 +346,7 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
Log.d(TAG, "Found subscription to this channel!");
}
final SubscriptionEntity subscription = subscriptionEntities.get(0);
updateNotifyButton(subscription);
subscribeButtonMonitor = monitorSubscribeButton(
headerBinding.channelSubscribeButton, mapOnUnsubscribe(subscription));
}
@ -369,6 +389,45 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
AnimationType.LIGHT_SCALE_AND_ALPHA);
}
private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) {
if (menuNotifyButton == null) {
return;
}
if (subscription != null) {
menuNotifyButton.setEnabled(
NotificationHelper.areNewStreamsNotificationsEnabled(requireContext())
);
menuNotifyButton.setChecked(
subscription.getNotificationMode() == NotificationMode.ENABLED
);
}
menuNotifyButton.setVisible(subscription != null);
}
private void setNotify(final boolean isEnabled) {
disposables.add(
subscriptionManager
.updateNotificationMode(
currentInfo.getServiceId(),
currentInfo.getUrl(),
isEnabled ? NotificationMode.ENABLED : NotificationMode.DISABLED)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe()
);
}
/**
* Show a snackbar with the option to enable notifications on new streams for this channel.
*/
private void showNotifySnackbar() {
Snackbar.make(itemsList, R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG)
.setAction(R.string.get_notified, v -> setNotify(true))
.setActionTextColor(Color.YELLOW)
.show();
}
/*//////////////////////////////////////////////////////////////////////////
// Load and handle
//////////////////////////////////////////////////////////////////////////*/

View File

@ -14,6 +14,7 @@ import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
import org.schabi.newpipe.database.stream.StreamWithState
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.local.subscription.FeedGroupIcon
@ -57,6 +58,11 @@ class FeedDatabaseManager(context: Context) {
fun outdatedSubscriptions(outdatedThreshold: OffsetDateTime) = feedTable.getAllOutdated(outdatedThreshold)
fun outdatedSubscriptionsWithNotificationMode(
outdatedThreshold: OffsetDateTime,
@NotificationMode notificationMode: Int
) = feedTable.getOutdatedWithNotificationMode(outdatedThreshold, notificationMode)
fun notLoadedCount(groupId: Long = FeedGroupEntity.GROUP_ALL_ID): Flowable<Long> {
return when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> feedTable.notLoadedCount()
@ -72,6 +78,10 @@ class FeedDatabaseManager(context: Context) {
fun markAsOutdated(subscriptionId: Long) = feedTable
.setLastUpdatedForSubscription(FeedLastUpdatedEntity(subscriptionId, null))
fun doesStreamExist(stream: StreamInfoItem): Boolean {
return streamTable.exists(stream.serviceId, stream.url)
}
fun upsertAll(
subscriptionId: Long,
items: List<StreamInfoItem>,

View File

@ -0,0 +1,145 @@
package org.schabi.newpipe.local.feed.notifications
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import androidx.preference.PreferenceManager
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.service.FeedUpdateInfo
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.PicassoHelper
/**
* Helper for everything related to show notifications about new streams to the user.
*/
class NotificationHelper(val context: Context) {
private val manager = context.getSystemService(
Context.NOTIFICATION_SERVICE
) as NotificationManager
/**
* Show a notification about new streams from a single channel.
* Opening the notification will open the corresponding channel page.
*/
fun displayNewStreamsNotification(data: FeedUpdateInfo) {
val newStreams: List<StreamInfoItem> = data.newStreams
val summary = context.resources.getQuantityString(
R.plurals.new_streams, newStreams.size, newStreams.size
)
val builder = NotificationCompat.Builder(
context,
context.getString(R.string.streams_notification_channel_id)
)
.setContentTitle(Localization.concatenateStrings(data.name, summary))
.setContentText(
data.listInfo.relatedItems.joinToString(
context.getString(R.string.enumeration_comma)
) { x -> x.name }
)
.setNumber(newStreams.size)
.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
.setColor(ContextCompat.getColor(context, R.color.ic_launcher_background))
.setColorized(true)
.setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_SOCIAL)
// Build style
val style = NotificationCompat.InboxStyle()
newStreams.forEach { style.addLine(it.name) }
style.setSummaryText(summary)
style.setBigContentTitle(data.name)
builder.setStyle(style)
// open the channel page when clicking on the notification
builder.setContentIntent(
PendingIntent.getActivity(
context,
data.pseudoId,
NavigationHelper
.getChannelIntent(context, data.listInfo.serviceId, data.listInfo.url)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
PendingIntent.FLAG_IMMUTABLE
else
0
)
)
PicassoHelper.loadNotificationIcon(data.avatarUrl) { bitmap ->
bitmap?.let { builder.setLargeIcon(it) } // set only if != null
manager.notify(data.pseudoId, builder.build())
}
}
companion object {
/**
* Check whether notifications are enabled on the device.
* Users can disable them via the system settings for a single app.
* If this is the case, the app cannot create any notifications
* and display them to the user.
* <br>
* On Android 26 and above, notification channels are used by NewPipe.
* These can be configured by the user, too.
* The notification channel for new streams is also checked by this method.
*
* @param context Context
* @return <code>true</code> if notifications are allowed and can be displayed;
* <code>false</code> otherwise
*/
fun areNotificationsEnabledOnDevice(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channelId = context.getString(R.string.streams_notification_channel_id)
val manager = context.getSystemService(
Context.NOTIFICATION_SERVICE
) as NotificationManager
val enabled = manager.areNotificationsEnabled()
val channel = manager.getNotificationChannel(channelId)
val importance = channel?.importance
enabled && channel != null && importance != NotificationManager.IMPORTANCE_NONE
} else {
NotificationManagerCompat.from(context).areNotificationsEnabled()
}
}
/**
* Whether the user enabled the notifications for new streams in the app settings.
*/
@JvmStatic
fun areNewStreamsNotificationsEnabled(context: Context): Boolean {
return (
PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(context.getString(R.string.enable_streams_notifications), false) &&
areNotificationsEnabledOnDevice(context)
)
}
/**
* Open the system's notification settings for NewPipe on Android Oreo (API 26) and later.
* Open the system's app settings for NewPipe on previous Android versions.
*/
fun openNewPipeSystemNotificationSettings(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS)
.putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
} else {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
intent.data = Uri.parse("package:" + context.packageName)
context.startActivity(intent)
}
}
}
}

View File

@ -0,0 +1,170 @@
package org.schabi.newpipe.local.feed.notifications
import android.content.Context
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.work.Constraints
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequest
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.rxjava3.RxWorker
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import org.schabi.newpipe.App
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.local.feed.service.FeedLoadManager
import org.schabi.newpipe.local.feed.service.FeedLoadService
import java.util.concurrent.TimeUnit
/*
* Worker which checks for new streams of subscribed channels
* in intervals which can be set by the user in the settings.
*/
class NotificationWorker(
appContext: Context,
workerParams: WorkerParameters,
) : RxWorker(appContext, workerParams) {
private val notificationHelper by lazy {
NotificationHelper(appContext)
}
private val feedLoadManager = FeedLoadManager(appContext)
override fun createWork(): Single<Result> = if (areNotificationsEnabled(applicationContext)) {
feedLoadManager.startLoading(
ignoreOutdatedThreshold = true,
groupId = FeedLoadManager.GROUP_NOTIFICATION_ENABLED
)
.doOnSubscribe { showLoadingFeedForegroundNotification() }
.map { feed ->
// filter out feedUpdateInfo items (i.e. channels) with nothing new
feed.mapNotNull {
it.value?.takeIf { feedUpdateInfo ->
feedUpdateInfo.newStreams.isNotEmpty()
}
}
}
.observeOn(AndroidSchedulers.mainThread()) // Picasso requires calls from main thread
.map { feedUpdateInfoList ->
// display notifications for each feedUpdateInfo (i.e. channel)
feedUpdateInfoList.forEach { feedUpdateInfo ->
notificationHelper.displayNewStreamsNotification(feedUpdateInfo)
}
return@map Result.success()
}
.doOnError { throwable ->
Log.e(TAG, "Error while displaying streams notifications", throwable)
ErrorUtil.createNotification(
applicationContext,
ErrorInfo(throwable, UserAction.NEW_STREAMS_NOTIFICATIONS, "main worker")
)
}
.onErrorReturnItem(Result.failure())
} else {
// the user can disable streams notifications in the device's app settings
Single.just(Result.success())
}
private fun showLoadingFeedForegroundNotification() {
val notification = NotificationCompat.Builder(
applicationContext,
applicationContext.getString(R.string.notification_channel_id)
).setOngoing(true)
.setProgress(-1, -1, true)
.setSmallIcon(R.drawable.ic_newpipe_triangle_white)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setContentTitle(applicationContext.getString(R.string.feed_notification_loading))
.build()
setForegroundAsync(ForegroundInfo(FeedLoadService.NOTIFICATION_ID, notification))
}
companion object {
private val TAG = NotificationWorker::class.java.simpleName
private const val WORK_TAG = App.PACKAGE_NAME + "_streams_notifications"
private fun areNotificationsEnabled(context: Context) =
NotificationHelper.areNewStreamsNotificationsEnabled(context) &&
NotificationHelper.areNotificationsEnabledOnDevice(context)
/**
* Schedules a task for the [NotificationWorker]
* if the (device and in-app) notifications are enabled,
* otherwise [cancel]s all scheduled tasks.
*/
@JvmStatic
fun initialize(context: Context) {
if (areNotificationsEnabled(context)) {
schedule(context)
} else {
cancel(context)
}
}
/**
* @param context the context to use
* @param options configuration options for the scheduler
* @param force Force the scheduler to use the new options
* by replacing the previously used worker.
*/
fun schedule(context: Context, options: ScheduleOptions, force: Boolean = false) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(
if (options.isRequireNonMeteredNetwork) {
NetworkType.UNMETERED
} else {
NetworkType.CONNECTED
}
).build()
val request = PeriodicWorkRequest.Builder(
NotificationWorker::class.java,
options.interval,
TimeUnit.MILLISECONDS
).setConstraints(constraints)
.addTag(WORK_TAG)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork(
WORK_TAG,
if (force) {
ExistingPeriodicWorkPolicy.REPLACE
} else {
ExistingPeriodicWorkPolicy.KEEP
},
request
)
}
@JvmStatic
fun schedule(context: Context) = schedule(context, ScheduleOptions.from(context))
/**
* Check for new streams immediately
*/
@JvmStatic
fun runNow(context: Context) {
val request = OneTimeWorkRequestBuilder<NotificationWorker>()
.addTag(WORK_TAG)
.build()
WorkManager.getInstance(context).enqueue(request)
}
/**
* Cancels all current work related to the [NotificationWorker].
*/
@JvmStatic
fun cancel(context: Context) {
WorkManager.getInstance(context).cancelAllWorkByTag(WORK_TAG)
}
}
}

View File

@ -0,0 +1,37 @@
package org.schabi.newpipe.local.feed.notifications
import android.content.Context
import androidx.preference.PreferenceManager
import org.schabi.newpipe.R
import java.util.concurrent.TimeUnit
/**
* Information for the Scheduler which checks for new streams.
* See [NotificationWorker]
*/
data class ScheduleOptions(
val interval: Long,
val isRequireNonMeteredNetwork: Boolean
) {
companion object {
fun from(context: Context): ScheduleOptions {
val preferences = PreferenceManager.getDefaultSharedPreferences(context)
return ScheduleOptions(
interval = TimeUnit.SECONDS.toMillis(
preferences.getString(
context.getString(R.string.streams_notifications_interval_key),
null
)?.toLongOrNull() ?: context.getString(
R.string.streams_notifications_interval_default
).toLong()
),
isRequireNonMeteredNetwork = preferences.getString(
context.getString(R.string.streams_notifications_network_key),
context.getString(R.string.streams_notifications_network_default)
) == context.getString(R.string.streams_notifications_network_wifi)
)
}
}
}

View File

@ -0,0 +1,270 @@
package org.schabi.newpipe.local.feed.service
import android.content.Context
import androidx.preference.PreferenceManager
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Notification
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.functions.Consumer
import io.reactivex.rxjava3.processors.PublishProcessor
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.extractor.ListInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.FeedDatabaseManager
import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.util.ExtractorHelper
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
class FeedLoadManager(private val context: Context) {
private val subscriptionManager = SubscriptionManager(context)
private val feedDatabaseManager = FeedDatabaseManager(context)
private val notificationUpdater = PublishProcessor.create<String>()
private val currentProgress = AtomicInteger(-1)
private val maxProgress = AtomicInteger(-1)
private val cancelSignal = AtomicBoolean()
private val feedResultsHolder = FeedResultsHolder()
val notification: Flowable<FeedLoadState> = notificationUpdater.map { description ->
FeedLoadState(description, maxProgress.get(), currentProgress.get())
}
/**
* Start checking for new streams of a subscription group.
* @param groupId The ID of the subscription group to load. When using
* [FeedGroupEntity.GROUP_ALL_ID], all subscriptions are loaded. When using
* [GROUP_NOTIFICATION_ENABLED], only subscriptions with enabled notifications for new streams
* are loaded. Using an id of a group created by the user results in that specific group to be
* loaded.
* @param ignoreOutdatedThreshold When `false`, only subscriptions which have not been updated
* within the `feed_update_threshold` are checked for updates. This threshold can be set by
* the user in the app settings. When `true`, all subscriptions are checked for new streams.
*/
fun startLoading(
groupId: Long = FeedGroupEntity.GROUP_ALL_ID,
ignoreOutdatedThreshold: Boolean = false,
): Single<List<Notification<FeedUpdateInfo>>> {
val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(context)
val useFeedExtractor = defaultSharedPreferences.getBoolean(
context.getString(R.string.feed_use_dedicated_fetch_method_key),
false
)
val outdatedThreshold = if (ignoreOutdatedThreshold) {
OffsetDateTime.now(ZoneOffset.UTC)
} else {
val thresholdOutdatedSeconds = (
defaultSharedPreferences.getString(
context.getString(R.string.feed_update_threshold_key),
context.getString(R.string.feed_update_threshold_default_value)
) ?: context.getString(R.string.feed_update_threshold_default_value)
).toInt()
OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(thresholdOutdatedSeconds.toLong())
}
/**
* subscriptions which have not been updated within the feed updated threshold
*/
val outdatedSubscriptions = when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
GROUP_NOTIFICATION_ENABLED -> feedDatabaseManager.outdatedSubscriptionsWithNotificationMode(
outdatedThreshold, NotificationMode.ENABLED
)
else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
}
return outdatedSubscriptions
.take(1)
.doOnNext {
currentProgress.set(0)
maxProgress.set(it.size)
}
.filter { it.isNotEmpty() }
.observeOn(AndroidSchedulers.mainThread())
.doOnNext {
notificationUpdater.onNext("")
broadcastProgress()
}
.observeOn(Schedulers.io())
.flatMap { Flowable.fromIterable(it) }
.takeWhile { !cancelSignal.get() }
.parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2)
.runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
.filter { !cancelSignal.get() }
.map { subscriptionEntity ->
var error: Throwable? = null
try {
// check for and load new streams
// either by using the dedicated feed method or by getting the channel info
val listInfo = if (useFeedExtractor) {
ExtractorHelper
.getFeedInfoFallbackToChannelInfo(
subscriptionEntity.serviceId,
subscriptionEntity.url
)
.onErrorReturn {
error = it // store error, otherwise wrapped into RuntimeException
throw it
}
.blockingGet()
} else {
ExtractorHelper
.getChannelInfo(
subscriptionEntity.serviceId,
subscriptionEntity.url,
true
)
.onErrorReturn {
error = it // store error, otherwise wrapped into RuntimeException
throw it
}
.blockingGet()
} as ListInfo<StreamInfoItem>
return@map Notification.createOnNext(
FeedUpdateInfo(
subscriptionEntity,
listInfo
)
)
} catch (e: Throwable) {
if (error == null) {
// do this to prevent blockingGet() from wrapping into RuntimeException
error = e
}
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
val wrapper =
FeedLoadService.RequestException(subscriptionEntity.uid, request, error!!)
return@map Notification.createOnError<FeedUpdateInfo>(wrapper)
}
}
.sequential()
.observeOn(AndroidSchedulers.mainThread())
.doOnNext(NotificationConsumer())
.observeOn(Schedulers.io())
.buffer(BUFFER_COUNT_BEFORE_INSERT)
.doOnNext(DatabaseConsumer())
.subscribeOn(Schedulers.io())
.toList()
.flatMap { x -> postProcessFeed().toSingleDefault(x.flatten()) }
}
fun cancel() {
cancelSignal.set(true)
}
private fun broadcastProgress() {
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(currentProgress.get(), maxProgress.get()))
}
/**
* Keep the feed and the stream tables small
* to reduce loading times when trying to display the feed.
* <br>
* Remove streams from the feed which are older than [FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE].
* Remove streams from the database which are not linked / used by any table.
*/
private fun postProcessFeed() = Completable.fromRunnable {
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message))
feedDatabaseManager.removeOrphansOrOlderStreams()
FeedEventManager.postEvent(FeedEventManager.Event.SuccessResultEvent(feedResultsHolder.itemsErrors))
}.doOnSubscribe {
currentProgress.set(-1)
maxProgress.set(-1)
notificationUpdater.onNext(context.getString(R.string.feed_processing_message))
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(R.string.feed_processing_message))
}.subscribeOn(Schedulers.io())
private inner class NotificationConsumer : Consumer<Notification<FeedUpdateInfo>> {
override fun accept(item: Notification<FeedUpdateInfo>) {
currentProgress.incrementAndGet()
notificationUpdater.onNext(item.value?.name.orEmpty())
broadcastProgress()
}
}
private inner class DatabaseConsumer : Consumer<List<Notification<FeedUpdateInfo>>> {
override fun accept(list: List<Notification<FeedUpdateInfo>>) {
feedDatabaseManager.database().runInTransaction {
for (notification in list) {
when {
notification.isOnNext -> {
val subscriptionId = notification.value!!.uid
val info = notification.value!!.listInfo
notification.value!!.newStreams = filterNewStreams(
notification.value!!.listInfo.relatedItems
)
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
subscriptionManager.updateFromInfo(subscriptionId, info)
if (info.errors.isNotEmpty()) {
feedResultsHolder.addErrors(
FeedLoadService.RequestException.wrapList(
subscriptionId,
info
)
)
feedDatabaseManager.markAsOutdated(subscriptionId)
}
}
notification.isOnError -> {
val error = notification.error
feedResultsHolder.addError(error!!)
if (error is FeedLoadService.RequestException) {
feedDatabaseManager.markAsOutdated(error.subscriptionId)
}
}
}
}
}
}
private fun filterNewStreams(list: List<StreamInfoItem>): List<StreamInfoItem> {
return list.filter {
!feedDatabaseManager.doesStreamExist(it) &&
it.uploadDate != null &&
// Streams older than this date are automatically removed from the feed.
// Therefore, streams which are not in the database,
// but older than this date, are considered old.
it.uploadDate!!.offsetDateTime().isAfter(
FeedDatabaseManager.FEED_OLDEST_ALLOWED_DATE
)
}
}
}
companion object {
/**
* Constant used to check for updates of subscriptions with [NotificationMode.ENABLED].
*/
const val GROUP_NOTIFICATION_ENABLED = -2L
/**
* How many extractions will be running in parallel.
*/
private const val PARALLEL_EXTRACTIONS = 6
/**
* Number of items to buffer to mass-insert in the database.
*/
private const val BUFFER_COUNT_BEFORE_INSERT = 20
}
}

View File

@ -31,41 +31,24 @@ import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.ServiceCompat
import androidx.preference.PreferenceManager
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Notification
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.functions.Consumer
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.functions.Function
import io.reactivex.rxjava3.processors.PublishProcessor
import io.reactivex.rxjava3.schedulers.Schedulers
import org.reactivestreams.Subscriber
import org.reactivestreams.Subscription
import org.schabi.newpipe.App
import org.schabi.newpipe.MainActivity.DEBUG
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.extractor.ListInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.FeedDatabaseManager
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ProgressEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.SuccessResultEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.util.ExtractorHelper
import java.time.OffsetDateTime
import java.time.ZoneOffset
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
class FeedLoadService : Service() {
companion object {
private val TAG = FeedLoadService::class.java.simpleName
private const val NOTIFICATION_ID = 7293450
const val NOTIFICATION_ID = 7293450
private const val ACTION_CANCEL = App.PACKAGE_NAME + ".local.feed.service.FeedLoadService.CANCEL"
/**
@ -73,27 +56,13 @@ class FeedLoadService : Service() {
*/
private const val NOTIFICATION_SAMPLING_PERIOD = 1500
/**
* How many extractions will be running in parallel.
*/
private const val PARALLEL_EXTRACTIONS = 6
/**
* Number of items to buffer to mass-insert in the database.
*/
private const val BUFFER_COUNT_BEFORE_INSERT = 20
const val EXTRA_GROUP_ID: String = "FeedLoadService.EXTRA_GROUP_ID"
}
private var loadingSubscription: Subscription? = null
private lateinit var subscriptionManager: SubscriptionManager
private var loadingDisposable: Disposable? = null
private var notificationDisposable: Disposable? = null
private lateinit var feedDatabaseManager: FeedDatabaseManager
private lateinit var feedResultsHolder: ResultsHolder
private var disposables = CompositeDisposable()
private var notificationUpdater = PublishProcessor.create<String>()
private lateinit var feedLoadManager: FeedLoadManager
// /////////////////////////////////////////////////////////////////////////
// Lifecycle
@ -101,8 +70,7 @@ class FeedLoadService : Service() {
override fun onCreate() {
super.onCreate()
subscriptionManager = SubscriptionManager(this)
feedDatabaseManager = FeedDatabaseManager(this)
feedLoadManager = FeedLoadManager(this)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -114,40 +82,45 @@ class FeedLoadService : Service() {
)
}
if (intent == null || loadingSubscription != null) {
if (intent == null || loadingDisposable != null) {
return START_NOT_STICKY
}
setupNotification()
setupBroadcastReceiver()
val defaultSharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
val groupId = intent.getLongExtra(EXTRA_GROUP_ID, FeedGroupEntity.GROUP_ALL_ID)
val useFeedExtractor = defaultSharedPreferences
.getBoolean(getString(R.string.feed_use_dedicated_fetch_method_key), false)
val thresholdOutdatedSecondsString = defaultSharedPreferences
.getString(getString(R.string.feed_update_threshold_key), getString(R.string.feed_update_threshold_default_value))
val thresholdOutdatedSeconds = thresholdOutdatedSecondsString!!.toInt()
startLoading(groupId, useFeedExtractor, thresholdOutdatedSeconds)
loadingDisposable = feedLoadManager.startLoading(groupId)
.observeOn(AndroidSchedulers.mainThread())
.doOnSubscribe {
startForeground(NOTIFICATION_ID, notificationBuilder.build())
}
.subscribe { _, error ->
// There seems to be a bug in the kotlin plugin as it tells you when
// building that this can't be null:
// "Condition 'error != null' is always 'true'"
// However it can indeed be null
// The suppression may be removed in further versions
@Suppress("SENSELESS_COMPARISON")
if (error != null) {
Log.e(TAG, "Error while storing result", error)
handleError(error)
return@subscribe
}
stopService()
}
return START_NOT_STICKY
}
private fun disposeAll() {
unregisterReceiver(broadcastReceiver)
loadingSubscription?.cancel()
loadingSubscription = null
disposables.dispose()
loadingDisposable?.dispose()
notificationDisposable?.dispose()
}
private fun stopService() {
disposeAll()
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
notificationManager.cancel(NOTIFICATION_ID)
stopSelf()
}
@ -171,182 +144,6 @@ class FeedLoadService : Service() {
}
}
private fun startLoading(groupId: Long = FeedGroupEntity.GROUP_ALL_ID, useFeedExtractor: Boolean, thresholdOutdatedSeconds: Int) {
feedResultsHolder = ResultsHolder()
val outdatedThreshold = OffsetDateTime.now(ZoneOffset.UTC).minusSeconds(thresholdOutdatedSeconds.toLong())
val subscriptions = when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold)
else -> feedDatabaseManager.outdatedSubscriptionsForGroup(groupId, outdatedThreshold)
}
subscriptions
.take(1)
.doOnNext {
currentProgress.set(0)
maxProgress.set(it.size)
}
.filter { it.isNotEmpty() }
.observeOn(AndroidSchedulers.mainThread())
.doOnNext {
startForeground(NOTIFICATION_ID, notificationBuilder.build())
updateNotificationProgress(null)
broadcastProgress()
}
.observeOn(Schedulers.io())
.flatMap { Flowable.fromIterable(it) }
.takeWhile { !cancelSignal.get() }
.parallel(PARALLEL_EXTRACTIONS, PARALLEL_EXTRACTIONS * 2)
.runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
.filter { !cancelSignal.get() }
.map { subscriptionEntity ->
var error: Throwable? = null
try {
val listInfo = if (useFeedExtractor) {
ExtractorHelper
.getFeedInfoFallbackToChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url)
.onErrorReturn {
error = it // store error, otherwise wrapped into RuntimeException
throw it
}
.blockingGet()
} else {
ExtractorHelper
.getChannelInfo(subscriptionEntity.serviceId, subscriptionEntity.url, true)
.onErrorReturn {
error = it // store error, otherwise wrapped into RuntimeException
throw it
}
.blockingGet()
} as ListInfo<StreamInfoItem>
return@map Notification.createOnNext(Pair(subscriptionEntity.uid, listInfo))
} catch (e: Throwable) {
if (error == null) {
// do this to prevent blockingGet() from wrapping into RuntimeException
error = e
}
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
val wrapper = RequestException(subscriptionEntity.uid, request, error!!)
return@map Notification.createOnError<Pair<Long, ListInfo<StreamInfoItem>>>(wrapper)
}
}
.sequential()
.observeOn(AndroidSchedulers.mainThread())
.doOnNext(notificationsConsumer)
.observeOn(Schedulers.io())
.buffer(BUFFER_COUNT_BEFORE_INSERT)
.doOnNext(databaseConsumer)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(resultSubscriber)
}
private fun broadcastProgress() {
postEvent(ProgressEvent(currentProgress.get(), maxProgress.get()))
}
private val resultSubscriber
get() = object : Subscriber<List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>> {
override fun onSubscribe(s: Subscription) {
loadingSubscription = s
s.request(java.lang.Long.MAX_VALUE)
}
override fun onNext(notification: List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>) {
if (DEBUG) Log.v(TAG, "onNext() → $notification")
}
override fun onError(error: Throwable) {
handleError(error)
}
override fun onComplete() {
if (maxProgress.get() == 0) {
postEvent(FeedEventManager.Event.IdleEvent)
stopService()
return
}
currentProgress.set(-1)
maxProgress.set(-1)
notificationUpdater.onNext(getString(R.string.feed_processing_message))
postEvent(ProgressEvent(R.string.feed_processing_message))
disposables.add(
Single
.fromCallable {
feedResultsHolder.ready()
postEvent(ProgressEvent(R.string.feed_processing_message))
feedDatabaseManager.removeOrphansOrOlderStreams()
postEvent(SuccessResultEvent(feedResultsHolder.itemsErrors))
true
}
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { _, throwable ->
// There seems to be a bug in the kotlin plugin as it tells you when
// building that this can't be null:
// "Condition 'throwable != null' is always 'true'"
// However it can indeed be null
// The suppression may be removed in further versions
@Suppress("SENSELESS_COMPARISON")
if (throwable != null) {
Log.e(TAG, "Error while storing result", throwable)
handleError(throwable)
return@subscribe
}
stopService()
}
)
}
}
private val databaseConsumer: Consumer<List<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>>
get() = Consumer {
feedDatabaseManager.database().runInTransaction {
for (notification in it) {
if (notification.isOnNext) {
val subscriptionId = notification.value!!.first
val info = notification.value!!.second
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
subscriptionManager.updateFromInfo(subscriptionId, info)
if (info.errors.isNotEmpty()) {
feedResultsHolder.addErrors(RequestException.wrapList(subscriptionId, info))
feedDatabaseManager.markAsOutdated(subscriptionId)
}
} else if (notification.isOnError) {
val error = notification.error!!
feedResultsHolder.addError(error)
if (error is RequestException) {
feedDatabaseManager.markAsOutdated(error.subscriptionId)
}
}
}
}
}
private val notificationsConsumer: Consumer<Notification<Pair<Long, ListInfo<StreamInfoItem>>>>
get() = Consumer { onItemCompleted(it.value?.second?.name) }
private fun onItemCompleted(updateDescription: String?) {
currentProgress.incrementAndGet()
notificationUpdater.onNext(updateDescription ?: "")
broadcastProgress()
}
// /////////////////////////////////////////////////////////////////////////
// Notification
// /////////////////////////////////////////////////////////////////////////
@ -354,13 +151,12 @@ class FeedLoadService : Service() {
private lateinit var notificationManager: NotificationManagerCompat
private lateinit var notificationBuilder: NotificationCompat.Builder
private var currentProgress = AtomicInteger(-1)
private var maxProgress = AtomicInteger(-1)
private fun createNotification(): NotificationCompat.Builder {
val cancelActionIntent = PendingIntent.getBroadcast(
this,
NOTIFICATION_ID, Intent(ACTION_CANCEL), 0
NOTIFICATION_ID,
Intent(ACTION_CANCEL),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
)
return NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
@ -376,33 +172,36 @@ class FeedLoadService : Service() {
notificationManager = NotificationManagerCompat.from(this)
notificationBuilder = createNotification()
val throttleAfterFirstEmission = Function { flow: Flowable<String> ->
val throttleAfterFirstEmission = Function { flow: Flowable<FeedLoadState> ->
flow.take(1).concatWith(flow.skip(1).throttleLatest(NOTIFICATION_SAMPLING_PERIOD.toLong(), TimeUnit.MILLISECONDS))
}
disposables.add(
notificationUpdater
.publish(throttleAfterFirstEmission)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::updateNotificationProgress)
)
notificationDisposable = feedLoadManager.notification
.publish(throttleAfterFirstEmission)
.observeOn(AndroidSchedulers.mainThread())
.doOnTerminate { notificationManager.cancel(NOTIFICATION_ID) }
.subscribe(this::updateNotificationProgress)
}
private fun updateNotificationProgress(updateDescription: String?) {
notificationBuilder.setProgress(maxProgress.get(), currentProgress.get(), maxProgress.get() == -1)
private fun updateNotificationProgress(state: FeedLoadState) {
notificationBuilder.setProgress(state.maxProgress, state.currentProgress, state.maxProgress == -1)
if (maxProgress.get() == -1) {
if (state.maxProgress == -1) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) notificationBuilder.setContentInfo(null)
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
notificationBuilder.setContentText(updateDescription)
if (state.updateDescription.isNotEmpty()) notificationBuilder.setContentText(state.updateDescription)
notificationBuilder.setContentText(state.updateDescription)
} else {
val progressText = this.currentProgress.toString() + "/" + maxProgress
val progressText = state.currentProgress.toString() + "/" + state.maxProgress
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText("$updateDescription ($progressText)")
if (state.updateDescription.isNotEmpty()) {
notificationBuilder.setContentText("${state.updateDescription} ($progressText)")
}
} else {
notificationBuilder.setContentInfo(progressText)
if (!updateDescription.isNullOrEmpty()) notificationBuilder.setContentText(updateDescription)
if (state.updateDescription.isNotEmpty()) {
notificationBuilder.setContentText(state.updateDescription)
}
}
}
@ -414,13 +213,12 @@ class FeedLoadService : Service() {
// /////////////////////////////////////////////////////////////////////////
private lateinit var broadcastReceiver: BroadcastReceiver
private val cancelSignal = AtomicBoolean()
private fun setupBroadcastReceiver() {
broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == ACTION_CANCEL) {
cancelSignal.set(true)
feedLoadManager.cancel()
}
}
}
@ -435,29 +233,4 @@ class FeedLoadService : Service() {
postEvent(ErrorResultEvent(error))
stopService()
}
// /////////////////////////////////////////////////////////////////////////
// Results Holder
// /////////////////////////////////////////////////////////////////////////
class ResultsHolder {
/**
* List of errors that may have happen during loading.
*/
internal lateinit var itemsErrors: List<Throwable>
private val itemsErrorsHolder: MutableList<Throwable> = ArrayList()
fun addError(error: Throwable) {
itemsErrorsHolder.add(error)
}
fun addErrors(errors: List<Throwable>) {
itemsErrorsHolder.addAll(errors)
}
fun ready() {
itemsErrors = itemsErrorsHolder.toList()
}
}
}

View File

@ -0,0 +1,7 @@
package org.schabi.newpipe.local.feed.service
data class FeedLoadState(
val updateDescription: String,
val maxProgress: Int,
val currentProgress: Int,
)

View File

@ -0,0 +1,19 @@
package org.schabi.newpipe.local.feed.service
class FeedResultsHolder {
/**
* List of errors that may have happen during loading.
*/
val itemsErrors: List<Throwable>
get() = itemsErrorsHolder
private val itemsErrorsHolder: MutableList<Throwable> = ArrayList()
fun addError(error: Throwable) {
itemsErrorsHolder.add(error)
}
fun addErrors(errors: List<Throwable>) {
itemsErrorsHolder.addAll(errors)
}
}

View File

@ -0,0 +1,34 @@
package org.schabi.newpipe.local.feed.service
import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.extractor.ListInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
data class FeedUpdateInfo(
val uid: Long,
@NotificationMode
val notificationMode: Int,
val name: String,
val avatarUrl: String,
val listInfo: ListInfo<StreamInfoItem>,
) {
constructor(
subscription: SubscriptionEntity,
listInfo: ListInfo<StreamInfoItem>,
) : this(
uid = subscription.uid,
notificationMode = subscription.notificationMode,
name = subscription.name,
avatarUrl = subscription.avatarUrl,
listInfo = listInfo,
)
/**
* Integer id, can be used as notification id, etc.
*/
val pseudoId: Int
get() = listInfo.url.hashCode()
lateinit var newStreams: List<StreamInfoItem>
}

View File

@ -7,6 +7,8 @@ import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionDAO
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.extractor.ListInfo
@ -14,6 +16,7 @@ import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.feed.FeedInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.FeedDatabaseManager
import org.schabi.newpipe.util.ExtractorHelper
class SubscriptionManager(context: Context) {
private val database = NewPipeDatabase.getInstance(context)
@ -66,13 +69,33 @@ class SubscriptionManager(context: Context) {
}
}
fun updateNotificationMode(serviceId: Int, url: String, @NotificationMode mode: Int): Completable {
return subscriptionTable().getSubscription(serviceId, url)
.flatMapCompletable { entity: SubscriptionEntity ->
Completable.fromAction {
entity.notificationMode = mode
subscriptionTable().update(entity)
}.apply {
if (mode != NotificationMode.DISABLED) {
// notifications have just been enabled, mark all streams as "old"
andThen(rememberAllStreams(entity))
}
}
}
}
fun updateFromInfo(subscriptionId: Long, info: ListInfo<StreamInfoItem>) {
val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId)
if (info is FeedInfo) {
subscriptionEntity.name = info.name
} else if (info is ChannelInfo) {
subscriptionEntity.setData(info.name, info.avatarUrl, info.description, info.subscriberCount)
subscriptionEntity.setData(
info.name,
info.avatarUrl,
info.description,
info.subscriberCount
)
}
subscriptionTable.update(subscriptionEntity)
@ -94,4 +117,19 @@ class SubscriptionManager(context: Context) {
fun deleteSubscription(subscriptionEntity: SubscriptionEntity) {
subscriptionTable.delete(subscriptionEntity)
}
/**
* Fetches the list of videos for the provided channel and saves them in the database, so that
* they will be considered as "old"/"already seen" streams and the user will never be notified
* about any one of them.
*/
private fun rememberAllStreams(subscription: SubscriptionEntity): Completable {
return ExtractorHelper.getChannelInfo(subscription.serviceId, subscription.url, false)
.map { channel -> channel.relatedItems.map { stream -> StreamEntity(stream) } }
.flatMapCompletable { entities ->
Completable.fromAction {
database.streamDAO().upsertAll(entities)
}
}.onErrorComplete()
}
}

View File

@ -50,7 +50,7 @@ public class AppearanceSettingsFragment extends BasePreferenceFragment {
@Override
public boolean onPreferenceTreeClick(final Preference preference) {
if (preference.getKey().equals(getString(R.string.caption_settings_key))) {
if (getString(R.string.caption_settings_key).equals(preference.getKey())) {
try {
startActivity(new Intent(Settings.ACTION_CAPTIONING_SETTINGS));
} catch (final ActivityNotFoundException e) {

View File

@ -10,6 +10,7 @@ import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.local.feed.notifications.NotificationWorker;
import java.util.Optional;
@ -26,6 +27,8 @@ public class DebugSettingsFragment extends BasePreferenceFragment {
= findPreference(getString(R.string.show_memory_leaks_key));
final Preference showImageIndicatorsPreference
= findPreference(getString(R.string.show_image_indicators_key));
final Preference checkNewStreamsPreference
= findPreference(getString(R.string.check_new_streams_key));
final Preference crashTheAppPreference
= findPreference(getString(R.string.crash_the_app_key));
final Preference showErrorSnackbarPreference
@ -36,6 +39,7 @@ public class DebugSettingsFragment extends BasePreferenceFragment {
assert allowHeapDumpingPreference != null;
assert showMemoryLeaksPreference != null;
assert showImageIndicatorsPreference != null;
assert checkNewStreamsPreference != null;
assert crashTheAppPreference != null;
assert showErrorSnackbarPreference != null;
assert createErrorNotificationPreference != null;
@ -62,6 +66,11 @@ public class DebugSettingsFragment extends BasePreferenceFragment {
return true;
});
checkNewStreamsPreference.setOnPreferenceClickListener(preference -> {
NotificationWorker.runNow(preference.getContext());
return true;
});
crashTheAppPreference.setOnPreferenceClickListener(preference -> {
throw new RuntimeException(DUMMY);
});

View File

@ -70,7 +70,7 @@ public final class NewPipeSettings {
PreferenceManager.setDefaultValues(context, R.xml.appearance_settings, true);
PreferenceManager.setDefaultValues(context, R.xml.history_settings, true);
PreferenceManager.setDefaultValues(context, R.xml.content_settings, true);
PreferenceManager.setDefaultValues(context, R.xml.notification_settings, true);
PreferenceManager.setDefaultValues(context, R.xml.player_notification_settings, true);
PreferenceManager.setDefaultValues(context, R.xml.update_settings, true);
PreferenceManager.setDefaultValues(context, R.xml.debug_settings, true);

View File

@ -0,0 +1,120 @@
package org.schabi.newpipe.settings
import android.content.SharedPreferences
import android.content.SharedPreferences.OnSharedPreferenceChangeListener
import android.graphics.Color
import android.os.Bundle
import androidx.preference.Preference
import com.google.android.material.snackbar.Snackbar
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import org.schabi.newpipe.R
import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.ErrorUtil
import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.local.feed.notifications.NotificationHelper
import org.schabi.newpipe.local.feed.notifications.NotificationWorker
import org.schabi.newpipe.local.feed.notifications.ScheduleOptions
import org.schabi.newpipe.local.subscription.SubscriptionManager
class NotificationsSettingsFragment : BasePreferenceFragment(), OnSharedPreferenceChangeListener {
private var notificationWarningSnackbar: Snackbar? = null
private var loader: Disposable? = null
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.notifications_settings)
}
override fun onStart() {
super.onStart()
defaultPreferences.registerOnSharedPreferenceChangeListener(this)
}
override fun onStop() {
defaultPreferences.unregisterOnSharedPreferenceChangeListener(this)
super.onStop()
}
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
val context = context ?: return
if (key == getString(R.string.streams_notifications_interval_key) ||
key == getString(R.string.streams_notifications_network_key)
) {
// apply new configuration
NotificationWorker.schedule(context, ScheduleOptions.from(context), true)
} else if (key == getString(R.string.enable_streams_notifications)) {
if (NotificationHelper.areNewStreamsNotificationsEnabled(context)) {
// Start the worker, because notifications were disabled previously.
NotificationWorker.schedule(context)
} else {
// The user disabled the notifications. Cancel the worker to save energy.
// A new one will be created once the notifications are enabled again.
NotificationWorker.cancel(context)
}
}
}
override fun onResume() {
super.onResume()
// Check whether the notifications are disabled in the device's app settings.
// If they are disabled, show a snackbar informing the user about that
// while allowing them to open the device's app settings.
val enabled = NotificationHelper.areNotificationsEnabledOnDevice(requireContext())
preferenceScreen.isEnabled = enabled
if (!enabled) {
if (notificationWarningSnackbar == null) {
notificationWarningSnackbar = Snackbar.make(
listView,
R.string.notifications_disabled,
Snackbar.LENGTH_INDEFINITE
).apply {
setAction(R.string.settings) {
NotificationHelper.openNewPipeSystemNotificationSettings(it.context)
}
setActionTextColor(Color.YELLOW)
addCallback(object : Snackbar.Callback() {
override fun onDismissed(transientBottomBar: Snackbar, event: Int) {
super.onDismissed(transientBottomBar, event)
notificationWarningSnackbar = null
}
})
show()
}
}
} else {
notificationWarningSnackbar?.dismiss()
notificationWarningSnackbar = null
}
// (Re-)Create loader
loader?.dispose()
loader = SubscriptionManager(requireContext())
.subscriptions()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::updateSubscriptions, this::onError)
}
override fun onPause() {
loader?.dispose()
loader = null
super.onPause()
}
private fun updateSubscriptions(subscriptions: List<SubscriptionEntity>) {
val notified = subscriptions.count { it.notificationMode != NotificationMode.DISABLED }
val preference = findPreference<Preference>(getString(R.string.streams_notifications_channels_key))
preference?.apply { summary = "$notified/${subscriptions.size}" }
}
private fun onError(e: Throwable) {
ErrorUtil.showSnackbar(
this,
ErrorInfo(e, UserAction.SUBSCRIPTION_GET, "Get subscriptions list")
)
}
}

View File

@ -0,0 +1,19 @@
package org.schabi.newpipe.settings
import android.os.Build
import android.os.Bundle
import androidx.preference.Preference
import org.schabi.newpipe.R
class PlayerNotificationSettingsFragment : BasePreferenceFragment() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResourceRegistry()
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
val colorizePref: Preference? = findPreference(getString(R.string.notification_colorize_key))
colorizePref?.let {
preferenceScreen.removePreference(it)
}
}
}
}

View File

@ -36,7 +36,8 @@ public final class SettingsResourceRegistry {
add(DebugSettingsFragment.class, R.xml.debug_settings).setSearchable(false);
add(DownloadSettingsFragment.class, R.xml.download_settings);
add(HistorySettingsFragment.class, R.xml.history_settings);
add(NotificationSettingsFragment.class, R.xml.notification_settings);
add(NotificationSettingsFragment.class, R.xml.notifications_settings);
add(PlayerNotificationSettingsFragment.class, R.xml.player_notification_settings);
add(UpdateSettingsFragment.class, R.xml.update_settings);
add(VideoAudioSettingsFragment.class, R.xml.video_audio_settings);
}

View File

@ -0,0 +1,124 @@
package org.schabi.newpipe.settings.notifications
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.CheckedTextView
import androidx.recyclerview.widget.AsyncListDiffer
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.RecyclerView
import org.schabi.newpipe.R
import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.settings.notifications.NotificationModeConfigAdapter.SubscriptionHolder
/**
* This [RecyclerView.Adapter] is used in the [NotificationModeConfigFragment].
* The adapter holds all subscribed channels and their [NotificationMode]s
* and provides the needed data structures and methods for this task.
*/
class NotificationModeConfigAdapter(
private val listener: ModeToggleListener
) : RecyclerView.Adapter<SubscriptionHolder>() {
private val differ = AsyncListDiffer(this, DiffCallback())
init {
setHasStableIds(true)
}
override fun onCreateViewHolder(viewGroup: ViewGroup, i: Int): SubscriptionHolder {
val view = LayoutInflater.from(viewGroup.context)
.inflate(R.layout.item_notification_config, viewGroup, false)
return SubscriptionHolder(view, listener)
}
override fun onBindViewHolder(subscriptionHolder: SubscriptionHolder, i: Int) {
subscriptionHolder.bind(differ.currentList[i])
}
fun getItem(position: Int): SubscriptionItem = differ.currentList[position]
override fun getItemCount() = differ.currentList.size
override fun getItemId(position: Int): Long {
return differ.currentList[position].id
}
fun getCurrentList(): List<SubscriptionItem> = differ.currentList
fun update(newData: List<SubscriptionEntity>) {
differ.submitList(
newData.map {
SubscriptionItem(
id = it.uid,
title = it.name,
notificationMode = it.notificationMode,
serviceId = it.serviceId,
url = it.url
)
}
)
}
data class SubscriptionItem(
val id: Long,
val title: String,
@NotificationMode
val notificationMode: Int,
val serviceId: Int,
val url: String
)
class SubscriptionHolder(
itemView: View,
private val listener: ModeToggleListener
) : RecyclerView.ViewHolder(itemView), View.OnClickListener {
private val checkedTextView = itemView as CheckedTextView
init {
itemView.setOnClickListener(this)
}
fun bind(data: SubscriptionItem) {
checkedTextView.text = data.title
checkedTextView.isChecked = data.notificationMode != NotificationMode.DISABLED
}
override fun onClick(v: View) {
val mode = if (checkedTextView.isChecked) {
NotificationMode.DISABLED
} else {
NotificationMode.ENABLED
}
listener.onModeChange(bindingAdapterPosition, mode)
}
}
private class DiffCallback : DiffUtil.ItemCallback<SubscriptionItem>() {
override fun areItemsTheSame(oldItem: SubscriptionItem, newItem: SubscriptionItem): Boolean {
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: SubscriptionItem, newItem: SubscriptionItem): Boolean {
return oldItem == newItem
}
override fun getChangePayload(oldItem: SubscriptionItem, newItem: SubscriptionItem): Any? {
if (oldItem.notificationMode != newItem.notificationMode) {
return newItem.notificationMode
} else {
return super.getChangePayload(oldItem, newItem)
}
}
}
interface ModeToggleListener {
/**
* Triggered when the UI representation of a notification mode is changed.
*/
fun onModeChange(position: Int, @NotificationMode mode: Int)
}
}

View File

@ -0,0 +1,119 @@
package org.schabi.newpipe.settings.notifications
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.RecyclerView
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.R
import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.settings.notifications.NotificationModeConfigAdapter.ModeToggleListener
/**
* [NotificationModeConfigFragment] is a settings fragment
* which allows changing the [NotificationMode] of all subscribed channels.
* The [NotificationMode] can either be changed one by one or toggled for all channels.
*/
class NotificationModeConfigFragment : Fragment(), ModeToggleListener {
private lateinit var updaters: CompositeDisposable
private var loader: Disposable? = null
private var adapter: NotificationModeConfigAdapter? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
updaters = CompositeDisposable()
setHasOptionsMenu(true)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View = inflater.inflate(R.layout.fragment_channels_notifications, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val recyclerView: RecyclerView = view.findViewById(R.id.recycler_view)
adapter = NotificationModeConfigAdapter(this)
recyclerView.adapter = adapter
loader?.dispose()
loader = SubscriptionManager(requireContext())
.subscriptions()
.observeOn(AndroidSchedulers.mainThread())
.subscribe { newData -> adapter?.update(newData) }
}
override fun onDestroyView() {
loader?.dispose()
loader = null
super.onDestroyView()
}
override fun onDestroy() {
updaters.dispose()
super.onDestroy()
}
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
super.onCreateOptionsMenu(menu, inflater)
inflater.inflate(R.menu.menu_notifications_channels, menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.action_toggle_all -> {
toggleAll()
true
}
else -> super.onOptionsItemSelected(item)
}
}
override fun onModeChange(position: Int, @NotificationMode mode: Int) {
// Notification mode has been changed via the UI.
// Now change it in the database.
val subscription = adapter?.getItem(position) ?: return
updaters.add(
SubscriptionManager(requireContext())
.updateNotificationMode(
subscription.serviceId,
subscription.url,
mode
)
.subscribeOn(Schedulers.io())
.subscribe()
)
}
private fun toggleAll() {
val subscriptions = adapter?.getCurrentList() ?: return
val mode = subscriptions.firstOrNull()?.notificationMode ?: return
val newMode = when (mode) {
NotificationMode.DISABLED -> NotificationMode.ENABLED
else -> NotificationMode.DISABLED
}
val subscriptionManager = SubscriptionManager(requireContext())
updaters.add(
CompositeDisposable(
subscriptions.map { item ->
subscriptionManager.updateNotificationMode(
serviceId = item.serviceId,
url = item.url,
mode = newMode
).subscribeOn(Schedulers.io())
.subscribe()
}
)
)
}
}

View File

@ -1,5 +1,7 @@
package org.schabi.newpipe.util;
import static org.schabi.newpipe.util.external_communication.ShareUtils.installApp;
import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.Context;
@ -18,6 +20,8 @@ import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
import com.jakewharton.processphoenix.ProcessPhoenix;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
@ -58,10 +62,6 @@ import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.ArrayList;
import static org.schabi.newpipe.util.external_communication.ShareUtils.installApp;
import com.jakewharton.processphoenix.ProcessPhoenix;
public final class NavigationHelper {
public static final String MAIN_FRAGMENT_TAG = "main_fragment_tag";
public static final String SEARCH_FRAGMENT_TAG = "search_fragment_tag";
@ -611,6 +611,12 @@ public final class NavigationHelper {
return getOpenIntent(context, url, service.getServiceId(), linkType);
}
public static Intent getChannelIntent(final Context context,
final int serviceId,
final String url) {
return getOpenIntent(context, url, serviceId, StreamingService.LinkType.CHANNEL);
}
/**
* Start an activity to install Kore.
*

View File

@ -1,14 +1,18 @@
package org.schabi.newpipe.util;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import android.annotation.SuppressLint;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import com.squareup.picasso.Cache;
import com.squareup.picasso.LruCache;
import com.squareup.picasso.OkHttp3Downloader;
import com.squareup.picasso.Picasso;
import com.squareup.picasso.RequestCreator;
import com.squareup.picasso.Target;
import com.squareup.picasso.Transformation;
import org.schabi.newpipe.R;
@ -16,11 +20,10 @@ import org.schabi.newpipe.R;
import java.io.File;
import java.io.IOException;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import okhttp3.OkHttpClient;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
public final class PicassoHelper {
public static final String PLAYER_THUMBNAIL_TAG = "PICASSO_PLAYER_THUMBNAIL_TAG";
private static final String PLAYER_THUMBNAIL_TRANSFORMATION_KEY
@ -156,6 +159,28 @@ public final class PicassoHelper {
}
public static void loadNotificationIcon(final String url,
final Consumer<Bitmap> bitmapConsumer) {
loadImageDefault(url, R.drawable.ic_newpipe_triangle_white)
.into(new Target() {
@Override
public void onBitmapLoaded(final Bitmap bitmap, final Picasso.LoadedFrom from) {
bitmapConsumer.accept(bitmap);
}
@Override
public void onBitmapFailed(final Exception e, final Drawable errorDrawable) {
bitmapConsumer.accept(null);
}
@Override
public void onPrepareLoad(final Drawable placeHolderDrawable) {
// Nothing to do
}
});
}
private static RequestCreator loadImageDefault(final String url, final int placeholderResId) {
if (!shouldLoadImages || isBlank(url)) {
return picassoInstance

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z" />
</vector>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M3,5H9V11H3V5M5,7V9H7V7H5M11,7H21V9H11V7M11,15H21V17H11V15M5,20L1.5,16.5L2.91,15.09L5,17.17L9.59,12.59L11,14L5,20Z" />
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M12,22c1.1,0 2,-0.9 2,-2h-4c0,1.1 0.89,2 2,2zM18,16v-5c0,-3.07 -1.64,-5.64 -4.5,-6.32L13.5,4c0,-0.83 -0.67,-1.5 -1.5,-1.5s-1.5,0.67 -1.5,1.5v0.68C7.63,5.36 6,7.92 6,11v5l-2,2v1h16v-1l-2,-2z" />
</vector>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</FrameLayout>

View File

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<CheckedTextView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="?listPreferredItemHeightSmall"
android:background="?selectableItemBackground"
android:ellipsize="end"
android:gravity="center_vertical"
android:maxLines="2"
android:orientation="horizontal"
android:paddingStart="?listPreferredItemPaddingStart"
android:paddingEnd="?listPreferredItemPaddingEnd"
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
android:drawableEnd="?android:listChoiceIndicatorMultiple"
tools:text="@tools:sample/lorem[4]" />

View File

@ -18,14 +18,23 @@
app:showAsAction="ifRoom" />
<item
android:id="@+id/action_settings"
android:id="@+id/menu_item_notify"
android:checkable="true"
android:orderInCategory="1"
android:title="@string/get_notified"
android:visible="false"
app:showAsAction="never"
tools:visible="true" />
<item
android:id="@+id/action_settings"
android:orderInCategory="2"
android:title="@string/settings"
app:showAsAction="never" />
<item
android:id="@+id/menu_item_openInBrowser"
android:orderInCategory="2"
android:orderInCategory="3"
android:title="@string/open_in_browser"
app:showAsAction="never" />
</menu>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/action_toggle_all"
android:icon="@drawable/ic_list_check"
android:title="@string/toggle_all"
app:showAsAction="ifRoom" />
</menu>

View File

@ -584,7 +584,6 @@
<string name="wifi_only">فقط على شبكة Wi-Fi</string>
<string name="autoplay_summary">بدء التشغيل تلقائياً — %s</string>
<string name="title_activity_play_queue">تشغيل قائمة الانتظار</string>
<string name="settings_category_notification_title">الإشعار</string>
<string name="unsupported_url_dialog_message">تعذر التعرف على الرابط. فتح باستخدام تطبيق آخر؟</string>
<string name="auto_queue_toggle">قائمة انتظار تلقائيّة</string>
<string name="clear_queue_confirmation_description">سيتم استبدال قائمة انتظار للمشغل النشط</string>

View File

@ -104,7 +104,6 @@
<string name="content">Məzmun</string>
<string name="popup_playing_toast">Ani pəncərədə oxudulur</string>
<string name="background_player_playing_toast">Fonda oxudulur</string>
<string name="settings_category_notification_title">Bildiriş</string>
<string name="settings_category_updates_title">Yeniləmələr</string>
<string name="settings_category_debug_title">Sazlama</string>
<string name="settings_category_appearance_title">Görünüş</string>

View File

@ -515,7 +515,6 @@
<string name="youtube_restricted_mode_enabled_summary">YouTube forne\'l «Mou torgáu» qu\'anubre conteníu\'l que seya potencialmente p\'adultos</string>
<string name="youtube_restricted_mode_enabled_title">Activar el «Mou torgáu» de YouTube</string>
<string name="show_age_restricted_content_summary">Amuesa\'l conteníu que quiciabes nun seya afayadizu pa guaḥes porque tien una llende d\'edá (como +18)</string>
<string name="settings_category_notification_title">Avisu permanente</string>
<string name="settings_category_debug_title">Depuración</string>
<string name="peertube_instance_add_https_only">Namás se sofiten URLs HTTPS</string>
<string name="peertube_instance_add_help">Introduz la URL d\'una instancia</string>

View File

@ -136,7 +136,6 @@
<string name="content">Tarkib</string>
<string name="popup_playing_toast">Pop-up rejimda ijro etish</string>
<string name="background_player_playing_toast">Ijro etish foni</string>
<string name="settings_category_notification_title">Bildirishnoma</string>
<string name="settings_category_updates_title">Yangilanishlar</string>
<string name="settings_category_debug_title">Nosozliklarni tuzatish</string>
<string name="settings_category_appearance_title">Tashqi ko\'rinish</string>

View File

@ -552,7 +552,6 @@
<string name="notification_action_0_title">第一操作按钮</string>
<string name="notification_scale_to_square_image_summary">将通知中视频缩略图的长宽比从 16:9 强制缩放到 1:1可能会导致失真</string>
<string name="notification_scale_to_square_image_title">强制缩放缩略图至 1:1 比例</string>
<string name="settings_category_notification_title">通知</string>
<string name="show_memory_leaks">显示内存泄漏</string>
<string name="enqueued">已加入播放队列</string>
<string name="enqueue_stream">加入播放队列</string>

View File

@ -481,7 +481,6 @@
<string name="notification_action_shuffle">Ператасаваць</string>
<string name="notification_action_repeat">Паўтор</string>
<string name="notification_action_4_title">Кнопка пятага дзеяння</string>
<string name="settings_category_notification_title">Паведамленні</string>
<string name="notification_colorize_summary">Афарбоўваць апавяшчэнне асноўным колерам мініяцюры. Падтрымваецца не ўсімі прыладамі</string>
<string name="notification_actions_at_most_three">У кампактным апавяшчэнні дасяжна не больш за тры дзеянні!</string>
<string name="notification_actions_summary">Дзеянні можна змяніць, націснуўшы на іх. Адзначце не больш за трох для адлюстравання ў кампактным апавяшчэнні</string>

View File

@ -511,7 +511,6 @@
<string name="songs">Песни</string>
<string name="artists">Изпълнители</string>
<string name="albums">Албуми</string>
<string name="settings_category_notification_title">Известие</string>
<string name="recent">Скорошни</string>
<string name="metadata_category">Категория</string>
<string name="download_has_started">Изтеглянето започна</string>

View File

@ -289,7 +289,6 @@
</plurals>
<string name="description_tab_description">বিবরণ</string>
<string name="comments_tab_description">মন্তব্য</string>
<string name="settings_category_notification_title">নোটিফিকেশন</string>
<string name="show_meta_info_title">মেটা ইনফো দেখান</string>
<string name="show_description_title">বিবরণ দেখান</string>
<string name="night_theme_title">রাত্রি থিম</string>

View File

@ -330,7 +330,6 @@
<item quantity="other">%s সদস্যতাগণ</item>
</plurals>
<string name="users">ব্যবহারকারীরা</string>
<string name="settings_category_notification_title">বিজ্ঞপ্তি</string>
<string name="resume_on_audio_focus_gain_summary">বাধার পর প্লে চালিয়ে যাও (উদাহরণস্বরূপ ফোনকল)</string>
<string name="subscriptions_export_unsuccessful">সদস্যতা রপ্তানি করা যায়নি</string>
<string name="subscriptions_import_unsuccessful">সদস্যতা আমদানি করা যায়নি</string>

View File

@ -557,7 +557,6 @@
<string name="hash_channel_name">Notificació de comprovació del vídeo</string>
<string name="youtube_restricted_mode_enabled_summary">YouTube proporciona un \"mode restringit\" que amaga contingut potencialment inadequat per a infants</string>
<string name="show_age_restricted_content_summary">Mostra contingut que podria ser inadequat pels infants</string>
<string name="settings_category_notification_title">Notificació</string>
<string name="unsupported_url_dialog_message">No s\'ha pogut reconèixer l\'adreça URL. Obrir-la amb una altra aplicació\?</string>
<string name="auto_queue_toggle">Posa a la cua automàticament</string>
<string name="show_meta_info_summary">Desactiveu-ho per deixar de mostrar les metadades, que contenen informació addicional sobre el creador del directe, el contingut o una sol·licitud de cerca</string>

View File

@ -541,7 +541,6 @@
<string name="error_report_open_github_notice">تكایه‌ پشكنینێك بكه‌ كه‌ ئاخۆ كێشه‌یه‌ك هه‌یه‌ باسی كڕاشه‌كه‌ت بكات. له‌كاتی سازدانی پلیتی لێكچوو ، كات له‌ ئێمه‌ ده‌گریت كه‌ ئێمه‌ سه‌رقاڵی چاره‌سه‌ركردنی هه‌مان كێشه‌ ده‌كه‌یت.</string>
<string name="error_report_open_issue_button_text">سكاڵا لەسەر GitHub</string>
<string name="copy_for_github">له‌به‌رگرتنه‌وه‌ی سكاڵای جۆركراو</string>
<string name="settings_category_notification_title">پەیام</string>
<string name="unsupported_url_dialog_message">ناتوانرێت به‌سته‌ره‌كه‌ بناسرێتەوە. بە بەرنامەیەکی دیكه‌ بکرێتەوە؟</string>
<string name="auto_queue_toggle">خستنه‌ نۆبه‌تی-خۆكاری</string>
<string name="clear_queue_confirmation_description">نۆبه‌ته‌كه‌ لە لێدەری چالاکەوە جێگۆڕکێی دەکرێت</string>

View File

@ -559,7 +559,6 @@
<string name="clear_queue_confirmation_description">Fronta aktivního přehrávače bude smazána</string>
<string name="clear_queue_confirmation_summary">Při přechodu z jednoho přehrávače do druhého může dojít k smazání fronty</string>
<string name="clear_queue_confirmation_title">Žádat potvrzení před vyklizením fronty</string>
<string name="settings_category_notification_title">Oznámení</string>
<string name="notification_action_nothing">Nic</string>
<string name="notification_action_buffering">Bufferovat</string>
<string name="notification_action_shuffle">Promíchat</string>

View File

@ -342,6 +342,8 @@
<string name="brightness_gesture_control_title">Gestensteuerung für Helligkeit</string>
<string name="brightness_gesture_control_summary">Gesten verwenden, um die Helligkeit einzustellen</string>
<string name="settings_category_updates_title">Aktualisierungen</string>
<string name="settings_category_player_notification_title">Wiedergabebenachrichtigung</string>
<string name="settings_category_player_notification_summary">Konfiguriert die Benachrichtigung zum aktuell abgespielten Stream</string>
<string name="file_deleted">Datei gelöscht</string>
<string name="app_update_notification_channel_name">Benachrichtigung über App-Update</string>
<string name="app_update_notification_channel_description">Benachrichtigungen über neue NewPipe-Versionen</string>
@ -550,7 +552,6 @@
<string name="never">Nie</string>
<string name="notification_actions_at_most_three">Du kannst maximal drei Aktionen auswählen, die in der Kompaktbenachrichtigung angezeigt werden sollen!</string>
<string name="notification_actions_summary">Bearbeite jede Benachrichtigungsaktion unten, indem du darauf tippst. Wähle mithilfe der Kontrollkästchen rechts bis zu drei aus, die in der Kompaktbenachrichtigung angezeigt werden sollen</string>
<string name="settings_category_notification_title">Benachrichtigung</string>
<string name="unsupported_url_dialog_message">Konnte die angegebene URL nicht erkennen. Mit einer anderen Anwendung öffnen\?</string>
<string name="notification_action_4_title">Fünfte Aktionstaste</string>
<string name="notification_action_3_title">Vierte Aktionstaste</string>

View File

@ -483,7 +483,6 @@
\n
\nΕνεργοποιήστε το «%1$s» στις ρυθμίσεις εάν θέλετε να το δείτε.</string>
<string name="youtube_restricted_mode_enabled_title">Λειτουργία περιορισμένης πρόσβασης του YouTube</string>
<string name="settings_category_notification_title">Ειδοποίηση</string>
<string name="unsupported_url_dialog_message">Δεν ήταν δυνατή η αναγνώριση της διεύθυνσης URL. Άνοιγμα με άλλη εφαρμογή;</string>
<string name="auto_queue_toggle">Αυτόματη προσθήκη στην ουρά</string>
<string name="clear_queue_confirmation_description">Η ουρά του ενεργού αναπαραγωγού θα αντικατασταθεί</string>

View File

@ -512,8 +512,7 @@
<string name="restricted_video">Tiu ĉi filmeto havas aĝlimon.
\n
\nŜalti \"%1$s\" en la agordoj, se vi volas vidi ĝin.</string>
<string name="settings_category_notification_title">Sciigo</string>
<string name="night_theme_title">Nokta etoso</string>
<string name="night_theme_title">Malhela etoso</string>
<string name="notification_colorize_title">farbi sciigon</string>
<string name="notification_action_buffering">Alŝuto</string>
<string name="notification_scale_to_square_image_title">Skali bildeton ĝis 1:1 proportio</string>

View File

@ -549,7 +549,6 @@
<string name="wifi_only">Solo en Wi-Fi</string>
<string name="autoplay_summary">Comenzar reproducción automáticamente — %s</string>
<string name="title_activity_play_queue">Reproducir cola</string>
<string name="settings_category_notification_title">Notificación</string>
<string name="unsupported_url_dialog_message">No se pudo reconocer la URL. ¿Abrir con otra aplicación\?</string>
<string name="auto_queue_toggle">Poner en cola automáticamente</string>
<string name="clear_queue_confirmation_summary">Cambiar de un reproductor a otro puede reemplazar la cola de reproducción</string>

View File

@ -484,7 +484,6 @@
\nKui sa soovid seda näha, siis lülita seadistustest „%1$s“ sisse.</string>
<string name="youtube_restricted_mode_enabled_summary">YouTube\'is leiduv „Piiratud režiim“ peidab võimaliku täiskasvanutele mõeldud sisu</string>
<string name="show_age_restricted_content_summary">Näita sisu, mis vanusepiirangu tõttu ilmselt ei sobi lastele (näiteks 18+)</string>
<string name="settings_category_notification_title">Teavitus</string>
<string name="peertube_instance_add_https_only">Sa saad kasutada vaid HTTPS-urle</string>
<string name="night_theme_title">Öine teema</string>
<string name="never">Ei iialgi</string>

View File

@ -547,7 +547,6 @@
<string name="show_age_restricted_content_summary">Adinez mugatuta dagoen eta haurrentzako desegokia izan daitezkeen edukia erakutsi (+18 adibidez)</string>
<string name="youtube_restricted_mode_enabled_summary">YouTube-ren \"Modu Murriztua\" helduentzako edukia izan daitekeen edukia ezkutatzen du</string>
<string name="youtube_restricted_mode_enabled_title">Piztu YouTube-ren \"Modu Murriztua\"</string>
<string name="settings_category_notification_title">Jakinarazpena</string>
<string name="unsupported_url_dialog_message">Ezin izan da URL-a ezagutu. Beste aplikazio batekin ireki\?</string>
<string name="auto_queue_toggle">Auto-ilara</string>
<string name="clear_queue_confirmation_description">Erreprodukzio ilara aktiboa ordezkatuko da</string>

View File

@ -542,7 +542,6 @@
<string name="wifi_only">فقط روی وای‌فای</string>
<string name="autoplay_summary">شروع خودکار پخش — %s</string>
<string name="title_activity_play_queue">پخش صف</string>
<string name="settings_category_notification_title">اعلان</string>
<string name="unsupported_url_dialog_message">نشانی قابل تشخیص نبود. با برنامه دیگری باز شود؟</string>
<string name="auto_queue_toggle">صف‌گذاری خودکار</string>
<string name="clear_queue_confirmation_description">صف پخش‌کنندهٔ فعال جایگزین می‌شود</string>

View File

@ -544,7 +544,6 @@
<string name="wifi_only">Vain Wi-Fi-verkossa</string>
<string name="autoplay_summary">Aloita toisto automaattisesti — %s</string>
<string name="title_activity_play_queue">Toistojono</string>
<string name="settings_category_notification_title">Ilmoitus</string>
<string name="unsupported_url_dialog_message">Ei tunnistettu URL:ää. Avataanko toisessa sovelluksessa\?</string>
<string name="auto_queue_toggle">Automaattinen jonoon lisääminen</string>
<string name="clear_queue_confirmation_description">Aktiivisen soittimen jono korvataan</string>

View File

@ -552,7 +552,6 @@
<string name="auto_queue_toggle">Ajouter automatiquement à la liste de lecture</string>
<string name="clear_queue_confirmation_description">La liste de lecture du lecteur actif sera remplacée</string>
<string name="clear_queue_confirmation_title">Confirmer av. de suppr. la liste de lecture</string>
<string name="settings_category_notification_title">Notification</string>
<string name="notification_action_nothing">Rien</string>
<string name="notification_action_buffering">Chargement</string>
<string name="notification_action_shuffle">Lire aléatoirement</string>

View File

@ -593,7 +593,6 @@
<string name="restricted_video_no_stream">Este vídeo ten restrición de idade.
\nDebido ás novas políticas de Youtube cos vídeos con restrición de idade, NewPipe non pode acceder ás transmisións do vídeo, polo que non pode reproducilo.</string>
<string name="youtube_restricted_mode_enabled_summary">Youtube ten un \"Modo Restrinxido\" que oculta contido potencialmente só para adultos</string>
<string name="settings_category_notification_title">Notificación</string>
<string name="unsupported_url_dialog_message">URL non recoñecido. Abrir con outra aplicación\?</string>
<string name="show_meta_info_title">Mostrar metainformación</string>
<string name="show_description_summary">Desactíveo para ocultar a descrición do vídeo e a información adicional</string>

View File

@ -569,7 +569,6 @@
<string name="clear_queue_confirmation_description">התור מהנגן הפעיל יוחלף</string>
<string name="clear_queue_confirmation_summary">מעבר מנגן אחד למשנהו עלול להחליף את התור שלך</string>
<string name="clear_queue_confirmation_title">לבקש אישור לפני מחיקת התור</string>
<string name="settings_category_notification_title">התראה</string>
<string name="notification_action_nothing">כלום</string>
<string name="notification_action_buffering">איסוף</string>
<string name="notification_action_shuffle">ערבוב</string>

View File

@ -463,7 +463,6 @@
<string name="youtube_restricted_mode_enabled_summary">यूट्यूब एक \"प्रतिबंधित मोड\" प्रदान करता है जो संभावित रूप से परिपक्व सामग्री को छुपाता है</string>
<string name="youtube_restricted_mode_enabled_title">यूट्यूब का \"प्रतिबंधित मोड\" चालू करें</string>
<string name="show_age_restricted_content_summary">बच्चों के लिए अनुपयुक्त सामग्री दिखाएं क्योंकि इसकी आयु सीमा है (जैसे 18)</string>
<string name="settings_category_notification_title">अधिसूचना</string>
<string name="peertube_instance_add_https_only">केवल HTTPS यूआरएल ही समर्थित हैं</string>
<string name="unsupported_url_dialog_message">URL की पहचान नहीं हो सकी। दूसरे ऐप से खोलें\?</string>
<string name="auto_queue_toggle">ऑटोमैटिकली कतार करे</string>

View File

@ -447,7 +447,6 @@
<string name="albums">Albumi</string>
<string name="songs">Pjesme</string>
<string name="channel_created_by">Napravio %s</string>
<string name="settings_category_notification_title">Obavijest</string>
<string name="never">Nikada</string>
<string name="enable_queue_limit">Ograniči popis preuzimanja</string>
<string name="downloads_storage_use_saf_title">Koristi birač mapa sustava (SAF)</string>

View File

@ -485,7 +485,6 @@
\n
\nEngedélyezze a(z) „%1$s” beállítást, ha meg szeretné tekinteni.</string>
<string name="show_age_restricted_content_summary">Gyermekek számára esetlegesen nem megfelelő, korhatáros tartalom megjelenítése (például 18+)</string>
<string name="settings_category_notification_title">Értesítés</string>
<string name="peertube_instance_add_https_only">Csak a HTTPS URL-ek támogatottak</string>
<string name="show_meta_info_title">Metainformációk megjelenítése</string>
<string name="clear_queue_confirmation_description">A jelenleg aktív lejátszási sor le lesz cserélve</string>

View File

@ -58,7 +58,6 @@
<string name="tab_about">Մասին</string>
<string name="channels">Ալիքներ</string>
<string name="all">Ամենը</string>
<string name="settings_category_notification_title">Ծանուցում</string>
<string name="settings_category_appearance_title">Տեսք</string>
<string name="settings_category_updates_title">Թարմացումներ</string>
<string name="enable_watch_history_title">Դիտման պատմություն</string>

View File

@ -539,7 +539,6 @@
<string name="clear_queue_confirmation_description">Antrean dari pemutar yang aktif akan digantikan</string>
<string name="clear_queue_confirmation_summary">Beralih ke pemutar yang lain mungkin akan mengganti antrean Anda</string>
<string name="clear_queue_confirmation_title">Konfirmasi sebelum mengosongkan antrean</string>
<string name="settings_category_notification_title">Notifikasi</string>
<string name="notification_action_nothing">Tidak ada</string>
<string name="notification_action_buffering">Bufer</string>
<string name="notification_action_shuffle">Aduk</string>

View File

@ -558,7 +558,6 @@
<string name="notification_action_buffering">Buffer in corso</string>
<string name="notification_actions_at_most_three">Nella notifica compatta è possibile visualizzare al massimo 3 azioni!</string>
<string name="notification_action_shuffle">Casuale</string>
<string name="settings_category_notification_title">Notifica</string>
<string name="notification_action_nothing">Niente</string>
<string name="notification_action_repeat">Ripeti</string>
<string name="notification_scale_to_square_image_title">Ridimensiona copertina alla proporzione 1:1</string>

View File

@ -552,7 +552,6 @@
<string name="notification_action_repeat">繰り返し</string>
<string name="notification_action_shuffle">シャッフル</string>
<string name="notification_action_buffering">バッファリング</string>
<string name="settings_category_notification_title">通知</string>
<string name="youtube_restricted_mode_enabled_summary">YouTube は、成人向けの可能性があるコンテンツを除外する「制限付きモード」を提供しています</string>
<string name="show_age_restricted_content_summary">年齢制限 (18+ など) の理由で、子供には不適切な可能性のあるコンテンツを表示する</string>
<string name="enqueue_stream">キューに追加</string>

View File

@ -193,7 +193,6 @@
<string name="content">Dilşad</string>
<string name="popup_playing_toast">Di moda popupê de dilîzin</string>
<string name="background_player_playing_toast">Di paşayê de dilîzin</string>
<string name="settings_category_notification_title">Agahdayin</string>
<string name="settings_category_updates_title">Nûvekirin</string>
<string name="settings_category_debug_title">Xeletkirin</string>
<string name="settings_category_appearance_title">Xuyabûnî</string>

View File

@ -497,7 +497,6 @@
<string name="remove_watched_popup_title">시청 기록을 지우겠습니까\?</string>
<string name="remove_watched">시청 기록 지우기</string>
<string name="title_activity_play_queue">재생목록 실행</string>
<string name="settings_category_notification_title">알림</string>
<string name="unsupported_url_dialog_message">URL을 인식할 수 없습니다. 다른 앱으로 여시겠습니까\?</string>
<string name="clear_queue_confirmation_title">대기열을 비우기 전 확인하도록 합니다.</string>
<string name="notification_colorize_summary">안드로이드에서 썸네일의 색상에 따라 알림 색상을 조절합니다. (지원되지 않는 기기가 있을 수 있습니다.)</string>

View File

@ -538,7 +538,6 @@
<string name="autoplay_summary">دەسپێکردنی کارپێکەر بەخۆکاری — %s</string>
<string name="title_activity_play_queue">لێدانی ڕیز</string>
<string name="no_playlist_bookmarked_yet">هیچ لیستەلێدانێک نیشانە نەکراوە</string>
<string name="settings_category_notification_title">پەیام</string>
<string name="unsupported_url_dialog_message">بەستەرەکە نەناسرایەوە. لە ئەپێکیتردا بکرێتەوە؟</string>
<string name="auto_queue_toggle">ڕیزبوونی خۆکار</string>
<string name="clear_queue_confirmation_description">ڕیزی لێدەری چالاک جێیدەگیرێتەوە</string>

View File

@ -330,7 +330,6 @@
<string name="youtube_restricted_mode_enabled_summary">Youtube turi „apribotą režimą“ kuriame slepiamas galimai suaugusiems skirtas turinys</string>
<string name="youtube_restricted_mode_enabled_title">Įjungti YouTube „apribotą režimą“</string>
<string name="show_age_restricted_content_summary">Rodyti turinį kuris gali būti netinkamas vaikams (18+)</string>
<string name="settings_category_notification_title">Pranešimai</string>
<string name="settings_category_updates_title">Atnaujinimai</string>
<string name="peertube_instance_add_exists">Kopija jau yra</string>
<string name="peertube_instance_add_https_only">Palaikomi tik HTTPS adresai</string>

View File

@ -260,7 +260,6 @@
<string name="content">Saturs</string>
<string name="popup_playing_toast">Atskaņo popup režīmā</string>
<string name="background_player_playing_toast">Atskaņo fonā</string>
<string name="settings_category_notification_title">Notifikācija</string>
<string name="settings_category_updates_title">Atjauninājumi</string>
<string name="settings_category_debug_title">Atkļūdošana</string>
<string name="settings_category_appearance_title">Izskats</string>

View File

@ -587,7 +587,6 @@
\nപ്രായ-നിയന്ത്രിത വീഡിയോകളുള്ള പുതിയ യൂട്യൂബ് നയങ്ങൾ കാരണം, ന്യൂപൈപ്പിന് അതിന്റെ വീഡിയോ സ്ട്രീമുകളിലൊന്നും ആക്സസ് ചെയ്യാൻ കഴിയില്ല, അതിനാൽ ഇത് പ്ലേ ചെയ്യാൻ കഴിയില്ല.</string>
<string name="youtube_restricted_mode_enabled_summary">പക്വതയുള്ള ഉള്ളടക്കം മറയ്ക്കുന്ന \"നിയന്ത്രിത മോഡ്\" യൂട്യൂബ് നൽകുന്നു</string>
<string name="show_age_restricted_content_summary">കുട്ടികൾക്ക് അനുയോജ്യമല്ലാത്ത ഉള്ളടക്കം കാണിക്കുക കാരണം അതിന് പ്രായപരിധി ഉണ്ട് (18+ പോലെ)</string>
<string name="settings_category_notification_title">അറിയിപ്പ്</string>
<string name="unsupported_url_dialog_message">URL തിരിച്ചറിയാൻ കഴിഞ്ഞില്ല. മറ്റൊരു അപ്ലിക്കേഷൻ ഉപയോഗിച്ച് തുറക്കണോ\?</string>
<string name="auto_queue_toggle">യാന്ത്രിക-ക്യൂ</string>
<string name="show_meta_info_summary">സ്ട്രീം സ്രഷ്ടാവ്, സ്ട്രീം ഉള്ളടക്കം അല്ലെങ്കിൽ ഒരു തിരയൽ അഭ്യർത്ഥന എന്നിവയെക്കുറിച്ചുള്ള കൂടുതൽ വിവരങ്ങൾ ഉൾക്കൊള്ളുന്ന മെറ്റാ വിവര ബോക്സുകൾ മറയ്ക്കുന്നതിന് ഓഫാക്കുക</string>

View File

@ -382,7 +382,6 @@
<item quantity="other">%d hari</item>
</plurals>
<string name="help">Bantuan</string>
<string name="settings_category_notification_title">Pemberitahuan</string>
<string name="open_with">Buka dengan</string>
<plurals name="listening">
<item quantity="other">%s pendengar</item>

View File

@ -552,7 +552,6 @@
<string name="title_activity_play_queue">Spill kø</string>
<string name="no_playlist_bookmarked_yet">Ingen spillelistebokmerker enda</string>
<string name="copy_for_github">Kopier formatert rapport</string>
<string name="settings_category_notification_title">Merknad</string>
<string name="notification_action_repeat">Gjenta</string>
<string name="notification_action_4_title">Femte handlingstast</string>
<string name="notification_action_3_title">Fjerde handlingstast</string>

View File

@ -537,7 +537,6 @@
<string name="youtube_restricted_mode_enabled_summary">YouTube biedt een \"beperkte modes\" aan, dit verbergt mogelijk materiaal voor volwassenen</string>
<string name="youtube_restricted_mode_enabled_title">YouTube \"beperkte modus\" aanzetten</string>
<string name="show_age_restricted_content_summary">Toon inhoud die mogelijk niet geschikt is voor kinderen omwille van een leeftijdslimiet (zoals 18+)</string>
<string name="settings_category_notification_title">Melding</string>
<string name="peertube_instance_add_exists">Kanaal bestaat al</string>
<string name="peertube_instance_add_https_only">Alleen HTTPS URL\'s worden ondersteund</string>
<string name="peertube_instance_add_fail">Kon kanaal niet valideren</string>

View File

@ -544,7 +544,6 @@
<string name="wifi_only">Enkel via Wi-Fi</string>
<string name="autoplay_summary">Start automatisch met afspelen — %s</string>
<string name="title_activity_play_queue">Speel wachtrij af</string>
<string name="settings_category_notification_title">Notificatie</string>
<string name="unsupported_url_dialog_message">Kon de URL niet herkennen. In een andere app openen\?</string>
<string name="clear_queue_confirmation_description">De actieve spelerswachtrij wordt vervangen</string>
<string name="clear_queue_confirmation_summary">Veranderen van één speler naar een andere kan jouw wachtrij vervangen</string>

View File

@ -584,7 +584,6 @@
<string name="youtube_restricted_mode_enabled_summary">ਯੂਟਿਊਬ \"ਪਾਬੰਦੀਸ਼ੁਦਾ ਮੋਡ\" ਉਪਲਬਧ ਕਰਾਉਂਦਾ ਹੈ ਜੋ ਬਾਲਗਾਂ ਵਾਲ਼ੀ ਸਮੱਗਰੀ ਲੁਕਾਉਂਦਾ ਹੈ</string>
<string name="youtube_restricted_mode_enabled_title">ਯੂਟਿਊਬ ਦਾ ਪਾਬੰਦੀਸ਼ੁਦਾ ਮੋਡ ਚਾਲੂ ਕਰੋ</string>
<string name="show_age_restricted_content_summary">ਉਹ ਸਮੱਗਰੀ ਵੀ ਵਿਖਾਓ ਜੋ ਉਮਰ-ਸੀਮਾ ਕਰਕੇ ਬੱਚਿਆਂ ਲਈ ਸ਼ਾਇਦ ਸਹੀ ਨਾ ਹੋਵੇ (ਜਿਵੇਂ 18+)</string>
<string name="settings_category_notification_title">ਇਤਲਾਹਾਂ</string>
<string name="peertube_instance_add_exists">ਸਥਿਤੀ ਪਹਿਲਾਂ ਨੂੰ ਮੌਜੂਦ ਹੈ</string>
<string name="peertube_instance_add_https_only">ਸਿਰਫ਼ HTTP URLs ਹੀ ਮਾਣਨਯੋਗ ਹਨ</string>
<string name="peertube_instance_add_fail">ਸਥਿਤੀ ਦੀ ਜਾਇਜ਼ਗੀ ਤਸਦੀਕ ਨਹੀਂ ਹੋ ਸਕੀ</string>

View File

@ -564,7 +564,6 @@
<string name="clear_queue_confirmation_description">Kolejka aktywnego odtwarzacza zostanie zastąpiona</string>
<string name="clear_queue_confirmation_summary">Przejście z jednego odtwarzacza na inny może zastąpić kolejkę</string>
<string name="clear_queue_confirmation_title">Poproś o potwierdzenie przed wyczyszczeniem kolejki</string>
<string name="settings_category_notification_title">Powiadomienie</string>
<string name="notification_action_nothing">Nic</string>
<string name="notification_action_buffering">Buforowanie</string>
<string name="notification_action_shuffle">Losuj</string>

View File

@ -551,7 +551,6 @@
<string name="clear_queue_confirmation_title">Pedir confirmação antes de limpar uma fila</string>
<string name="notification_action_shuffle">Aleatório</string>
<string name="notification_action_buffering">Carregando</string>
<string name="settings_category_notification_title">Notificação</string>
<string name="notification_action_nothing">Nada</string>
<string name="notification_action_repeat">Repetir</string>
<string name="notification_actions_at_most_three">Você pode selecionar até no máximo três botões para mostrar na notificação compacta!</string>

View File

@ -543,7 +543,6 @@
<string name="wifi_only">Apenas em Wi-Fi</string>
<string name="autoplay_summary">Iniciar reprodução automaticamente — %s</string>
<string name="title_activity_play_queue">Reproduzir fila</string>
<string name="settings_category_notification_title">Notificação</string>
<string name="unsupported_url_dialog_message">URL não reconhecido. Abrir com outra aplicação\?</string>
<string name="auto_queue_toggle">Enfileiramento automático</string>
<string name="clear_queue_confirmation_description">A fila do reprodutor ativo será substituída</string>

View File

@ -554,7 +554,6 @@
<string name="unsupported_url_dialog_message">URL não reconhecido. Abrir com outra aplicação\?</string>
<string name="auto_queue_toggle">Enfileiramento automático</string>
<string name="notification_action_shuffle">Baralhar</string>
<string name="settings_category_notification_title">Notificação</string>
<string name="wifi_only">Apenas em Wi-Fi</string>
<string name="notification_action_nothing">Nada</string>
<string name="clear_queue_confirmation_summary">Mudar de um reprodutor para outro pode substituir a sua fila</string>

View File

@ -363,7 +363,6 @@
<string name="youtube_restricted_mode_enabled_summary">YouTube oferă un \"Mod restricționat\" care ascunde conținutul potențial matur</string>
<string name="youtube_restricted_mode_enabled_title">Activați \"Modul restricționat\" de pe YouTube</string>
<string name="show_age_restricted_content_summary">Afișați conținut posibil nepotrivit pentru copii, deoarece are o limită de vârstă (cum ar fi 18+)</string>
<string name="settings_category_notification_title">Notificare</string>
<string name="unsupported_url_dialog_message">Adresa URL nu a putut fi recunoscută. Deschideți cu o altă aplicație\?</string>
<string name="show_meta_info_title">Afișează informațiile meta</string>
<string name="notification_colorize_summary">Faceți ca Android să personalizeze culoarea notificării în funcție de culoarea principală din miniatură (rețineți că aceasta nu este disponibilă pe toate dispozitivele)</string>

View File

@ -167,6 +167,11 @@
<item quantity="many">%sвидео</item>
<item quantity="other">%sвидео</item>
</plurals>
<plurals name="new_streams">
<item quantity="one">%s новое видео</item>
<item quantity="few">%s новых видео</item>
<item quantity="many">%s новых видео</item>
</plurals>
<string name="delete_item_search_history">Удалить этот элемент из истории поиска?</string>
<string name="main_page_content">Главная страница</string>
<string name="blank_page_summary">Пустая страница</string>
@ -561,7 +566,7 @@
<string name="clear_queue_confirmation_description">Очередь активного плеера будет заменена</string>
<string name="clear_queue_confirmation_title">Подтверждать очистку очереди</string>
<string name="clear_queue_confirmation_summary">Переход от одного плеера к другому может заменить вашу очередь</string>
<string name="settings_category_notification_title">Уведомление</string>
<string name="settings_category_player_notification_summary">Настроить уведомление о воспроизводимом сейчас потоке</string>
<string name="notification_action_nothing">Ничего</string>
<string name="notification_action_buffering">Буферизация</string>
<string name="notification_action_shuffle">Перемешать</string>
@ -686,6 +691,20 @@
<string name="manual_update_title">Проверить обновления</string>
<string name="checking_updates_toast">Проверка обновлений…</string>
<string name="feed_new_items">Новое на канале</string>
<string name="report_player_errors_title">Отчёт об ошибках плеера</string>
<string name="report_player_errors_summary">Подробные отчёты об ошибках плеера вместо коротких всплывающих сообщений (полезно при диагностике проблем)</string>
<string name="notifications">Уведомления</string>
<string name="streams_notification_channel_name">Новые видео</string>
<string name="streams_notification_channel_description">Уведомления о новых видео в подписках</string>
<string name="streams_notifications_interval_title">Частота проверки</string>
<string name="enable_streams_notifications_title">Уведомлять о новых видео</string>
<string name="enable_streams_notifications_summary">Получать уведомления о новых видео из каналов, на которые Вы подписаны</string>
<string name="streams_notifications_network_title">Тип подключения</string>
<string name="any_network">Любая сеть</string>
<string name="notifications_disabled">Уведомления отключены</string>
<string name="get_notified">Уведомлять</string>
<string name="you_successfully_subscribed">Вы подписались на канал</string>
<string name="toggle_all">Переключить все</string>
<string name="show_crash_the_player_title">Показать \"Вызвать сбой плеера\"</string>
<string name="show_crash_the_player_summary">Показать функцию вызова сбоя при работе плеера</string>
<string name="crash_the_player">Вызвать сбой плеера</string>

View File

@ -548,7 +548,6 @@
<string name="clear_queue_confirmation_description">Sa lista dae su riproduidore ativu at a èssere remplasada</string>
<string name="clear_queue_confirmation_summary">Colende dae unu riproduidore a s\'àteru dias pòdere remplasare sa lista tua</string>
<string name="clear_queue_confirmation_title">Pedi una cunfirma in antis de iscantzellare una lista</string>
<string name="settings_category_notification_title">Notìfica</string>
<string name="notification_action_shuffle">Òrdine casuale</string>
<string name="notification_actions_summary">Modìfica cada atzione de notìfica inoghe in suta incarchende·la. Ischerta·nde finas a tres de ammustrare in sa notìfica cumpata impreende sas casellas de controllu a destra</string>
<string name="notification_scale_to_square_image_summary">Iscala sa miniadura ammustrada in sa notìfica dae su formadu in 16:9 a cussu 1:1 (diat pòdere causare istorchimentos)</string>

View File

@ -554,7 +554,6 @@
<string name="no_playlist_bookmarked_yet">Zatiaľ bez záložiek zoznamu</string>
<string name="select_a_playlist">Vyberte zoznam skladieb</string>
<string name="error_report_open_github_notice">Skontrolujte prosím, či rovnaká chyba už nie je nahlásená. Vytváranie duplicitných hlásení komplikuje prácu vývojárov.</string>
<string name="settings_category_notification_title">Oznámenia</string>
<string name="unsupported_url_dialog_message">Nemožno rozpoznať URL. Otvoriť pomocou inej aplikácie\?</string>
<string name="auto_queue_toggle">Automatický rad</string>
<string name="clear_queue_confirmation_description">Zoznam aktuálneho prehrávača bude prepísaný</string>

View File

@ -425,7 +425,6 @@
<string name="youtube_restricted_mode_enabled_summary">Youtube ponuja \"omejeni način\", ki skrije potencialno vsebino za odrasle</string>
<string name="youtube_restricted_mode_enabled_title">Vklop YouTubovega \"omejenega načina\"</string>
<string name="show_age_restricted_content_summary">Prikaz vsebin, ki so morda neprimerne za otroke zaradi omejitve starosti (kot na primer 18+)</string>
<string name="settings_category_notification_title">Obvestilo</string>
<string name="peertube_instance_add_exists">Instanca že obstaja</string>
<string name="peertube_instance_add_fail">Validacija instance ni bila mogoča</string>
<string name="peertube_instance_add_help">Vnesite URL instance</string>

View File

@ -360,7 +360,6 @@
<string name="content">Luuqada &amp; Fadhiga Kale</string>
<string name="popup_playing_toast">Ku daaraya daaqada</string>
<string name="background_player_playing_toast">Ka daaraya xaga dambe</string>
<string name="settings_category_notification_title">Ogaysiisyada</string>
<string name="settings_category_updates_title">Cusboonaysiinta</string>
<string name="settings_category_debug_title">Cilad bixinta</string>
<string name="settings_category_appearance_title">Nashqada</string>

View File

@ -544,7 +544,6 @@
<string name="never">Kurrë</string>
<string name="autoplay_summary">Nise luajtjen automatikisht — %s</string>
<string name="title_activity_play_queue">Lista e luajtjes</string>
<string name="settings_category_notification_title">Njoftim</string>
<string name="unsupported_url_dialog_message">Nuk u njoh URL. Të hapet me një aplikacion tjetër\?</string>
<string name="auto_queue_toggle">Listë automatike luajtjeje</string>
<string name="clear_queue_confirmation_description">Lista aktive e luajtjes do të zëvendësohet</string>

View File

@ -484,7 +484,6 @@
<string name="youtube_restricted_mode_enabled_summary">Јутјуб омогућава „Ограничени режим“ који скрива потенцијални садржај за одрасле</string>
<string name="youtube_restricted_mode_enabled_title">Укључити Јутјубов „Ограничени режим“</string>
<string name="show_age_restricted_content_summary">Приказ садржаја који можда није прикладан за децу јер има старосну границу (попут 18+)</string>
<string name="settings_category_notification_title">Обавештење</string>
<string name="settings_category_updates_title">Ажурирања</string>
<string name="peertube_instance_add_exists">Инстанца већ постоји</string>
<string name="peertube_instance_add_https_only">Подржане су само HTTPS УРЛ адресе</string>

View File

@ -529,7 +529,6 @@
<string name="notification_scale_to_square_image_summary">Skala videominiatyrbilden som visas i aviseringen från 16:9- till 1:1-förhållande (kan orsaka bildförvrängning)</string>
<string name="autoplay_summary">Starta uppspelning automatiskt — %s</string>
<string name="title_activity_play_queue">Uppspelningskö</string>
<string name="settings_category_notification_title">Aviseringar</string>
<string name="unsupported_url_dialog_message">Kunde inte känna igen URL:en. Vill du öppna med annan app\?</string>
<string name="auto_queue_toggle">Köa automatiskt</string>
<string name="clear_queue_confirmation_description">Den aktiva spellistan kommer att ersättas</string>

View File

@ -229,7 +229,6 @@
<string name="peertube_instance_add_help">ఉదాహరణ URLని నమోదు చేయండి</string>
<string name="peertube_instance_add_fail">ఉదాహరణను ధృవీకరించడం సాధ్యపడలేదు</string>
<string name="peertube_instance_add_exists">ఉదాహరణ ఇప్పటికే ఉంది</string>
<string name="settings_category_notification_title">నోటిఫికేషన్</string>
<string name="users">వినియోగదారులు</string>
<string name="events">ఈవెంట్స్</string>
<string name="app_update_notification_channel_description">కొత్త NewPipe వెర్షన్ కోసం నోటిఫికేషన్‌లు</string>

View File

@ -549,7 +549,6 @@
<string name="clear_queue_confirmation_description">Etkin oynatıcının kuyruğu değiştirilecek</string>
<string name="clear_queue_confirmation_summary">Bir oynatıcıdan diğerine geçmek kuyruğunuzu değiştirebilir</string>
<string name="clear_queue_confirmation_title">Bir kuyruğu temizlemeden önce onay iste</string>
<string name="settings_category_notification_title">Bildirim</string>
<string name="notification_action_nothing">Hiçbir şey</string>
<string name="notification_action_buffering">Ara belleğe alınıyor</string>
<string name="notification_action_shuffle">Karıştır</string>

View File

@ -133,7 +133,6 @@
<string name="downloads_title">Tagamin</string>
<string name="downloads">Tagamin</string>
<string name="duration_live">Usrid</string>
<string name="settings_category_notification_title">Tineɣmisin</string>
<string name="settings_category_updates_title">Tisdɣiwin</string>
<string name="settings_category_player_title">Ameɣri</string>
<string name="download_dialog_title">Agem</string>

View File

@ -579,7 +579,7 @@
<string name="description_tab_description">Опис</string>
<string name="related_items_tab_description">Повʼязані елементи</string>
<string name="comments_tab_description">Коментарі</string>
<string name="settings_category_notification_title">Сповіщення</string>
<string name="settings_category_player_notification_summary">Налаштувати повідомлення про відтворюваний наразі потік</string>
<string name="unsupported_url_dialog_message">Не розпізнано URL. Відкрити через іншу програму\?</string>
<string name="auto_queue_toggle">Автоматична черга</string>
<string name="show_meta_info_title">Показувати метадані</string>

View File

@ -472,7 +472,6 @@
<string name="youtube_restricted_mode_enabled_summary">یوٹیوب ایک \"پابندی والا وضع\" فراہم کرتا ہے جو امکانی طور پر نازیبا مواد کو چھپاتا ہے</string>
<string name="youtube_restricted_mode_enabled_title">یوٹیوب کا \"پابندی والا وضع\" چالو کریں</string>
<string name="show_age_restricted_content_summary">وہ مواد دکھائیں جو بچوں کے لیے ممکنہ طور پر نا مناسب ہیں کیوں کہ اس میں عمر کی حد ہے (جیسے 18+)</string>
<string name="settings_category_notification_title">اطلاع</string>
<string name="unsupported_url_dialog_message">URL کو نہیں پہچان سکے۔ کسی اور ایپ کے ساتھ کھولیں؟</string>
<string name="auto_queue_toggle">ازخود قطار</string>
<string name="show_meta_info_summary">اسٹریم کے موجد، اسٹریم مواد یا تلاش کی درخواست کے بارے میں اضافی معلومات والے میٹا انفارمیشن بکسوں کو چھپانے کیلئے بند کریں۔</string>

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