Merge remote-tracking branch 'origin/1473_remove_duplicates_from_playlist' into 1473_remove_duplicates_from_playlist

This commit is contained in:
Jared Fantaye 2023-02-28 16:44:13 +01:00
commit 265de55a07
308 changed files with 5310 additions and 1395 deletions

View File

@ -1,3 +1,7 @@
import com.android.tools.profgen.ArtProfileKt
import com.android.tools.profgen.ArtProfileSerializer
import com.android.tools.profgen.DexFile
plugins {
id "com.android.application"
id "kotlin-android"
@ -16,8 +20,8 @@ android {
resValue "string", "app_name", "NewPipe"
minSdk 21
targetSdk 33
versionCode 991
versionName "0.24.1"
versionCode 992
versionName "0.25.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -102,7 +106,7 @@ ext {
androidxWorkVersion = '2.7.1'
icepickVersion = '3.2.0'
exoPlayerVersion = '2.18.2'
exoPlayerVersion = '2.18.1'
googleAutoServiceVersion = '1.0.1'
groupieVersion = '2.10.1'
markwonVersion = '4.6.2'
@ -179,7 +183,7 @@ sonar {
dependencies {
/** Desugaring **/
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.8'
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.2'
/** NewPipe libraries **/
// You can use a local version by uncommenting a few lines in settings.gradle
@ -187,7 +191,7 @@ dependencies {
// name and the commit hash with the commit hash of the (pushed) commit you want to test
// This works thanks to JitPack: https://jitpack.io/
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
implementation 'com.github.TeamNewPipe:NewPipeExtractor:2211a24b6934a8a8cdf5547ea1b52daa4cb5de6c'
implementation 'com.github.TeamNewPipe:NewPipeExtractor:7e793c11aec46358ccbfd8bcfcf521105f4f093a'
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
/** Checkstyle **/
@ -308,3 +312,24 @@ static String getGitWorkingBranch() {
return ""
}
}
project.afterEvaluate {
tasks.compileReleaseArtProfile.doLast {
outputs.files.each { file ->
if (file.toString().endsWith(".profm")) {
println("Sorting ${file} ...")
def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
def profile = ArtProfileKt.ArtProfile(file)
def keys = new ArrayList(profile.profileData.keySet())
def sortedData = new LinkedHashMap()
Collections.sort keys, new DexFile.Companion()
keys.each { key -> sortedData[key] = profile.profileData[key] }
new FileOutputStream(file).with {
write(version.magicBytes$profgen)
write(version.versionBytes$profgen)
version.write$profgen(it, sortedData, "")
}
}
}
}
}

View File

@ -0,0 +1,737 @@
{
"formatVersion": 1,
"database": {
"version": 6,
"identityHash": "4084aa342aef315dc7b558770a7755a9",
"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"
],
"orders": [],
"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}` (`creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)",
"fields": [
{
"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
},
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"id"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_search_history_search",
"unique": false,
"columnNames": [
"search"
],
"orders": [],
"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"
],
"orders": [],
"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"
],
"orders": [],
"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, `is_thumbnail_permanent` INTEGER NOT NULL)",
"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
},
{
"fieldPath": "isThumbnailPermanent",
"columnName": "is_thumbnail_permanent",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"columnNames": [
"uid"
],
"autoGenerate": true
},
"indices": [
{
"name": "index_playlists_name",
"unique": false,
"columnNames": [
"name"
],
"orders": [],
"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"
],
"orders": [],
"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"
],
"orders": [],
"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"
],
"orders": [],
"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"
],
"orders": [],
"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"
],
"orders": [],
"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"
],
"orders": [],
"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"
],
"orders": [],
"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, '4084aa342aef315dc7b558770a7755a9')"
]
}
}

View File

@ -33,7 +33,8 @@ class DatabaseMigrationTest {
@get:Rule
val testHelper = MigrationTestHelper(
InstrumentationRegistry.getInstrumentation(),
AppDatabase::class.java.canonicalName, FrameworkSQLiteOpenHelperFactory()
AppDatabase::class.java.canonicalName,
FrameworkSQLiteOpenHelperFactory()
)
@Test
@ -42,7 +43,8 @@ class DatabaseMigrationTest {
databaseInV2.run {
insert(
"streams", SQLiteDatabase.CONFLICT_FAIL,
"streams",
SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", DEFAULT_SERVICE_ID)
put("url", DEFAULT_URL)
@ -54,14 +56,16 @@ class DatabaseMigrationTest {
}
)
insert(
"streams", SQLiteDatabase.CONFLICT_FAIL,
"streams",
SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", DEFAULT_SECOND_SERVICE_ID)
put("url", DEFAULT_SECOND_URL)
}
)
insert(
"streams", SQLiteDatabase.CONFLICT_FAIL,
"streams",
SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", DEFAULT_SERVICE_ID)
}
@ -70,18 +74,31 @@ class DatabaseMigrationTest {
}
testHelper.runMigrationsAndValidate(
AppDatabase.DATABASE_NAME, Migrations.DB_VER_3,
true, Migrations.MIGRATION_2_3
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
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
AppDatabase.DATABASE_NAME,
Migrations.DB_VER_5,
true,
Migrations.MIGRATION_4_5
)
testHelper.runMigrationsAndValidate(
AppDatabase.DATABASE_NAME,
Migrations.DB_VER_6,
true,
Migrations.MIGRATION_5_6
)
val migratedDatabaseV3 = getMigratedDatabase()
@ -121,7 +138,8 @@ class DatabaseMigrationTest {
private fun getMigratedDatabase(): AppDatabase {
val database: AppDatabase = Room.databaseBuilder(
ApplicationProvider.getApplicationContext(),
AppDatabase::class.java, AppDatabase.DATABASE_NAME
AppDatabase::class.java,
AppDatabase.DATABASE_NAME
)
.build()
testHelper.closeWhenFinished(database)

View File

@ -11,6 +11,14 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- We need to be able to open links in the browser on API 30+ -->
<queries>
<intent>
<action android:name="android.intent.action.VIEW" />
<data android:scheme="http|https|market" />
</intent>
</queries>
<uses-feature
android:name="android.hardware.touchscreen"
android:required="false" />
@ -165,6 +173,7 @@
<data android:pathPrefix="/watch" />
<data android:pathPrefix="/attribution_link" />
<data android:pathPrefix="/shorts/" />
<data android:pathPrefix="/live/" />
<!-- channel prefix -->
<data android:pathPrefix="/channel/" />
<data android:pathPrefix="/user/" />

View File

@ -235,7 +235,7 @@ public class MainActivity extends AppCompatActivity {
.setIcon(R.drawable.ic_tv);
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title)
.setIcon(R.drawable.ic_rss_feed);
.setIcon(R.drawable.ic_subscriptions);
drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks)
.setIcon(R.drawable.ic_bookmark);

View File

@ -5,6 +5,7 @@ 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 static org.schabi.newpipe.database.Migrations.MIGRATION_5_6;
import android.content.Context;
import android.database.Cursor;
@ -24,7 +25,8 @@ 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, MIGRATION_4_5)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
MIGRATION_5_6)
.build();
}

View File

@ -10,12 +10,14 @@ import android.content.DialogInterface;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.os.Build;
import android.os.Bundle;
import android.text.TextUtils;
import android.view.ContextThemeWrapper;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.RadioButton;
import android.widget.RadioGroup;
@ -31,7 +33,12 @@ import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.app.NotificationCompat;
import androidx.core.app.ServiceCompat;
import androidx.core.math.MathUtils;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.DefaultLifecycleObserver;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.database.stream.model.StreamEntity;
@ -80,9 +87,13 @@ import org.schabi.newpipe.util.urlfinder.UrlFinder;
import org.schabi.newpipe.views.FocusOverlayView;
import java.io.Serializable;
import java.lang.ref.Reference;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
import icepick.Icepick;
import icepick.State;
@ -91,7 +102,6 @@ import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.functions.Consumer;
import io.reactivex.rxjava3.schedulers.Schedulers;
/**
@ -111,12 +121,57 @@ public class RouterActivity extends AppCompatActivity {
private boolean selectionIsDownload = false;
private boolean selectionIsAddToPlaylist = false;
private AlertDialog alertDialogChoice = null;
private FragmentManager.FragmentLifecycleCallbacks dismissListener = null;
@Override
protected void onCreate(final Bundle savedInstanceState) {
ThemeHelper.setDayNightMode(this);
setTheme(ThemeHelper.isLightThemeSelected(this)
? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark);
Localization.assureCorrectAppLanguage(this);
// Pass-through touch events to background activities
// so that our transparent window won't lock UI in the mean time
// network request is underway before showing PlaylistDialog or DownloadDialog
// (ref: https://stackoverflow.com/a/10606141)
getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
| WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE);
// Android never fails to impress us with a list of new restrictions per API.
// Starting with S (Android 12) one of the prerequisite conditions has to be met
// before the FLAG_NOT_TOUCHABLE flag is allowed to kick in:
// @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE
// For our present purpose it seems we can just set LayoutParams.alpha to 0
// on the strength of "4. Fully transparent windows" without affecting the scrim of dialogs
final WindowManager.LayoutParams params = getWindow().getAttributes();
params.alpha = 0f;
getWindow().setAttributes(params);
super.onCreate(savedInstanceState);
Icepick.restoreInstanceState(this, savedInstanceState);
// FragmentManager will take care to recreate (Playlist|Download)Dialog when screen rotates
// We used to .setOnDismissListener(dialog -> finish()); when creating these DialogFragments
// but those callbacks won't survive a config change
// Try an alternate approach to hook into FragmentManager instead, to that effect
// (ref: https://stackoverflow.com/a/44028453)
final FragmentManager fm = getSupportFragmentManager();
if (dismissListener == null) {
dismissListener = new FragmentManager.FragmentLifecycleCallbacks() {
@Override
public void onFragmentDestroyed(@NonNull final FragmentManager fm,
@NonNull final Fragment f) {
super.onFragmentDestroyed(fm, f);
if (f instanceof DialogFragment && fm.getFragments().isEmpty()) {
// No more DialogFragments, we're done
finish();
}
}
};
}
fm.registerFragmentLifecycleCallbacks(dismissListener, false);
if (TextUtils.isEmpty(currentUrl)) {
currentUrl = getUrl(getIntent());
@ -125,11 +180,6 @@ public class RouterActivity extends AppCompatActivity {
finish();
}
}
ThemeHelper.setDayNightMode(this);
setTheme(ThemeHelper.isLightThemeSelected(this)
? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark);
Localization.assureCorrectAppLanguage(this);
}
@Override
@ -151,16 +201,34 @@ public class RouterActivity extends AppCompatActivity {
protected void onStart() {
super.onStart();
handleUrl(currentUrl);
// Don't overlap the DialogFragment after rotating the screen
// If there's no DialogFragment, we're either starting afresh
// or we didn't make it to PlaylistDialog or DownloadDialog before the orientation change
if (getSupportFragmentManager().getFragments().isEmpty()) {
// Start over from scratch
handleUrl(currentUrl);
}
}
@Override
protected void onDestroy() {
super.onDestroy();
if (dismissListener != null) {
getSupportFragmentManager().unregisterFragmentLifecycleCallbacks(dismissListener);
}
disposables.clear();
}
@Override
public void finish() {
// allow the activity to recreate in case orientation changes
if (!isChangingConfigurations()) {
super.finish();
}
}
private void handleUrl(final String url) {
disposables.add(Observable
.fromCallable(() -> {
@ -240,7 +308,7 @@ public class RouterActivity extends AppCompatActivity {
}
}
private void showUnsupportedUrlDialog(final String url) {
protected void showUnsupportedUrlDialog(final String url) {
final Context context = getThemeWrapperContext();
new AlertDialog.Builder(context)
.setTitle(R.string.unsupported_url)
@ -527,7 +595,7 @@ public class RouterActivity extends AppCompatActivity {
return returnedItems;
}
private Context getThemeWrapperContext() {
protected Context getThemeWrapperContext() {
return new ContextThemeWrapper(this, ThemeHelper.isLightThemeSelected(this)
? R.style.LightTheme : R.style.DarkTheme);
}
@ -563,8 +631,7 @@ public class RouterActivity extends AppCompatActivity {
}
if (selectedChoiceKey.equals(getString(R.string.popup_player_key))
&& !PermissionHelper.isPopupEnabled(this)) {
PermissionHelper.showPopupEnablementToast(this);
&& !PermissionHelper.isPopupEnabledElseAsk(this)) {
finish();
return;
}
@ -634,54 +701,179 @@ public class RouterActivity extends AppCompatActivity {
return playerType == null || playerType == PlayerType.MAIN;
}
private void openAddToPlaylistDialog() {
// Getting the stream info usually takes a moment
// Notifying the user here to ensure that no confusion arises
Toast.makeText(
getApplicationContext(),
getString(R.string.processing_may_take_a_moment),
Toast.LENGTH_SHORT)
.show();
public static class PersistentFragment extends Fragment {
private WeakReference<AppCompatActivity> weakContext;
private final CompositeDisposable disposables = new CompositeDisposable();
private int running = 0;
disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, false)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
info -> PlaylistDialog.createCorrespondingDialog(
getThemeWrapperContext(),
List.of(new StreamEntity(info)),
playlistDialog -> {
playlistDialog.setOnDismissListener(dialog -> finish());
private synchronized void inFlight(final boolean started) {
if (started) {
running++;
} else {
running--;
if (running <= 0) {
getActivityContext().ifPresent(context -> context.getSupportFragmentManager()
.beginTransaction().remove(this).commit());
}
}
}
playlistDialog.show(
this.getSupportFragmentManager(),
"addToPlaylistDialog"
);
}
),
throwable -> handleError(this, new ErrorInfo(
throwable,
UserAction.REQUESTED_STREAM,
"Tried to add " + currentUrl + " to a playlist",
currentService.getServiceId())
)
)
);
@Override
public void onAttach(@NonNull final Context activityContext) {
super.onAttach(activityContext);
weakContext = new WeakReference<>((AppCompatActivity) activityContext);
}
@Override
public void onDetach() {
super.onDetach();
weakContext = null;
}
@SuppressWarnings("deprecation")
@Override
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setRetainInstance(true);
}
@Override
public void onDestroy() {
super.onDestroy();
disposables.clear();
}
/**
* @return the activity context, if there is one and the activity is not finishing
*/
private Optional<AppCompatActivity> getActivityContext() {
return Optional.ofNullable(weakContext)
.map(Reference::get)
.filter(context -> !context.isFinishing());
}
// guard against IllegalStateException in calling DialogFragment.show() whilst in background
// (which could happen, say, when the user pressed the home button while waiting for
// the network request to return) when it internally calls FragmentTransaction.commit()
// after the FragmentManager has saved its states (isStateSaved() == true)
// (ref: https://stackoverflow.com/a/39813506)
private void runOnVisible(final Consumer<AppCompatActivity> runnable) {
getActivityContext().ifPresentOrElse(context -> {
if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) {
context.runOnUiThread(() -> {
runnable.accept(context);
inFlight(false);
});
} else {
getLifecycle().addObserver(new DefaultLifecycleObserver() {
@Override
public void onResume(@NonNull final LifecycleOwner owner) {
getLifecycle().removeObserver(this);
getActivityContext().ifPresentOrElse(context ->
context.runOnUiThread(() -> {
runnable.accept(context);
inFlight(false);
}),
() -> inFlight(false)
);
}
});
// this trick doesn't seem to work on Android 10+ (API 29)
// which places restrictions on starting activities from the background
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
&& !context.isChangingConfigurations()) {
// try to bring the activity back to front if minimised
final Intent i = new Intent(context, RouterActivity.class);
i.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
startActivity(i);
}
}
}, () -> {
// this branch is executed if there is no activity context
inFlight(false);
});
}
<T> Single<T> pleaseWait(final Single<T> single) {
// 'abuse' ambWith() here to cancel the toast for us when the wait is over
return single.ambWith(Single.create(emitter -> getActivityContext().ifPresent(context ->
context.runOnUiThread(() -> {
// Getting the stream info usually takes a moment
// Notifying the user here to ensure that no confusion arises
final Toast toast = Toast.makeText(context,
getString(R.string.processing_may_take_a_moment),
Toast.LENGTH_LONG);
toast.show();
emitter.setCancellable(toast::cancel);
}))));
}
@SuppressLint("CheckResult")
private void openDownloadDialog(final int currentServiceId, final String currentUrl) {
inFlight(true);
disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.compose(this::pleaseWait)
.subscribe(result ->
runOnVisible(ctx -> {
final FragmentManager fm = ctx.getSupportFragmentManager();
final DownloadDialog downloadDialog = new DownloadDialog(ctx, result);
// dismiss listener to be handled by FragmentManager
downloadDialog.show(fm, "downloadDialog");
}
), throwable -> runOnVisible(ctx ->
((RouterActivity) ctx).showUnsupportedUrlDialog(currentUrl))));
}
private void openAddToPlaylistDialog(final int currentServiceId, final String currentUrl) {
inFlight(true);
disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, false)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.compose(this::pleaseWait)
.subscribe(
info -> getActivityContext().ifPresent(context ->
PlaylistDialog.createCorrespondingDialog(context,
List.of(new StreamEntity(info)),
playlistDialog -> runOnVisible(ctx -> {
// dismiss listener to be handled by FragmentManager
final FragmentManager fm =
ctx.getSupportFragmentManager();
playlistDialog.show(fm, "addToPlaylistDialog");
})
)),
throwable -> runOnVisible(ctx -> handleError(ctx, new ErrorInfo(
throwable,
UserAction.REQUESTED_STREAM,
"Tried to add " + currentUrl + " to a playlist",
((RouterActivity) ctx).currentService.getServiceId())
))
)
);
}
}
@SuppressLint("CheckResult")
private void openDownloadDialog() {
disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(result -> {
final DownloadDialog downloadDialog = new DownloadDialog(this, result);
downloadDialog.setOnDismissListener(dialog -> finish());
private void openAddToPlaylistDialog() {
getPersistFragment().openAddToPlaylistDialog(currentServiceId, currentUrl);
}
final FragmentManager fm = getSupportFragmentManager();
downloadDialog.show(fm, "downloadDialog");
fm.executePendingTransactions();
}, throwable -> showUnsupportedUrlDialog(currentUrl)));
private void openDownloadDialog() {
getPersistFragment().openDownloadDialog(currentServiceId, currentUrl);
}
private PersistentFragment getPersistFragment() {
final FragmentManager fm = getSupportFragmentManager();
PersistentFragment persistFragment =
(PersistentFragment) fm.findFragmentByTag("PERSIST_FRAGMENT");
if (persistFragment == null) {
persistFragment = new PersistentFragment();
fm.beginTransaction()
.add(persistFragment, "PERSIST_FRAGMENT")
.commitNow();
}
return persistFragment;
}
@Override

View File

@ -1,6 +1,6 @@
package org.schabi.newpipe.database;
import static org.schabi.newpipe.database.Migrations.DB_VER_5;
import static org.schabi.newpipe.database.Migrations.DB_VER_6;
import androidx.room.Database;
import androidx.room.RoomDatabase;
@ -38,7 +38,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity;
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
FeedLastUpdatedEntity.class
},
version = DB_VER_5
version = DB_VER_6
)
public abstract class AppDatabase extends RoomDatabase {
public static final String DATABASE_NAME = "newpipe.db";

View File

@ -23,6 +23,7 @@ public final class Migrations {
public static final int DB_VER_3 = 3;
public static final int DB_VER_4 = 4;
public static final int DB_VER_5 = 5;
public static final int DB_VER_6 = 6;
private static final String TAG = Migrations.class.getName();
public static final boolean DEBUG = MainActivity.DEBUG;
@ -188,6 +189,14 @@ public final class Migrations {
}
};
public static final Migration MIGRATION_5_6 = new Migration(DB_VER_5, DB_VER_6) {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` "
+ "INTEGER NOT NULL DEFAULT 0");
}
};
private Migrations() {
}
}

View File

@ -0,0 +1,24 @@
package org.schabi.newpipe.database.playlist;
import androidx.room.ColumnInfo;
/**
* This class adds a field to {@link PlaylistMetadataEntry} that contains an integer representing
* how many times a specific stream is already contained inside a local playlist. Used to be able
* to grey out playlists which already contain the current stream in the playlist append dialog.
* @see org.schabi.newpipe.local.playlist.LocalPlaylistManager#getPlaylistDuplicates(String)
*/
public class PlaylistDuplicatesEntry extends PlaylistMetadataEntry {
public static final String PLAYLIST_TIMES_STREAM_IS_CONTAINED = "timesStreamIsContained";
@ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED)
public final long timesStreamIsContained;
public PlaylistDuplicatesEntry(final long uid,
final String name,
final String thumbnailUrl,
final long streamCount,
final long timesStreamIsContained) {
super(uid, name, thumbnailUrl, streamCount);
this.timesStreamIsContained = timesStreamIsContained;
}
}

View File

@ -6,6 +6,7 @@ import androidx.room.RewriteQueriesToDropUnusedColumns;
import androidx.room.Transaction;
import org.schabi.newpipe.database.BasicDAO;
import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity;
@ -14,6 +15,7 @@ import java.util.List;
import io.reactivex.rxjava3.core.Flowable;
import static org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry.PLAYLIST_TIMES_STREAM_IS_CONTAINED;
import static org.schabi.newpipe.database.playlist.PlaylistMetadataEntry.PLAYLIST_STREAM_COUNT;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME;
@ -25,6 +27,8 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JO
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE;
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID;
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE;
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_THUMBNAIL_URL;
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_URL;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
@ -53,6 +57,15 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
Flowable<Integer> getMaximumIndexOf(long playlistId);
@Query("SELECT CASE WHEN COUNT(*) != 0 then " + STREAM_THUMBNAIL_URL + " ELSE :defaultUrl END"
+ " FROM " + STREAM_TABLE
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId "
+ " LIMIT 1"
)
Flowable<String> getAutomaticThumbnailUrl(long playlistId, String defaultUrl);
@RewriteQueriesToDropUnusedColumns
@Transaction
@Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN "
@ -80,7 +93,7 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
+ " FROM " + PLAYLIST_TABLE
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
+ " ON " + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID
+ " GROUP BY " + JOIN_PLAYLIST_ID
+ " GROUP BY " + PLAYLIST_ID
+ " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC")
Flowable<List<PlaylistMetadataEntry>> getPlaylistMetadata();
@ -101,6 +114,23 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
+ " ORDER BY MIN(" + JOIN_INDEX + ") ASC")
Flowable<List<PlaylistStreamEntry>> getStreamsWithoutDuplicates(long playlistId);
@Transaction
@Query("SELECT " + PLAYLIST_TABLE + "." + PLAYLIST_ID + ", "
+ PLAYLIST_NAME + ", "
+ PLAYLIST_TABLE + "." + PLAYLIST_THUMBNAIL_URL + ", "
+ "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT + ", "
+ "COALESCE(SUM(" + STREAM_URL + " = :streamUrl), 0) AS "
+ PLAYLIST_TIMES_STREAM_IS_CONTAINED
+ " FROM " + PLAYLIST_TABLE
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
+ " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID
+ " LEFT JOIN " + STREAM_TABLE
+ " ON " + STREAM_TABLE + "." + STREAM_ID + " = " + JOIN_STREAM_ID
+ " AND :streamUrl = :streamUrl"
+ " GROUP BY " + JOIN_PLAYLIST_ID
+ " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC")
Flowable<List<PlaylistDuplicatesEntry>> getPlaylistDuplicatesMetadata(String streamUrl);
}

View File

@ -15,6 +15,7 @@ public class PlaylistEntity {
public static final String PLAYLIST_ID = "uid";
public static final String PLAYLIST_NAME = "name";
public static final String PLAYLIST_THUMBNAIL_URL = "thumbnail_url";
public static final String PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent";
@PrimaryKey(autoGenerate = true)
@ColumnInfo(name = PLAYLIST_ID)
@ -26,9 +27,14 @@ public class PlaylistEntity {
@ColumnInfo(name = PLAYLIST_THUMBNAIL_URL)
private String thumbnailUrl;
public PlaylistEntity(final String name, final String thumbnailUrl) {
@ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT)
private boolean isThumbnailPermanent;
public PlaylistEntity(final String name, final String thumbnailUrl,
final boolean isThumbnailPermanent) {
this.name = name;
this.thumbnailUrl = thumbnailUrl;
this.isThumbnailPermanent = isThumbnailPermanent;
}
public long getUid() {
@ -54,4 +60,13 @@ public class PlaylistEntity {
public void setThumbnailUrl(final String thumbnailUrl) {
this.thumbnailUrl = thumbnailUrl;
}
public boolean getIsThumbnailPermanent() {
return isThumbnailPermanent;
}
public void setIsThumbnailPermanent(final boolean isThumbnailSet) {
this.isThumbnailPermanent = isThumbnailSet;
}
}

View File

@ -75,6 +75,7 @@ import java.io.IOException;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import icepick.Icepick;
import icepick.State;
@ -560,6 +561,39 @@ public class DownloadDialog extends DialogFragment
selectedSubtitleIndex = position;
break;
}
onItemSelectedSetFileName();
}
private void onItemSelectedSetFileName() {
final String fileName = FilenameUtils.createFilename(getContext(), currentInfo.getName());
final String prevFileName = Optional.ofNullable(dialogBinding.fileName.getText())
.map(Object::toString)
.orElse("");
if (prevFileName.isEmpty()
|| prevFileName.equals(fileName)
|| prevFileName.startsWith(getString(R.string.caption_file_name, fileName, ""))) {
// only update the file name field if it was not edited by the user
switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) {
case R.id.audio_button:
case R.id.video_button:
if (!prevFileName.equals(fileName)) {
// since the user might have switched between audio and video, the correct
// text might already be in place, so avoid resetting the cursor position
dialogBinding.fileName.setText(fileName);
}
break;
case R.id.subtitle_button:
final String setSubtitleLanguageCode = subtitleStreamsAdapter
.getItem(selectedSubtitleIndex).getLanguageTag();
// this will reset the cursor position, which is bad UX, but it can't be avoided
dialogBinding.fileName.setText(getString(
R.string.caption_file_name, fileName, setSubtitleLanguageCode));
break;
}
}
}
@Override

View File

@ -3,6 +3,8 @@ package org.schabi.newpipe.fragments.detail;
import static android.text.TextUtils.isEmpty;
import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.util.Localization.getAppLocale;
import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD;
import android.os.Bundle;
import android.view.LayoutInflater;
@ -28,7 +30,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.external_communication.TextLinkifier;
import org.schabi.newpipe.util.text.TextLinkifier;
import icepick.State;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
@ -111,7 +113,10 @@ public class DescriptionFragment extends BaseFragment {
private void disableDescriptionSelection() {
// show description content again, otherwise some links are not clickable
loadDescriptionContent();
TextLinkifier.fromDescription(binding.detailDescriptionView,
streamInfo.getDescription(), HtmlCompat.FROM_HTML_MODE_LEGACY,
streamInfo.getService(), streamInfo.getUrl(),
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
binding.detailDescriptionNoteView.setVisibility(View.GONE);
binding.detailDescriptionView.setTextIsSelectable(false);
@ -122,52 +127,32 @@ public class DescriptionFragment extends BaseFragment {
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all);
}
private void loadDescriptionContent() {
final Description description = streamInfo.getDescription();
switch (description.getType()) {
case Description.HTML:
TextLinkifier.createLinksFromHtmlBlock(binding.detailDescriptionView,
description.getContent(), HtmlCompat.FROM_HTML_MODE_LEGACY, streamInfo,
descriptionDisposables);
break;
case Description.MARKDOWN:
TextLinkifier.createLinksFromMarkdownText(binding.detailDescriptionView,
description.getContent(), streamInfo, descriptionDisposables);
break;
case Description.PLAIN_TEXT: default:
TextLinkifier.createLinksFromPlainText(binding.detailDescriptionView,
description.getContent(), streamInfo, descriptionDisposables);
break;
}
}
private void setupMetadata(final LayoutInflater inflater,
final LinearLayout layout) {
addMetadataItem(inflater, layout, false,
R.string.metadata_category, streamInfo.getCategory());
addMetadataItem(inflater, layout, false, R.string.metadata_category,
streamInfo.getCategory());
addMetadataItem(inflater, layout, false,
R.string.metadata_licence, streamInfo.getLicence());
addMetadataItem(inflater, layout, false, R.string.metadata_licence,
streamInfo.getLicence());
addPrivacyMetadataItem(inflater, layout);
if (streamInfo.getAgeLimit() != NO_AGE_LIMIT) {
addMetadataItem(inflater, layout, false,
R.string.metadata_age_limit, String.valueOf(streamInfo.getAgeLimit()));
addMetadataItem(inflater, layout, false, R.string.metadata_age_limit,
String.valueOf(streamInfo.getAgeLimit()));
}
if (streamInfo.getLanguageInfo() != null) {
addMetadataItem(inflater, layout, false,
R.string.metadata_language, streamInfo.getLanguageInfo().getDisplayLanguage());
addMetadataItem(inflater, layout, false, R.string.metadata_language,
streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale(getContext())));
}
addMetadataItem(inflater, layout, true,
R.string.metadata_support, streamInfo.getSupportInfo());
addMetadataItem(inflater, layout, true,
R.string.metadata_host, streamInfo.getHost());
addMetadataItem(inflater, layout, true,
R.string.metadata_thumbnail_url, streamInfo.getThumbnailUrl());
addMetadataItem(inflater, layout, true, R.string.metadata_support,
streamInfo.getSupportInfo());
addMetadataItem(inflater, layout, true, R.string.metadata_host,
streamInfo.getHost());
addMetadataItem(inflater, layout, true, R.string.metadata_thumbnail_url,
streamInfo.getThumbnailUrl());
addTagsMetadataItem(inflater, layout);
}
@ -191,12 +176,14 @@ public class DescriptionFragment extends BaseFragment {
});
if (linkifyContent) {
TextLinkifier.createLinksFromPlainText(itemBinding.metadataContentView, content, null,
descriptionDisposables);
TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null,
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
} else {
itemBinding.metadataContentView.setText(content);
}
itemBinding.metadataContentView.setClickable(true);
layout.addView(itemBinding.getRoot());
}
@ -245,14 +232,15 @@ public class DescriptionFragment extends BaseFragment {
case INTERNAL:
contentRes = R.string.metadata_privacy_internal;
break;
case OTHER: default:
case OTHER:
default:
contentRes = 0;
break;
}
if (contentRes != 0) {
addMetadataItem(inflater, layout, false,
R.string.metadata_privacy, getString(contentRes));
addMetadataItem(inflater, layout, false, R.string.metadata_privacy,
getString(contentRes));
}
}
}

View File

@ -27,7 +27,6 @@ import android.graphics.Color;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.Looper;
@ -55,6 +54,9 @@ import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.Toolbar;
import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.core.content.ContextCompat;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import androidx.preference.PreferenceManager;
import com.google.android.exoplayer2.PlaybackException;
@ -170,13 +172,13 @@ public final class VideoDetailFragment
private final SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener =
(sharedPreferences, key) -> {
if (key.equals(getString(R.string.show_comments_key))) {
if (getString(R.string.show_comments_key).equals(key)) {
showComments = sharedPreferences.getBoolean(key, true);
tabSettingsChanged = true;
} else if (key.equals(getString(R.string.show_next_video_key))) {
} else if (getString(R.string.show_next_video_key).equals(key)) {
showRelatedItems = sharedPreferences.getBoolean(key, true);
tabSettingsChanged = true;
} else if (key.equals(getString(R.string.show_description_key))) {
} else if (getString(R.string.show_description_key).equals(key)) {
showDescription = sharedPreferences.getBoolean(key, true);
tabSettingsChanged = true;
}
@ -255,11 +257,10 @@ public final class VideoDetailFragment
playerUi.ifPresent(MainPlayerUi::toggleFullscreen);
}
//noinspection SimplifyOptionalCallChains
if (playAfterConnect
|| (currentInfo != null
&& isAutoplayEnabled()
&& !playerUi.isPresent())) {
&& playerUi.isEmpty())) {
autoPlayEnabled = true; // forcefully start playing
openVideoPlayerAutoFullscreen();
}
@ -864,7 +865,8 @@ public final class VideoDetailFragment
if (playQueue == null) {
playQueue = new SinglePlayQueue(result);
}
if (stack.isEmpty() || !stack.peek().getPlayQueue().equals(playQueue)) {
if (stack.isEmpty() || !stack.peek().getPlayQueue()
.equalStreams(playQueue)) {
stack.push(new StackItem(serviceId, url, title, playQueue));
}
}
@ -1067,8 +1069,7 @@ public final class VideoDetailFragment
}
private void openPopupPlayer(final boolean append) {
if (!PermissionHelper.isPopupEnabled(activity)) {
PermissionHelper.showPopupEnablementToast(activity);
if (!PermissionHelper.isPopupEnabledElseAsk(activity)) {
return;
}
@ -1174,16 +1175,15 @@ public final class VideoDetailFragment
* be reused in a few milliseconds and the flickering would be annoying.
*/
private void hideMainPlayerOnLoadingNewStream() {
//noinspection SimplifyOptionalCallChains
if (!isPlayerServiceAvailable() || !getRoot().isPresent()
|| !player.videoPlayerSelected()) {
final var root = getRoot();
if (!isPlayerServiceAvailable() || root.isEmpty() || !player.videoPlayerSelected()) {
return;
}
removeVideoPlayerView();
if (isAutoplayEnabled()) {
playerService.stopForImmediateReusing();
getRoot().ifPresent(view -> view.setVisibility(View.GONE));
root.ifPresent(view -> view.setVisibility(View.GONE));
} else {
playerHolder.stopService();
}
@ -1780,7 +1780,7 @@ public final class VideoDetailFragment
// deleted/added items inside Channel/Playlist queue and makes possible to have
// a history of played items
@Nullable final StackItem stackPeek = stack.peek();
if (stackPeek != null && !stackPeek.getPlayQueue().equals(queue)) {
if (stackPeek != null && !stackPeek.getPlayQueue().equalStreams(queue)) {
@Nullable final PlayQueueItem playQueueItem = queue.getItem();
if (playQueueItem != null) {
stack.push(new StackItem(playQueueItem.getServiceId(), playQueueItem.getUrl(),
@ -1846,7 +1846,7 @@ public final class VideoDetailFragment
// They are not equal when user watches something in popup while browsing in fragment and
// then changes screen orientation. In that case the fragment will set itself as
// a service listener and will receive initial call to onMetadataUpdate()
if (!queue.equals(playQueue)) {
if (!queue.equalStreams(playQueue)) {
return;
}
@ -1887,10 +1887,9 @@ public final class VideoDetailFragment
@Override
public void onFullscreenStateChanged(final boolean fullscreen) {
setupBrightness();
//noinspection SimplifyOptionalCallChains
if (!isPlayerAndPlayerServiceAvailable()
|| !player.UIs().get(MainPlayerUi.class).isPresent()
|| getRoot().map(View::getParent).orElse(null) == null) {
|| player.UIs().get(MainPlayerUi.class).isEmpty()
|| getRoot().map(View::getParent).isEmpty()) {
return;
}
@ -1962,15 +1961,17 @@ public final class VideoDetailFragment
return;
}
// Prevent jumping of the player on devices with cutout
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
activity.getWindow().getAttributes().layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT;
}
activity.getWindow().getDecorView().setSystemUiVisibility(0);
activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
activity.getWindow().setStatusBarColor(ThemeHelper.resolveColorFromAttr(
requireContext(), android.R.attr.colorPrimary));
final var window = activity.getWindow();
final var windowInsetsController = WindowCompat.getInsetsController(window,
window.getDecorView());
WindowCompat.setDecorFitsSystemWindows(window, true);
windowInsetsController.setSystemBarsBehavior(WindowInsetsControllerCompat
.BEHAVIOR_SHOW_BARS_BY_TOUCH);
windowInsetsController.show(WindowInsetsCompat.Type.systemBars());
window.setStatusBarColor(ThemeHelper.resolveColorFromAttr(requireContext(),
android.R.attr.colorPrimary));
}
private void hideSystemUi() {
@ -1982,30 +1983,19 @@ public final class VideoDetailFragment
return;
}
// Prevent jumping of the player on devices with cutout
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
activity.getWindow().getAttributes().layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
}
int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
final var window = activity.getWindow();
final var windowInsetsController = WindowCompat.getInsetsController(window,
window.getDecorView());
// In multiWindow mode status bar is not transparent for devices with cutout
// if I include this flag. So without it is better in this case
final boolean isInMultiWindow = DeviceUtils.isInMultiWindow(activity);
if (!isInMultiWindow) {
visibility |= View.SYSTEM_UI_FLAG_FULLSCREEN;
}
activity.getWindow().getDecorView().setSystemUiVisibility(visibility);
WindowCompat.setDecorFitsSystemWindows(window, false);
windowInsetsController.setSystemBarsBehavior(WindowInsetsControllerCompat
.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars());
if (isInMultiWindow || isFullscreen()) {
activity.getWindow().setStatusBarColor(Color.TRANSPARENT);
activity.getWindow().setNavigationBarColor(Color.TRANSPARENT);
if (DeviceUtils.isInMultiWindow(activity) || isFullscreen()) {
window.setStatusBarColor(Color.TRANSPARENT);
window.setNavigationBarColor(Color.TRANSPARENT);
}
activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
}
// Listener implementation
@ -2113,7 +2103,7 @@ public final class VideoDetailFragment
final Iterator<StackItem> iterator = stack.descendingIterator();
while (iterator.hasNext()) {
final StackItem next = iterator.next();
if (next.getPlayQueue().equals(queue)) {
if (next.getPlayQueue().equalStreams(queue)) {
item = next;
break;
}
@ -2128,7 +2118,7 @@ public final class VideoDetailFragment
if (isClearingQueueConfirmationRequired(activity)
&& playerIsNotStopped()
&& activeQueue != null
&& !activeQueue.equals(playQueue)) {
&& !activeQueue.equalStreams(playQueue)) {
showClearingQueueConfirmation(onAllow);
} else {
onAllow.run();
@ -2429,23 +2419,20 @@ public final class VideoDetailFragment
// helpers to check the state of player and playerService
boolean isPlayerAvailable() {
return (player != null);
return player != null;
}
boolean isPlayerServiceAvailable() {
return (playerService != null);
return playerService != null;
}
boolean isPlayerAndPlayerServiceAvailable() {
return (player != null && playerService != null);
return player != null && playerService != null;
}
public Optional<View> getRoot() {
if (player == null) {
return Optional.empty();
}
return player.UIs().get(VideoPlayerUi.class)
return Optional.ofNullable(player)
.flatMap(player1 -> player1.UIs().get(VideoPlayerUi.class))
.map(playerUi -> playerUi.getBinding().getRoot());
}

View File

@ -26,6 +26,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.BaseStateFragment;
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
import org.schabi.newpipe.info_list.InfoListAdapter;
import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
@ -91,11 +92,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
if (updateFlags != 0) {
if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) {
final boolean useGrid = isGridLayout();
itemsList.setLayoutManager(useGrid
? getGridLayoutManager() : getListLayoutManager());
infoListAdapter.setUseGridVariant(useGrid);
infoListAdapter.notifyDataSetChanged();
refreshItemViewMode();
}
updateFlags = 0;
}
@ -221,15 +218,23 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
return lm;
}
/**
* Updates the item view mode based on user preference.
*/
private void refreshItemViewMode() {
final ItemViewMode itemViewMode = getItemViewMode();
itemsList.setLayoutManager((itemViewMode == ItemViewMode.GRID)
? getGridLayoutManager() : getListLayoutManager());
infoListAdapter.setItemViewMode(itemViewMode);
infoListAdapter.notifyDataSetChanged();
}
@Override
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
final boolean useGrid = isGridLayout();
itemsList = rootView.findViewById(R.id.items_list);
itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
infoListAdapter.setUseGridVariant(useGrid);
refreshItemViewMode();
final Supplier<View> listHeaderSupplier = getListHeaderSupplier();
if (listHeaderSupplier != null) {
@ -469,12 +474,16 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
@Override
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
final String key) {
if (key.equals(getString(R.string.list_view_mode_key))) {
if (getString(R.string.list_view_mode_key).equals(key)) {
updateFlags |= LIST_MODE_UPDATE_FLAG;
}
}
protected boolean isGridLayout() {
return ThemeHelper.shouldUseGridLayout(activity);
/**
* Returns preferred item view mode.
* @return ItemViewMode
*/
protected ItemViewMode getItemViewMode() {
return ThemeHelper.getItemViewMode(requireContext());
}
}

View File

@ -17,6 +17,7 @@ import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.comments.CommentsInfo;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.util.ExtractorHelper;
@ -106,7 +107,7 @@ public class CommentsFragment extends BaseListInfoFragment<CommentsInfoItem, Com
@NonNull final MenuInflater inflater) { }
@Override
protected boolean isGridLayout() {
return false;
protected ItemViewMode getItemViewMode() {
return ItemViewMode.LIST;
}
}

View File

@ -132,6 +132,8 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
// Is mini variant still relevant?
// Only the remote playlist screen uses it now
infoListAdapter.setUseMiniVariant(true);
}

View File

@ -19,6 +19,7 @@ import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.util.RelatedItemInfo;
@ -158,16 +159,19 @@ public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, Related
@Override
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
final String s) {
if (headerBinding != null) {
headerBinding.autoplaySwitch.setChecked(
sharedPreferences.getBoolean(
getString(R.string.auto_queue_key), false));
final String key) {
if (headerBinding != null && getString(R.string.auto_queue_key).equals(key)) {
headerBinding.autoplaySwitch.setChecked(sharedPreferences.getBoolean(key, false));
}
}
@Override
protected boolean isGridLayout() {
return false;
protected ItemViewMode getItemViewMode() {
ItemViewMode mode = super.getItemViewMode();
// Only list mode is supported. Either List or card will be used.
if (mode != ItemViewMode.LIST && mode != ItemViewMode.CARD) {
mode = ItemViewMode.LIST;
}
return mode;
}
}

View File

@ -23,9 +23,11 @@ import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder;
import org.schabi.newpipe.info_list.holder.CommentsInfoItemHolder;
import org.schabi.newpipe.info_list.holder.CommentsMiniInfoItemHolder;
import org.schabi.newpipe.info_list.holder.InfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistCardInfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder;
import org.schabi.newpipe.info_list.holder.StreamCardInfoItemHolder;
import org.schabi.newpipe.info_list.holder.StreamGridInfoItemHolder;
import org.schabi.newpipe.info_list.holder.StreamInfoItemHolder;
import org.schabi.newpipe.info_list.holder.StreamMiniInfoItemHolder;
@ -67,12 +69,14 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
private static final int MINI_STREAM_HOLDER_TYPE = 0x100;
private static final int STREAM_HOLDER_TYPE = 0x101;
private static final int GRID_STREAM_HOLDER_TYPE = 0x102;
private static final int CARD_STREAM_HOLDER_TYPE = 0x103;
private static final int MINI_CHANNEL_HOLDER_TYPE = 0x200;
private static final int CHANNEL_HOLDER_TYPE = 0x201;
private static final int GRID_CHANNEL_HOLDER_TYPE = 0x202;
private static final int MINI_PLAYLIST_HOLDER_TYPE = 0x300;
private static final int PLAYLIST_HOLDER_TYPE = 0x301;
private static final int GRID_PLAYLIST_HOLDER_TYPE = 0x302;
private static final int CARD_PLAYLIST_HOLDER_TYPE = 0x303;
private static final int MINI_COMMENT_HOLDER_TYPE = 0x400;
private static final int COMMENT_HOLDER_TYPE = 0x401;
@ -82,9 +86,10 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
private final HistoryRecordManager recordManager;
private boolean useMiniVariant = false;
private boolean useGridVariant = false;
private boolean showFooter = false;
private ItemViewMode itemMode = ItemViewMode.LIST;
private Supplier<View> headerSupplier = null;
public InfoListAdapter(final Context context) {
@ -114,8 +119,8 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
this.useMiniVariant = useMiniVariant;
}
public void setUseGridVariant(final boolean useGridVariant) {
this.useGridVariant = useGridVariant;
public void setItemViewMode(final ItemViewMode itemViewMode) {
this.itemMode = itemViewMode;
}
public void addInfoItemList(@Nullable final List<? extends InfoItem> data) {
@ -234,14 +239,33 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
final InfoItem item = infoItemList.get(position);
switch (item.getInfoType()) {
case STREAM:
return useGridVariant ? GRID_STREAM_HOLDER_TYPE : useMiniVariant
? MINI_STREAM_HOLDER_TYPE : STREAM_HOLDER_TYPE;
if (itemMode == ItemViewMode.CARD) {
return CARD_STREAM_HOLDER_TYPE;
} else if (itemMode == ItemViewMode.GRID) {
return GRID_STREAM_HOLDER_TYPE;
} else if (useMiniVariant) {
return MINI_STREAM_HOLDER_TYPE;
} else {
return STREAM_HOLDER_TYPE;
}
case CHANNEL:
return useGridVariant ? GRID_CHANNEL_HOLDER_TYPE : useMiniVariant
? MINI_CHANNEL_HOLDER_TYPE : CHANNEL_HOLDER_TYPE;
if (itemMode == ItemViewMode.GRID) {
return GRID_CHANNEL_HOLDER_TYPE;
} else if (useMiniVariant) {
return MINI_CHANNEL_HOLDER_TYPE;
} else {
return CHANNEL_HOLDER_TYPE;
}
case PLAYLIST:
return useGridVariant ? GRID_PLAYLIST_HOLDER_TYPE : useMiniVariant
? MINI_PLAYLIST_HOLDER_TYPE : PLAYLIST_HOLDER_TYPE;
if (itemMode == ItemViewMode.CARD) {
return CARD_PLAYLIST_HOLDER_TYPE;
} else if (itemMode == ItemViewMode.GRID) {
return GRID_PLAYLIST_HOLDER_TYPE;
} else if (useMiniVariant) {
return MINI_PLAYLIST_HOLDER_TYPE;
} else {
return PLAYLIST_HOLDER_TYPE;
}
case COMMENT:
return useMiniVariant ? MINI_COMMENT_HOLDER_TYPE : COMMENT_HOLDER_TYPE;
default:
@ -274,6 +298,8 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
return new StreamInfoItemHolder(infoItemBuilder, parent);
case GRID_STREAM_HOLDER_TYPE:
return new StreamGridInfoItemHolder(infoItemBuilder, parent);
case CARD_STREAM_HOLDER_TYPE:
return new StreamCardInfoItemHolder(infoItemBuilder, parent);
case MINI_CHANNEL_HOLDER_TYPE:
return new ChannelMiniInfoItemHolder(infoItemBuilder, parent);
case CHANNEL_HOLDER_TYPE:
@ -286,6 +312,8 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
return new PlaylistInfoItemHolder(infoItemBuilder, parent);
case GRID_PLAYLIST_HOLDER_TYPE:
return new PlaylistGridInfoItemHolder(infoItemBuilder, parent);
case CARD_PLAYLIST_HOLDER_TYPE:
return new PlaylistCardInfoItemHolder(infoItemBuilder, parent);
case MINI_COMMENT_HOLDER_TYPE:
return new CommentsMiniInfoItemHolder(infoItemBuilder, parent);
case COMMENT_HOLDER_TYPE:

View File

@ -0,0 +1,23 @@
package org.schabi.newpipe.info_list;
/**
* Item view mode for streams & playlist listing screens.
*/
public enum ItemViewMode {
/**
* Default mode.
*/
AUTO,
/**
* Full width list item with thumb on the left and two line title & uploader in right.
*/
LIST,
/**
* Grid mode places two cards per row.
*/
GRID,
/**
* A full width card in phone - portrait.
*/
CARD
}

View File

@ -1,14 +1,9 @@
package org.schabi.newpipe.info_list.holder;
import android.view.ViewGroup;
import android.widget.TextView;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.Localization;
/*
* Created by Christian Schabesberger on 12.02.17.
@ -31,40 +26,7 @@ import org.schabi.newpipe.util.Localization;
*/
public class ChannelInfoItemHolder extends ChannelMiniInfoItemHolder {
private final TextView itemChannelDescriptionView;
public ChannelInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_channel_item, parent);
itemChannelDescriptionView = itemView.findViewById(R.id.itemChannelDescriptionView);
}
@Override
public void updateFromItem(final InfoItem infoItem,
final HistoryRecordManager historyRecordManager) {
super.updateFromItem(infoItem, historyRecordManager);
if (!(infoItem instanceof ChannelInfoItem)) {
return;
}
final ChannelInfoItem item = (ChannelInfoItem) infoItem;
itemChannelDescriptionView.setText(item.getDescription());
}
@Override
protected String getDetailLine(final ChannelInfoItem item) {
String details = super.getDetailLine(item);
if (item.getStreamCount() >= 0) {
final String formattedVideoAmount = Localization.localizeStreamCount(
itemBuilder.getContext(), item.getStreamCount());
if (!details.isEmpty()) {
details += "" + formattedVideoAmount;
} else {
details = formattedVideoAmount;
}
}
return details;
}
}

View File

@ -1,21 +1,26 @@
package org.schabi.newpipe.info_list.holder;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
import androidx.annotation.Nullable;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
import org.schabi.newpipe.extractor.utils.Utils;
import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.Localization;
public class ChannelMiniInfoItemHolder extends InfoItemHolder {
public final ImageView itemThumbnailView;
public final TextView itemTitleView;
private final ImageView itemThumbnailView;
private final TextView itemTitleView;
private final TextView itemAdditionalDetailView;
private final TextView itemChannelDescriptionView;
ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId,
final ViewGroup parent) {
@ -24,6 +29,7 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder {
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
itemTitleView = itemView.findViewById(R.id.itemTitleView);
itemAdditionalDetailView = itemView.findViewById(R.id.itemAdditionalDetails);
itemChannelDescriptionView = itemView.findViewById(R.id.itemChannelDescriptionView);
}
public ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder,
@ -40,7 +46,14 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder {
final ChannelInfoItem item = (ChannelInfoItem) infoItem;
itemTitleView.setText(item.getName());
itemAdditionalDetailView.setText(getDetailLine(item));
final String detailLine = getDetailLine(item);
if (detailLine == null) {
itemAdditionalDetailView.setVisibility(View.GONE);
} else {
itemAdditionalDetailView.setVisibility(View.VISIBLE);
itemAdditionalDetailView.setText(getDetailLine(item));
}
PicassoHelper.loadAvatar(item.getThumbnailUrl()).into(itemThumbnailView);
@ -56,14 +69,35 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder {
}
return true;
});
if (itemChannelDescriptionView != null) {
// itemChannelDescriptionView will be null in the mini variant
if (Utils.isBlank(item.getDescription())) {
itemChannelDescriptionView.setVisibility(View.GONE);
} else {
itemChannelDescriptionView.setVisibility(View.VISIBLE);
itemChannelDescriptionView.setText(item.getDescription());
itemChannelDescriptionView.setMaxLines(detailLine == null ? 3 : 2);
}
}
}
protected String getDetailLine(final ChannelInfoItem item) {
String details = "";
if (item.getSubscriberCount() >= 0) {
details += Localization.shortSubscriberCount(itemBuilder.getContext(),
@Nullable
private String getDetailLine(final ChannelInfoItem item) {
if (item.getStreamCount() >= 0 && item.getSubscriberCount() >= 0) {
return Localization.concatenateStrings(
Localization.shortSubscriberCount(itemBuilder.getContext(),
item.getSubscriberCount()),
Localization.localizeStreamCount(itemBuilder.getContext(),
item.getStreamCount()));
} else if (item.getStreamCount() >= 0) {
return Localization.localizeStreamCount(itemBuilder.getContext(),
item.getStreamCount());
} else if (item.getSubscriberCount() >= 0) {
return Localization.shortSubscriberCount(itemBuilder.getContext(),
item.getSubscriberCount());
} else {
return null;
}
return details;
}
}

View File

@ -1,9 +1,10 @@
package org.schabi.newpipe.info_list.holder;
import android.graphics.Paint;
import android.text.Layout;
import android.text.TextUtils;
import android.text.method.LinkMovementMethod;
import android.text.style.URLSpan;
import android.text.util.Linkify;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
@ -11,27 +12,36 @@ import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.TextView;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.text.util.LinkifyCompat;
import androidx.core.text.HtmlCompat;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.CommentTextOnTouchListener;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PicassoHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.external_communication.TimestampExtractor;
import org.schabi.newpipe.util.text.CommentTextOnTouchListener;
import org.schabi.newpipe.util.text.TextLinkifier;
import java.util.Objects;
import java.util.function.Consumer;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public class CommentsMiniInfoItemHolder extends InfoItemHolder {
private static final String TAG = "CommentsMiniIIHolder";
private static final String ELLIPSIS = "";
private static final int COMMENT_DEFAULT_LINES = 2;
private static final int COMMENT_EXPANDED_LINES = 1000;
@ -39,13 +49,18 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
private final int commentHorizontalPadding;
private final int commentVerticalPadding;
private final Paint paintAtContentSize;
private final float ellipsisWidthPx;
private final RelativeLayout itemRoot;
private final ImageView itemThumbnailView;
private final TextView itemContentView;
private final TextView itemLikesCountView;
private final TextView itemPublishedTime;
private String commentText;
private final CompositeDisposable disposables = new CompositeDisposable();
private Description commentText;
private StreamingService streamService;
private String streamUrl;
CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId,
@ -62,6 +77,10 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
.getResources().getDimension(R.dimen.comments_horizontal_padding);
commentVerticalPadding = (int) infoItemBuilder.getContext()
.getResources().getDimension(R.dimen.comments_vertical_padding);
paintAtContentSize = new Paint();
paintAtContentSize.setTextSize(itemContentView.getTextSize());
ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS);
}
public CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder,
@ -91,18 +110,20 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item));
streamUrl = item.getUrl();
itemContentView.setLines(COMMENT_DEFAULT_LINES);
commentText = item.getCommentText();
itemContentView.setText(commentText, TextView.BufferType.SPANNABLE);
itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE);
if (itemContentView.getLineCount() == 0) {
itemContentView.post(this::ellipsize);
} else {
ellipsize();
try {
streamService = NewPipe.getService(item.getServiceId());
} catch (final ExtractionException e) {
// should never happen
ErrorUtil.showUiErrorSnackbar(itemBuilder.getContext(), "Getting StreamingService", e);
Log.w(TAG, "Cannot obtain service from comment service id, defaulting to YouTube", e);
streamService = ServiceList.YouTube;
}
streamUrl = item.getUrl();
commentText = item.getCommentText();
ellipsize();
//noinspection ClickableViewAccessibility
itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE);
if (item.getLikeCount() >= 0) {
itemLikesCountView.setText(
@ -132,7 +153,8 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
if (DeviceUtils.isTv(itemBuilder.getContext())) {
openCommentAuthor(item);
} else {
ShareUtils.copyToClipboard(itemBuilder.getContext(), commentText);
ShareUtils.copyToClipboard(itemBuilder.getContext(),
itemContentView.getText().toString());
}
return true;
});
@ -172,7 +194,7 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
return urls != null && urls.length != 0;
}
private void determineLinkFocus() {
private void determineMovementMethod() {
if (shouldFocusLinks()) {
allowLinkFocus();
} else {
@ -181,63 +203,73 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
}
private void ellipsize() {
boolean hasEllipsis = false;
itemContentView.setMaxLines(COMMENT_EXPANDED_LINES);
linkifyCommentContentView(v -> {
boolean hasEllipsis = false;
if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
final int endOfLastLine = itemContentView
.getLayout()
.getLineEnd(COMMENT_DEFAULT_LINES - 1);
int end = itemContentView.getText().toString().lastIndexOf(' ', endOfLastLine - 2);
if (end == -1) {
end = Math.max(endOfLastLine - 2, 0);
if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
// Note that converting to String removes spans (i.e. links), but that's something
// we actually want since when the text is ellipsized we want all clicks on the
// comment to expand the comment, not to open links.
final String text = itemContentView.getText().toString();
final Layout layout = itemContentView.getLayout();
final float lineWidth = layout.getLineWidth(COMMENT_DEFAULT_LINES - 1);
final float layoutWidth = layout.getWidth();
final int lineStart = layout.getLineStart(COMMENT_DEFAULT_LINES - 1);
final int lineEnd = layout.getLineEnd(COMMENT_DEFAULT_LINES - 1);
// remove characters up until there is enough space for the ellipsis
// (also summing 2 more pixels, just to be sure to avoid float rounding errors)
int end = lineEnd;
float removedCharactersWidth = 0.0f;
while (lineWidth - removedCharactersWidth + ellipsisWidthPx + 2.0f > layoutWidth
&& end >= lineStart) {
end -= 1;
// recalculate each time to account for ligatures or other similar things
removedCharactersWidth = paintAtContentSize.measureText(
text.substring(end, lineEnd));
}
// remove trailing spaces and newlines
while (end > 0 && Character.isWhitespace(text.charAt(end - 1))) {
end -= 1;
}
final String newVal = text.substring(0, end) + ELLIPSIS;
itemContentView.setText(newVal);
hasEllipsis = true;
}
final String newVal = itemContentView.getText().subSequence(0, end) + "";
itemContentView.setText(newVal);
hasEllipsis = true;
}
linkify();
if (hasEllipsis) {
denyLinkFocus();
} else {
determineLinkFocus();
}
itemContentView.setMaxLines(COMMENT_DEFAULT_LINES);
if (hasEllipsis) {
denyLinkFocus();
} else {
determineMovementMethod();
}
});
}
private void toggleEllipsize() {
if (itemContentView.getText().toString().equals(commentText)) {
if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
ellipsize();
}
} else {
final CharSequence text = itemContentView.getText();
if (text.charAt(text.length() - 1) == ELLIPSIS.charAt(0)) {
expand();
} else if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
ellipsize();
}
}
private void expand() {
itemContentView.setMaxLines(COMMENT_EXPANDED_LINES);
itemContentView.setText(commentText);
linkify();
determineLinkFocus();
linkifyCommentContentView(v -> determineMovementMethod());
}
private void linkify() {
LinkifyCompat.addLinks(itemContentView, Linkify.WEB_URLS);
LinkifyCompat.addLinks(itemContentView, TimestampExtractor.TIMESTAMPS_PATTERN, null, null,
(match, url) -> {
try {
final var timestampMatch = TimestampExtractor
.getTimestampFromMatcher(match, commentText);
if (timestampMatch == null) {
return url;
}
return streamUrl + url.replace(Objects.requireNonNull(match.group(0)),
"#timestamp=" + timestampMatch.seconds());
} catch (final Exception ex) {
Log.e(TAG, "Unable to process url='" + url + "' as timestampLink", ex);
return url;
}
});
private void linkifyCommentContentView(@Nullable final Consumer<TextView> onCompletion) {
disposables.clear();
if (commentText != null) {
TextLinkifier.fromDescription(itemContentView, commentText,
HtmlCompat.FROM_HTML_MODE_LEGACY, streamService, streamUrl, disposables,
onCompletion);
}
}
}

View File

@ -0,0 +1,17 @@
package org.schabi.newpipe.info_list.holder;
import android.view.ViewGroup;
import org.schabi.newpipe.R;
import org.schabi.newpipe.info_list.InfoItemBuilder;
/**
* Playlist card layout.
*/
public class PlaylistCardInfoItemHolder extends PlaylistMiniInfoItemHolder {
public PlaylistCardInfoItemHolder(final InfoItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_playlist_card_item, parent);
}
}

View File

@ -0,0 +1,16 @@
package org.schabi.newpipe.info_list.holder;
import android.view.ViewGroup;
import org.schabi.newpipe.R;
import org.schabi.newpipe.info_list.InfoItemBuilder;
/**
* Card layout for stream.
*/
public class StreamCardInfoItemHolder extends StreamInfoItemHolder {
public StreamCardInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_stream_card_item, parent);
}
}

View File

@ -22,10 +22,11 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.PignateFooterBinding;
import org.schabi.newpipe.fragments.BaseStateFragment;
import org.schabi.newpipe.fragments.list.ListViewContract;
import org.schabi.newpipe.info_list.ItemViewMode;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
import static org.schabi.newpipe.util.ThemeHelper.getItemViewMode;
/**
* This fragment is design to be used with persistent data such as
@ -77,16 +78,23 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
super.onResume();
if (updateFlags != 0) {
if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) {
final boolean useGrid = shouldUseGridLayout(requireContext());
itemsList.setLayoutManager(
useGrid ? getGridLayoutManager() : getListLayoutManager());
itemListAdapter.setUseGridVariant(useGrid);
itemListAdapter.notifyDataSetChanged();
refreshItemViewMode();
}
updateFlags = 0;
}
}
/**
* Updates the item view mode based on user preference.
*/
private void refreshItemViewMode() {
final ItemViewMode itemViewMode = getItemViewMode(requireContext());
itemsList.setLayoutManager((itemViewMode == ItemViewMode.GRID)
? getGridLayoutManager() : getListLayoutManager());
itemListAdapter.setItemViewMode(itemViewMode);
itemListAdapter.notifyDataSetChanged();
}
/*//////////////////////////////////////////////////////////////////////////
// Lifecycle - View
//////////////////////////////////////////////////////////////////////////*/
@ -120,11 +128,9 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
itemListAdapter = new LocalItemListAdapter(activity);
final boolean useGrid = shouldUseGridLayout(requireContext());
itemsList = rootView.findViewById(R.id.items_list);
itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
refreshItemViewMode();
itemListAdapter.setUseGridVariant(useGrid);
headerRootBinding = getListHeader();
if (headerRootBinding != null) {
itemListAdapter.setHeader(headerRootBinding.getRoot());
@ -255,7 +261,7 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
@Override
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
final String key) {
if (key.equals(getString(R.string.list_view_mode_key))) {
if (getString(R.string.list_view_mode_key).equals(key)) {
updateFlags |= LIST_MODE_UPDATE_FLAG;
}
}

View File

@ -12,14 +12,19 @@ import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.stream.model.StreamStateEntity;
import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.local.holder.LocalItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistCardItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistGridItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistStreamCardItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistStreamGridItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistStreamItemHolder;
import org.schabi.newpipe.local.holder.LocalStatisticStreamCardItemHolder;
import org.schabi.newpipe.local.holder.LocalStatisticStreamGridItemHolder;
import org.schabi.newpipe.local.holder.LocalStatisticStreamItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistCardItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistItemHolder;
import org.schabi.newpipe.util.FallbackViewHolder;
@ -61,11 +66,17 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
private static final int STREAM_STATISTICS_HOLDER_TYPE = 0x1000;
private static final int STREAM_PLAYLIST_HOLDER_TYPE = 0x1001;
private static final int STREAM_STATISTICS_GRID_HOLDER_TYPE = 0x1002;
private static final int STREAM_STATISTICS_CARD_HOLDER_TYPE = 0x1003;
private static final int STREAM_PLAYLIST_GRID_HOLDER_TYPE = 0x1004;
private static final int STREAM_PLAYLIST_CARD_HOLDER_TYPE = 0x1005;
private static final int LOCAL_PLAYLIST_HOLDER_TYPE = 0x2000;
private static final int REMOTE_PLAYLIST_HOLDER_TYPE = 0x2001;
private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2002;
private static final int REMOTE_PLAYLIST_GRID_HOLDER_TYPE = 0x2004;
private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2001;
private static final int LOCAL_PLAYLIST_CARD_HOLDER_TYPE = 0x2002;
private static final int REMOTE_PLAYLIST_HOLDER_TYPE = 0x3000;
private static final int REMOTE_PLAYLIST_GRID_HOLDER_TYPE = 0x3001;
private static final int REMOTE_PLAYLIST_CARD_HOLDER_TYPE = 0x3002;
private final LocalItemBuilder localItemBuilder;
private final ArrayList<LocalItem> localItems;
@ -73,9 +84,9 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
private final DateTimeFormatter dateTimeFormatter;
private boolean showFooter = false;
private boolean useGridVariant = false;
private View header = null;
private View footer = null;
private ItemViewMode itemViewMode = ItemViewMode.LIST;
public LocalItemListAdapter(final Context context) {
recordManager = new HistoryRecordManager(context);
@ -165,8 +176,8 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
notifyDataSetChanged();
}
public void setUseGridVariant(final boolean useGridVariant) {
this.useGridVariant = useGridVariant;
public void setItemViewMode(final ItemViewMode itemViewMode) {
this.itemViewMode = itemViewMode;
}
public void setHeader(final View header) {
@ -244,21 +255,39 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
return FOOTER_TYPE;
}
final LocalItem item = localItems.get(position);
switch (item.getLocalItemType()) {
case PLAYLIST_LOCAL_ITEM:
return useGridVariant
? LOCAL_PLAYLIST_GRID_HOLDER_TYPE : LOCAL_PLAYLIST_HOLDER_TYPE;
if (itemViewMode == ItemViewMode.CARD) {
return LOCAL_PLAYLIST_CARD_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.GRID) {
return LOCAL_PLAYLIST_GRID_HOLDER_TYPE;
} else {
return LOCAL_PLAYLIST_HOLDER_TYPE;
}
case PLAYLIST_REMOTE_ITEM:
return useGridVariant
? REMOTE_PLAYLIST_GRID_HOLDER_TYPE : REMOTE_PLAYLIST_HOLDER_TYPE;
if (itemViewMode == ItemViewMode.CARD) {
return REMOTE_PLAYLIST_CARD_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.GRID) {
return REMOTE_PLAYLIST_GRID_HOLDER_TYPE;
} else {
return REMOTE_PLAYLIST_HOLDER_TYPE;
}
case PLAYLIST_STREAM_ITEM:
return useGridVariant
? STREAM_PLAYLIST_GRID_HOLDER_TYPE : STREAM_PLAYLIST_HOLDER_TYPE;
if (itemViewMode == ItemViewMode.CARD) {
return STREAM_PLAYLIST_CARD_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.GRID) {
return STREAM_PLAYLIST_GRID_HOLDER_TYPE;
} else {
return STREAM_PLAYLIST_HOLDER_TYPE;
}
case STATISTIC_STREAM_ITEM:
return useGridVariant
? STREAM_STATISTICS_GRID_HOLDER_TYPE : STREAM_STATISTICS_HOLDER_TYPE;
if (itemViewMode == ItemViewMode.CARD) {
return STREAM_STATISTICS_CARD_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.GRID) {
return STREAM_STATISTICS_GRID_HOLDER_TYPE;
} else {
return STREAM_STATISTICS_HOLDER_TYPE;
}
default:
Log.e(TAG, "No holder type has been considered for item: ["
+ item.getLocalItemType() + "]");
@ -283,18 +312,26 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
return new LocalPlaylistItemHolder(localItemBuilder, parent);
case LOCAL_PLAYLIST_GRID_HOLDER_TYPE:
return new LocalPlaylistGridItemHolder(localItemBuilder, parent);
case LOCAL_PLAYLIST_CARD_HOLDER_TYPE:
return new LocalPlaylistCardItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_HOLDER_TYPE:
return new RemotePlaylistItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_GRID_HOLDER_TYPE:
return new RemotePlaylistGridItemHolder(localItemBuilder, parent);
case REMOTE_PLAYLIST_CARD_HOLDER_TYPE:
return new RemotePlaylistCardItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_HOLDER_TYPE:
return new LocalPlaylistStreamItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_GRID_HOLDER_TYPE:
return new LocalPlaylistStreamGridItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_CARD_HOLDER_TYPE:
return new LocalPlaylistStreamCardItemHolder(localItemBuilder, parent);
case STREAM_STATISTICS_HOLDER_TYPE:
return new LocalStatisticStreamItemHolder(localItemBuilder, parent);
case STREAM_STATISTICS_GRID_HOLDER_TYPE:
return new LocalStatisticStreamGridItemHolder(localItemBuilder, parent);
case STREAM_STATISTICS_CARD_HOLDER_TYPE:
return new LocalStatisticStreamCardItemHolder(localItemBuilder, parent);
default:
Log.e(TAG, "No view type has been considered for holder: [" + type + "]");
return new FallbackViewHolder(new View(parent.getContext()));

View File

@ -1,5 +1,6 @@
package org.schabi.newpipe.local.bookmark;
import android.content.DialogInterface;
import android.os.Bundle;
import android.os.Parcelable;
import android.text.InputType;
@ -31,6 +32,7 @@ import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
import java.util.ArrayList;
import java.util.List;
import icepick.State;
@ -256,6 +258,41 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
}
private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
final String rename = getString(R.string.rename);
final String delete = getString(R.string.delete);
final String unsetThumbnail = getString(R.string.unset_playlist_thumbnail);
final boolean isThumbnailPermanent = localPlaylistManager
.getIsPlaylistThumbnailPermanent(selectedItem.uid);
final AlertDialog.Builder builder = new AlertDialog.Builder(activity);
final ArrayList<String> items = new ArrayList<>();
items.add(rename);
items.add(delete);
if (isThumbnailPermanent) {
items.add(unsetThumbnail);
}
final DialogInterface.OnClickListener action = (d, index) -> {
if (items.get(index).equals(rename)) {
showRenameDialog(selectedItem);
} else if (items.get(index).equals(delete)) {
showDeleteDialog(selectedItem.name,
localPlaylistManager.deletePlaylist(selectedItem.uid));
} else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) {
final String thumbnailUrl = localPlaylistManager
.getAutomaticPlaylistThumbnail(selectedItem.uid);
localPlaylistManager
.changePlaylistThumbnail(selectedItem.uid, thumbnailUrl, false)
.observeOn(AndroidSchedulers.mainThread())
.subscribe();
}
};
builder.setItems(items.toArray(new String[0]), action).create().show();
}
private void showRenameDialog(final PlaylistMetadataEntry selectedItem) {
final DialogEditTextBinding dialogBinding =
DialogEditTextBinding.inflate(getLayoutInflater());
dialogBinding.dialogEditText.setHint(R.string.name);
@ -269,11 +306,6 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
selectedItem.uid,
dialogBinding.dialogEditText.getText().toString()))
.setNegativeButton(R.string.cancel, null)
.setNeutralButton(R.string.delete, (dialog, which) -> {
showDeleteDialog(selectedItem.name,
localPlaylistManager.deletePlaylist(selectedItem.uid));
dialog.dismiss();
})
.create()
.show();
}

View File

@ -4,6 +4,7 @@ import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
@ -13,7 +14,7 @@ import androidx.recyclerview.widget.RecyclerView;
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.local.LocalItemListAdapter;
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
@ -28,6 +29,7 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
private RecyclerView playlistRecyclerView;
private LocalItemListAdapter playlistAdapter;
private TextView playlistDuplicateIndicator;
private final CompositeDisposable playlistDisposables = new CompositeDisposable();
@ -63,8 +65,9 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
playlistAdapter = new LocalItemListAdapter(getActivity());
playlistAdapter.setSelectedListener(selectedItem -> {
final List<StreamEntity> entities = getStreamEntities();
if (selectedItem instanceof PlaylistMetadataEntry && entities != null) {
onPlaylistSelected(playlistManager, (PlaylistMetadataEntry) selectedItem, entities);
if (selectedItem instanceof PlaylistDuplicatesEntry && entities != null) {
onPlaylistSelected(playlistManager,
(PlaylistDuplicatesEntry) selectedItem, entities);
}
});
@ -72,10 +75,13 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
playlistRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
playlistRecyclerView.setAdapter(playlistAdapter);
playlistDuplicateIndicator = view.findViewById(R.id.playlist_duplicate);
final View newPlaylistButton = view.findViewById(R.id.newPlaylist);
newPlaylistButton.setOnClickListener(ignored -> openCreatePlaylistDialog());
playlistDisposables.add(playlistManager.getPlaylists()
playlistDisposables.add(playlistManager
.getPlaylistDuplicates(getStreamEntities().get(0).getUrl())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::onPlaylistsReceived));
}
@ -117,24 +123,41 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
requireDialog().dismiss();
}
private void onPlaylistsReceived(@NonNull final List<PlaylistMetadataEntry> playlists) {
if (playlistAdapter != null && playlistRecyclerView != null) {
private void onPlaylistsReceived(@NonNull final List<PlaylistDuplicatesEntry> playlists) {
if (playlistAdapter != null
&& playlistRecyclerView != null
&& playlistDuplicateIndicator != null) {
playlistAdapter.clearStreamItemList();
playlistAdapter.addItems(playlists);
playlistRecyclerView.setVisibility(View.VISIBLE);
playlistDuplicateIndicator.setVisibility(
anyPlaylistContainsDuplicates(playlists) ? View.VISIBLE : View.GONE);
}
}
private boolean anyPlaylistContainsDuplicates(final List<PlaylistDuplicatesEntry> playlists) {
return playlists.stream()
.anyMatch(playlist -> playlist.timesStreamIsContained > 0);
}
private void onPlaylistSelected(@NonNull final LocalPlaylistManager manager,
@NonNull final PlaylistMetadataEntry playlist,
@NonNull final PlaylistDuplicatesEntry playlist,
@NonNull final List<StreamEntity> streams) {
final Toast successToast = Toast.makeText(getContext(),
R.string.playlist_add_stream_success, Toast.LENGTH_SHORT);
final String toastText;
if (playlist.timesStreamIsContained > 0) {
toastText = getString(R.string.playlist_add_stream_success_duplicate,
playlist.timesStreamIsContained);
} else {
toastText = getString(R.string.playlist_add_stream_success);
}
final Toast successToast = Toast.makeText(getContext(), toastText, Toast.LENGTH_SHORT);
if (playlist.thumbnailUrl
.equals("drawable://" + R.drawable.placeholder_thumbnail_playlist)) {
playlistDisposables.add(manager
.changePlaylistThumbnail(playlist.uid, streams.get(0).getThumbnailUrl())
.changePlaylistThumbnail(playlist.uid, streams.get(0).getThumbnailUrl(), false)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignored -> successToast.show()));
}

View File

@ -36,7 +36,6 @@ import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.annotation.Nullable
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.edit
@ -69,6 +68,7 @@ import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
import org.schabi.newpipe.fragments.BaseStateFragment
import org.schabi.newpipe.info_list.ItemViewMode
import org.schabi.newpipe.info_list.dialog.InfoItemDialog
import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling
@ -80,6 +80,7 @@ import org.schabi.newpipe.util.DeviceUtils
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams
import org.schabi.newpipe.util.ThemeHelper.getItemViewMode
import org.schabi.newpipe.util.ThemeHelper.resolveDrawable
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
import java.time.OffsetDateTime
@ -120,7 +121,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
groupName = arguments?.getString(KEY_GROUP_NAME) ?: ""
onSettingsChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key.equals(getString(R.string.list_view_mode_key))) {
if (getString(R.string.list_view_mode_key).equals(key)) {
updateListViewModeOnResume = true
}
}
@ -416,11 +417,10 @@ class FeedFragment : BaseStateFragment<FeedState>() {
@SuppressLint("StringFormatMatches")
private fun handleLoadedState(loadedState: FeedState.LoadedState) {
val itemVersion = if (shouldUseGridLayout(context)) {
StreamItem.ItemVersion.GRID
} else {
StreamItem.ItemVersion.NORMAL
val itemVersion = when (getItemViewMode(requireContext())) {
ItemViewMode.GRID -> StreamItem.ItemVersion.GRID
ItemViewMode.CARD -> StreamItem.ItemVersion.CARD
else -> StreamItem.ItemVersion.NORMAL
}
loadedState.items.forEach { it.itemVersion = itemVersion }
@ -499,7 +499,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
private fun handleFeedNotAvailable(
subscriptionEntity: SubscriptionEntity,
@Nullable cause: Throwable?,
cause: Throwable?,
nextItemsErrors: List<Throwable>
) {
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())

View File

@ -42,12 +42,13 @@ data class StreamItem(
override fun getId(): Long = stream.uid
enum class ItemVersion { NORMAL, MINI, GRID }
enum class ItemVersion { NORMAL, MINI, GRID, CARD }
override fun getLayout(): Int = when (itemVersion) {
ItemVersion.NORMAL -> R.layout.list_stream_item
ItemVersion.MINI -> R.layout.list_stream_mini_item
ItemVersion.GRID -> R.layout.list_stream_grid_item
ItemVersion.CARD -> R.layout.list_stream_card_item
}
override fun initializeViewBinding(view: View) = ListStreamItemBinding.bind(view)

View File

@ -0,0 +1,17 @@
package org.schabi.newpipe.local.holder;
import android.view.ViewGroup;
import org.schabi.newpipe.R;
import org.schabi.newpipe.local.LocalItemBuilder;
/**
* Playlist card layout.
*/
public class LocalPlaylistCardItemHolder extends LocalPlaylistItemHolder {
public LocalPlaylistCardItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_playlist_card_item, parent);
}
}

View File

@ -4,6 +4,7 @@ import android.view.View;
import android.view.ViewGroup;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
@ -13,6 +14,9 @@ import org.schabi.newpipe.util.Localization;
import java.time.format.DateTimeFormatter;
public class LocalPlaylistItemHolder extends PlaylistItemHolder {
private static final float GRAYED_OUT_ALPHA = 0.6f;
public LocalPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final ViewGroup parent) {
super(infoItemBuilder, parent);
}
@ -38,6 +42,13 @@ public class LocalPlaylistItemHolder extends PlaylistItemHolder {
PicassoHelper.loadPlaylistThumbnail(item.thumbnailUrl).into(itemThumbnailView);
if (item instanceof PlaylistDuplicatesEntry
&& ((PlaylistDuplicatesEntry) item).timesStreamIsContained > 0) {
itemView.setAlpha(GRAYED_OUT_ALPHA);
} else {
itemView.setAlpha(1.0f);
}
super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter);
}
}

View File

@ -0,0 +1,17 @@
package org.schabi.newpipe.local.holder;
import android.view.ViewGroup;
import org.schabi.newpipe.R;
import org.schabi.newpipe.local.LocalItemBuilder;
/**
* Local playlist stream UI. This also includes a handle to rearrange the videos.
*/
public class LocalPlaylistStreamCardItemHolder extends LocalPlaylistStreamItemHolder {
public LocalPlaylistStreamCardItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_stream_playlist_card_item, parent);
}
}

View File

@ -0,0 +1,13 @@
package org.schabi.newpipe.local.holder;
import android.view.ViewGroup;
import org.schabi.newpipe.R;
import org.schabi.newpipe.local.LocalItemBuilder;
public class LocalStatisticStreamCardItemHolder extends LocalStatisticStreamItemHolder {
public LocalStatisticStreamCardItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_stream_card_item, parent);
}
}

View File

@ -0,0 +1,17 @@
package org.schabi.newpipe.local.holder;
import android.view.ViewGroup;
import org.schabi.newpipe.R;
import org.schabi.newpipe.local.LocalItemBuilder;
/**
* Playlist card UI for list item.
*/
public class RemotePlaylistCardItemHolder extends RemotePlaylistItemHolder {
public RemotePlaylistCardItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_playlist_card_item, parent);
}
}

View File

@ -408,7 +408,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
.firstElement()
.zipWith(historyIdsMaybe, (playlist, historyStreamIds) -> {
// Remove Watched, Functionality data
final List<PlaylistStreamEntry> notWatchedItems = new ArrayList<>();
final List<PlaylistStreamEntry> itemsToKeep = new ArrayList<>();
final boolean isThumbnailPermanent = playlistManager
.getIsPlaylistThumbnailPermanent(playlistId);
boolean thumbnailVideoRemoved = false;
if (removePartiallyWatched) {
@ -417,8 +419,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
playlistItem.getStreamId());
if (indexInHistory < 0) {
notWatchedItems.add(playlistItem);
} else if (!thumbnailVideoRemoved
itemsToKeep.add(playlistItem);
} else if (!isThumbnailPermanent && !thumbnailVideoRemoved
&& playlistManager.getPlaylistThumbnail(playlistId)
.equals(playlistItem.getStreamEntity().getThumbnailUrl())) {
thumbnailVideoRemoved = true;
@ -438,8 +440,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
if (indexInHistory < 0 || (streamStateEntity != null
&& !streamStateEntity.isFinished(duration))) {
notWatchedItems.add(playlistItem);
} else if (!thumbnailVideoRemoved
itemsToKeep.add(playlistItem);
} else if (!isThumbnailPermanent && !thumbnailVideoRemoved
&& playlistManager.getPlaylistThumbnail(playlistId)
.equals(playlistItem.getStreamEntity().getThumbnailUrl())) {
thumbnailVideoRemoved = true;
@ -447,17 +449,17 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
}
}
return new Pair<>(notWatchedItems, thumbnailVideoRemoved);
return new Pair<>(itemsToKeep, thumbnailVideoRemoved);
});
disposables.add(streamsMaybe.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(flow -> {
final List<PlaylistStreamEntry> notWatchedItems = flow.first;
final List<PlaylistStreamEntry> itemsToKeep = flow.first;
final boolean thumbnailVideoRemoved = flow.second;
itemListAdapter.clearStreamItemList();
itemListAdapter.addItems(notWatchedItems);
itemListAdapter.addItems(itemsToKeep);
saveChanges();
if (thumbnailVideoRemoved) {
@ -589,8 +591,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
disposables.add(disposable);
}
private void changeThumbnailUrl(final String thumbnailUrl) {
if (playlistManager == null) {
private void changeThumbnailUrl(final String thumbnailUrl, final boolean isPermanent) {
if (playlistManager == null || (!isPermanent && playlistManager
.getIsPlaylistThumbnailPermanent(playlistId))) {
return;
}
@ -604,7 +607,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
}
final Disposable disposable = playlistManager
.changePlaylistThumbnail(playlistId, thumbnailUrl)
.changePlaylistThumbnail(playlistId, thumbnailUrl, isPermanent)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignore -> successToast.show(), throwable ->
showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK,
@ -613,6 +616,10 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
}
private void updateThumbnailUrl() {
if (playlistManager.getIsPlaylistThumbnailPermanent(playlistId)) {
return;
}
final String newThumbnailUrl;
if (!itemListAdapter.getItemsList().isEmpty()) {
@ -622,7 +629,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
newThumbnailUrl = "drawable://" + R.drawable.placeholder_thumbnail_playlist;
}
changeThumbnailUrl(newThumbnailUrl);
changeThumbnailUrl(newThumbnailUrl, false);
}
private void openRemoveDuplicatesDialog() {
@ -828,7 +835,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
.setAction(
StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL,
(f, i) ->
changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl()))
changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl(),
true))
.setAction(
StreamDialogDefaultEntry.DELETE,
(f, i) -> deleteItem(item))

View File

@ -2,7 +2,9 @@ package org.schabi.newpipe.local.playlist;
import androidx.annotation.Nullable;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.AppDatabase;
import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
import org.schabi.newpipe.database.playlist.dao.PlaylistDAO;
@ -41,7 +43,7 @@ public class LocalPlaylistManager {
}
final StreamEntity defaultStream = streams.get(0);
final PlaylistEntity newPlaylist =
new PlaylistEntity(name, defaultStream.getThumbnailUrl());
new PlaylistEntity(name, defaultStream.getThumbnailUrl(), false);
return Maybe.fromCallable(() -> database.runInTransaction(() ->
upsertStreams(playlistTable.insert(newPlaylist), streams, 0))
@ -91,6 +93,18 @@ public class LocalPlaylistManager {
.getStreamsWithoutDuplicates(playlistId).subscribeOn(Schedulers.io());
}
/**
* Get playlists with attached information about how many times the provided stream is already
* contained in each playlist.
*
* @param streamUrl the stream url for which to check for duplicates
* @return a list of {@link PlaylistDuplicatesEntry}
*/
public Flowable<List<PlaylistDuplicatesEntry>> getPlaylistDuplicates(final String streamUrl) {
return playlistStreamTable.getPlaylistDuplicatesMetadata(streamUrl)
.subscribeOn(Schedulers.io());
}
public Flowable<List<PlaylistStreamEntry>> getPlaylistStreams(final long playlistId) {
return playlistStreamTable.getOrderedStreamsOf(playlistId).subscribeOn(Schedulers.io());
}
@ -101,21 +115,33 @@ public class LocalPlaylistManager {
}
public Maybe<Integer> renamePlaylist(final long playlistId, final String name) {
return modifyPlaylist(playlistId, name, null);
return modifyPlaylist(playlistId, name, null, false);
}
public Maybe<Integer> changePlaylistThumbnail(final long playlistId,
final String thumbnailUrl) {
return modifyPlaylist(playlistId, null, thumbnailUrl);
final String thumbnailUrl,
final boolean isPermanent) {
return modifyPlaylist(playlistId, null, thumbnailUrl, isPermanent);
}
public String getPlaylistThumbnail(final long playlistId) {
return playlistTable.getPlaylist(playlistId).blockingFirst().get(0).getThumbnailUrl();
}
public boolean getIsPlaylistThumbnailPermanent(final long playlistId) {
return playlistTable.getPlaylist(playlistId).blockingFirst().get(0)
.getIsThumbnailPermanent();
}
public String getAutomaticPlaylistThumbnail(final long playlistId) {
final String def = "drawable://" + R.drawable.placeholder_thumbnail_playlist;
return playlistStreamTable.getAutomaticThumbnailUrl(playlistId, def).blockingFirst();
}
private Maybe<Integer> modifyPlaylist(final long playlistId,
@Nullable final String name,
@Nullable final String thumbnailUrl) {
@Nullable final String thumbnailUrl,
final boolean isPermanent) {
return playlistTable.getPlaylist(playlistId)
.firstElement()
.filter(playlistEntities -> !playlistEntities.isEmpty())
@ -126,6 +152,7 @@ public class LocalPlaylistManager {
}
if (thumbnailUrl != null) {
playlist.setThumbnailUrl(thumbnailUrl);
playlist.setIsThumbnailPermanent(isPermanent);
}
return playlistTable.update(playlist);
}).subscribeOn(Schedulers.io());

View File

@ -51,7 +51,8 @@ enum class FeedGroupIcon(
WORLD(34, R.drawable.ic_public),
STAR(35, R.drawable.ic_stars),
SUN(36, R.drawable.ic_wb_sunny),
RSS(37, R.drawable.ic_rss_feed);
RSS(37, R.drawable.ic_rss_feed),
WHATS_NEW(38, R.drawable.ic_subscriptions);
@DrawableRes
fun getDrawableRes(): Int {

View File

@ -433,10 +433,10 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
clear()
if (listViewMode) {
add(FeedGroupAddNewItem())
add(FeedGroupCardItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.RSS))
add(FeedGroupCardItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.WHATS_NEW))
} else {
add(FeedGroupAddNewGridItem())
add(FeedGroupCardGridItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.RSS))
add(FeedGroupCardGridItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.WHATS_NEW))
}
addAll(groups)
}

View File

@ -143,11 +143,9 @@ public final class PlayQueueActivity extends AppCompatActivity
NavigationHelper.playOnMainPlayer(this, player.getPlayQueue(), true);
return true;
case R.id.action_switch_popup:
if (PermissionHelper.isPopupEnabled(this)) {
if (PermissionHelper.isPopupEnabledElseAsk(this)) {
this.player.setRecovery();
NavigationHelper.playOnPopupPlayer(this, player.getPlayQueue(), true);
} else {
PermissionHelper.showPopupEnablementToast(this);
}
return true;
case R.id.action_switch_background:

View File

@ -348,7 +348,7 @@ public final class Player implements PlaybackListener, Listener {
final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString(
R.string.playback_skip_silence_key), getPlaybackSkipSilence());
final boolean samePlayQueue = playQueue != null && playQueue.equals(newQueue);
final boolean samePlayQueue = playQueue != null && playQueue.equalStreamsAndIndex(newQueue);
final int repeatMode = intent.getIntExtra(REPEAT_MODE, getRepeatMode());
final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true);
final boolean isMuted = intent.getBooleanExtra(IS_MUTED, isMuted());
@ -1695,26 +1695,25 @@ public final class Player implements PlaybackListener, Listener {
}
private void saveStreamProgressState(final long progressMillis) {
//noinspection SimplifyOptionalCallChains
if (!getCurrentStreamInfo().isPresent()
|| !prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) {
return;
}
if (DEBUG) {
Log.d(TAG, "saveStreamProgressState() called with: progressMillis=" + progressMillis
+ ", currentMetadata=[" + getCurrentStreamInfo().get().getName() + "]");
}
getCurrentStreamInfo().ifPresent(info -> {
if (!prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) {
return;
}
if (DEBUG) {
Log.d(TAG, "saveStreamProgressState() called with: progressMillis=" + progressMillis
+ ", currentMetadata=[" + info.getName() + "]");
}
databaseUpdateDisposable
.add(recordManager.saveStreamState(getCurrentStreamInfo().get(), progressMillis)
.observeOn(AndroidSchedulers.mainThread())
.doOnError(e -> {
if (DEBUG) {
e.printStackTrace();
}
})
.onErrorComplete()
.subscribe());
databaseUpdateDisposable.add(recordManager.saveStreamState(info, progressMillis)
.observeOn(AndroidSchedulers.mainThread())
.doOnError(e -> {
if (DEBUG) {
e.printStackTrace();
}
})
.onErrorComplete()
.subscribe());
});
}
public void saveStreamProgressState() {
@ -1876,23 +1875,16 @@ public final class Player implements PlaybackListener, Listener {
loadController.disablePreloadingOfCurrentTrack();
}
@Nullable
public VideoStream getSelectedVideoStream() {
@Nullable final MediaItemTag.Quality quality = Optional.ofNullable(currentMetadata)
public Optional<VideoStream> getSelectedVideoStream() {
return Optional.ofNullable(currentMetadata)
.flatMap(MediaItemTag::getMaybeQuality)
.orElse(null);
if (quality == null) {
return null;
}
final List<VideoStream> availableStreams = quality.getSortedVideoStreams();
final int selectedStreamIndex = quality.getSelectedVideoStreamIndex();
if (selectedStreamIndex >= 0 && availableStreams.size() > selectedStreamIndex) {
return availableStreams.get(selectedStreamIndex);
} else {
return null;
}
.filter(quality -> {
final int selectedStreamIndex = quality.getSelectedVideoStreamIndex();
return selectedStreamIndex >= 0
&& selectedStreamIndex < quality.getSortedVideoStreams().size();
})
.map(quality -> quality.getSortedVideoStreams()
.get(quality.getSelectedVideoStreamIndex()));
}
//endregion
@ -2036,40 +2028,36 @@ public final class Player implements PlaybackListener, Listener {
// in livestreams) so we will be not able to execute the block below.
// Reload the play queue manager in this case, which is the behavior when we don't know the
// index of the video renderer or playQueueManagerReloadingNeeded returns true.
final Optional<StreamInfo> optCurrentStreamInfo = getCurrentStreamInfo();
if (!optCurrentStreamInfo.isPresent()) {
reloadPlayQueueManager();
setRecovery();
return;
}
getCurrentStreamInfo().ifPresentOrElse(info -> {
// In the case we don't know the source type, fallback to the one with video with audio
// or audio-only source.
final SourceType sourceType = videoResolver.getStreamSourceType()
.orElse(SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY);
final StreamInfo info = optCurrentStreamInfo.get();
if (playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) {
reloadPlayQueueManager();
} else {
if (StreamTypeUtil.isAudio(info.getStreamType())) {
// Nothing to do more than setting the recovery position
setRecovery();
return;
}
// In the case we don't know the source type, fallback to the one with video with audio or
// audio-only source.
final SourceType sourceType = videoResolver.getStreamSourceType().orElse(
SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY);
final var parametersBuilder = trackSelector.buildUponParameters();
if (playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) {
reloadPlayQueueManager();
} else {
if (StreamTypeUtil.isAudio(info.getStreamType())) {
// Nothing to do more than setting the recovery position
setRecovery();
return;
// Enable/disable the video track and the ability to select subtitles
parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !videoEnabled);
parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, !videoEnabled);
trackSelector.setParameters(parametersBuilder);
}
final DefaultTrackSelector.Parameters.Builder parametersBuilder =
trackSelector.buildUponParameters();
// Enable/disable the video track and the ability to select subtitles
parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !videoEnabled);
parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, !videoEnabled);
trackSelector.setParameters(parametersBuilder);
}
setRecovery();
setRecovery();
}, () -> {
// This is executed when the current stream info is not available.
reloadPlayQueueManager();
setRecovery();
});
}
/**

View File

@ -6,6 +6,7 @@ import android.util.Log
import android.view.GestureDetector
import android.view.MotionEvent
import android.view.View
import androidx.core.os.postDelayed
import org.schabi.newpipe.databinding.PlayerBinding
import org.schabi.newpipe.player.Player
import org.schabi.newpipe.player.ui.VideoPlayerUi
@ -132,13 +133,6 @@ abstract class BasePlayerGestureListener(
private var doubleTapDelay = DOUBLE_TAP_DELAY
private val doubleTapHandler: Handler = Handler(Looper.getMainLooper())
private val doubleTapRunnable = Runnable {
if (DEBUG)
Log.d(TAG, "doubleTapRunnable called")
isDoubleTapping = false
doubleTapControls?.onDoubleTapFinished()
}
private fun startMultiDoubleTap(e: MotionEvent) {
if (!isDoubleTapping) {
@ -155,8 +149,15 @@ abstract class BasePlayerGestureListener(
Log.d(TAG, "keepInDoubleTapMode called")
isDoubleTapping = true
doubleTapHandler.removeCallbacks(doubleTapRunnable)
doubleTapHandler.postDelayed(doubleTapRunnable, doubleTapDelay)
doubleTapHandler.removeCallbacksAndMessages(DOUBLE_TAP)
doubleTapHandler.postDelayed(DOUBLE_TAP_DELAY, DOUBLE_TAP) {
if (DEBUG) {
Log.d(TAG, "doubleTapRunnable called")
}
isDoubleTapping = false
doubleTapControls?.onDoubleTapFinished()
}
}
fun endMultiDoubleTap() {
@ -164,7 +165,7 @@ abstract class BasePlayerGestureListener(
Log.d(TAG, "endMultiDoubleTap called")
isDoubleTapping = false
doubleTapHandler.removeCallbacks(doubleTapRunnable)
doubleTapHandler.removeCallbacksAndMessages(DOUBLE_TAP)
doubleTapControls?.onDoubleTapFinished()
}
@ -181,6 +182,7 @@ abstract class BasePlayerGestureListener(
private const val TAG = "BasePlayerGestListener"
private val DEBUG = Player.DEBUG
private const val DOUBLE_TAP = "doubleTap"
private const val DOUBLE_TAP_DELAY = 550L
}
}

View File

@ -14,6 +14,7 @@ import static java.lang.annotation.RetentionPolicy.SOURCE;
import android.annotation.SuppressLint;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.provider.Settings;
import android.view.accessibility.CaptioningManager;
@ -382,8 +383,11 @@ public final class PlayerHelper {
public static boolean globalScreenOrientationLocked(final Context context) {
// 1: Screen orientation changes using accelerometer
// 0: Screen orientation is locked
// if the accelerometer sensor is missing completely, assume locked orientation
return android.provider.Settings.System.getInt(
context.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 0) == 0;
context.getContentResolver(), Settings.System.ACCELEROMETER_ROTATION, 0) == 0
|| !context.getPackageManager()
.hasSystemFeature(PackageManager.FEATURE_SENSOR_ACCELEROMETER);
}
public static int getProgressiveLoadIntervalBytes(@NonNull final Context context) {

View File

@ -61,12 +61,11 @@ public interface MediaItemTag {
@NonNull
static Optional<MediaItemTag> from(@Nullable final MediaItem mediaItem) {
if (mediaItem == null || mediaItem.localConfiguration == null
|| !(mediaItem.localConfiguration.tag instanceof MediaItemTag)) {
return Optional.empty();
}
return Optional.of((MediaItemTag) mediaItem.localConfiguration.tag);
return Optional.ofNullable(mediaItem)
.map(item -> item.localConfiguration)
.map(localConfiguration -> localConfiguration.tag)
.filter(MediaItemTag.class::isInstance)
.map(MediaItemTag.class::cast);
}
@NonNull

View File

@ -1,21 +1,27 @@
package org.schabi.newpipe.player.mediasource;
import androidx.annotation.NonNull;
import com.google.android.exoplayer2.MediaItem;
import com.google.android.exoplayer2.Timeline;
import com.google.android.exoplayer2.source.CompositeMediaSource;
import com.google.android.exoplayer2.source.MediaPeriod;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.source.WrappingMediaSource;
import com.google.android.exoplayer2.upstream.Allocator;
import com.google.android.exoplayer2.upstream.TransferListener;
import org.schabi.newpipe.player.mediaitem.MediaItemTag;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
public class LoadedMediaSource extends WrappingMediaSource implements ManagedMediaSource {
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
public class LoadedMediaSource extends CompositeMediaSource<Integer> implements ManagedMediaSource {
private final MediaSource source;
private final PlayQueueItem stream;
private final MediaItem mediaItem;
private final long expireTimestamp;
/**
* Uses a {@link WrappingMediaSource} to wrap one child {@link MediaSource}
* Uses a {@link CompositeMediaSource} to wrap one or more child {@link MediaSource}s
* containing actual media. This wrapper {@link LoadedMediaSource} holds the expiration
* timestamp as a {@link ManagedMediaSource} to allow explicit playlist management under
* {@link ManagedMediaSourcePlaylist}.
@ -30,7 +36,7 @@ public class LoadedMediaSource extends WrappingMediaSource implements ManagedMed
@NonNull final MediaItemTag tag,
@NonNull final PlayQueueItem stream,
final long expireTimestamp) {
super(source);
this.source = source;
this.stream = stream;
this.expireTimestamp = expireTimestamp;
@ -45,6 +51,51 @@ public class LoadedMediaSource extends WrappingMediaSource implements ManagedMed
return System.currentTimeMillis() >= expireTimestamp;
}
/**
* Delegates the preparation of child {@link MediaSource}s to the
* {@link CompositeMediaSource} wrapper. Since all {@link LoadedMediaSource}s use only
* a single child media, the child id of 0 is always used (sonar doesn't like null as id here).
*
* @param mediaTransferListener A data transfer listener that will be registered by the
* {@link CompositeMediaSource} for child source preparation.
*/
@Override
protected void prepareSourceInternal(@Nullable final TransferListener mediaTransferListener) {
super.prepareSourceInternal(mediaTransferListener);
prepareChildSource(0, source);
}
/**
* When any child {@link MediaSource} is prepared, the refreshed {@link Timeline} can
* be listened to here. But since {@link LoadedMediaSource} has only a single child source,
* this method is called only once until {@link #releaseSourceInternal()} is called.
* <br><br>
* On refresh, the {@link CompositeMediaSource} delegate will be notified with the
* new {@link Timeline}, otherwise {@link #createPeriod(MediaPeriodId, Allocator, long)}
* will not be called and playback may be stalled.
*
* @param id The unique id used to prepare the child source.
* @param mediaSource The child source whose source info has been refreshed.
* @param timeline The new timeline of the child source.
*/
@Override
protected void onChildSourceInfoRefreshed(final Integer id,
final MediaSource mediaSource,
final Timeline timeline) {
refreshSourceInfo(timeline);
}
@Override
public MediaPeriod createPeriod(final MediaPeriodId id, final Allocator allocator,
final long startPositionUs) {
return source.createPeriod(id, allocator, startPositionUs);
}
@Override
public void releasePeriod(final MediaPeriod mediaPeriod) {
source.releasePeriod(mediaPeriod);
}
@NonNull
@Override
public MediaItem getMediaItem() {

View File

@ -7,8 +7,6 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.collection.ArraySet;
import com.google.android.exoplayer2.source.MediaSource;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
@ -23,10 +21,10 @@ import org.schabi.newpipe.player.playqueue.events.MoveEvent;
import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent;
import org.schabi.newpipe.player.playqueue.events.RemoveEvent;
import org.schabi.newpipe.player.playqueue.events.ReorderEvent;
import org.schabi.newpipe.util.ServiceHelper;
import java.util.Collection;
import java.util.Collections;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
@ -43,6 +41,7 @@ import io.reactivex.rxjava3.subjects.PublishSubject;
import static org.schabi.newpipe.player.mediasource.FailedMediaSource.MediaSourceResolutionException;
import static org.schabi.newpipe.player.mediasource.FailedMediaSource.StreamInfoLoadException;
import static org.schabi.newpipe.player.playqueue.PlayQueue.DEBUG;
import static org.schabi.newpipe.util.ServiceHelper.getCacheExpirationMillis;
public class MediaSourceManager {
@NonNull
@ -421,31 +420,39 @@ public class MediaSourceManager {
}
private Single<ManagedMediaSource> getLoadedMediaSource(@NonNull final PlayQueueItem stream) {
return stream.getStream().map(streamInfo -> {
final MediaSource source = playbackListener.sourceOf(stream, streamInfo);
if (source == null || !MediaItemTag.from(source.getMediaItem()).isPresent()) {
final String message = "Unable to resolve source from stream info. "
+ "URL: " + stream.getUrl() + ", "
+ "audio count: " + streamInfo.getAudioStreams().size() + ", "
+ "video count: " + streamInfo.getVideoOnlyStreams().size() + ", "
+ streamInfo.getVideoStreams().size();
return (ManagedMediaSource)
FailedMediaSource.of(stream, new MediaSourceResolutionException(message));
}
final MediaItemTag tag = MediaItemTag.from(source.getMediaItem()).get();
final long expiration = System.currentTimeMillis()
+ ServiceHelper.getCacheExpirationMillis(streamInfo.getServiceId());
return new LoadedMediaSource(source, tag, stream, expiration);
}).onErrorReturn(throwable -> {
if (throwable instanceof ExtractionException) {
return FailedMediaSource.of(stream, new StreamInfoLoadException(throwable));
}
// Non-source related error expected here (e.g. network),
// should allow retry shortly after the error.
return FailedMediaSource.of(stream, new Exception(throwable),
/*allowRetryIn=*/TimeUnit.MILLISECONDS.convert(3, TimeUnit.SECONDS));
});
return stream.getStream()
.map(streamInfo -> Optional
.ofNullable(playbackListener.sourceOf(stream, streamInfo))
.<ManagedMediaSource>flatMap(source ->
MediaItemTag.from(source.getMediaItem())
.map(tag -> {
final int serviceId = streamInfo.getServiceId();
final long expiration = System.currentTimeMillis()
+ getCacheExpirationMillis(serviceId);
return new LoadedMediaSource(source, tag, stream,
expiration);
})
)
.orElseGet(() -> {
final String message = "Unable to resolve source from stream info. "
+ "URL: " + stream.getUrl()
+ ", audio count: " + streamInfo.getAudioStreams().size()
+ ", video count: " + streamInfo.getVideoOnlyStreams().size()
+ ", " + streamInfo.getVideoStreams().size();
return FailedMediaSource.of(stream,
new MediaSourceResolutionException(message));
})
)
.onErrorReturn(throwable -> {
if (throwable instanceof ExtractionException) {
return FailedMediaSource.of(stream, new StreamInfoLoadException(throwable));
}
// Non-source related error expected here (e.g. network),
// should allow retry shortly after the error.
final long allowRetryIn = TimeUnit.MILLISECONDS.convert(3,
TimeUnit.SECONDS);
return FailedMediaSource.of(stream, new Exception(throwable), allowRetryIn);
});
}
private void onMediaSourceReceived(@NonNull final PlayQueueItem item,

View File

@ -518,12 +518,10 @@ public abstract class PlayQueue implements Serializable {
* This method also gives a chance to track history of items in a queue in
* VideoDetailFragment without duplicating items from two identical queues
*/
@Override
public boolean equals(@Nullable final Object obj) {
if (!(obj instanceof PlayQueue)) {
public boolean equalStreams(@Nullable final PlayQueue other) {
if (other == null) {
return false;
}
final PlayQueue other = (PlayQueue) obj;
if (size() != other.size()) {
return false;
}
@ -539,9 +537,11 @@ public abstract class PlayQueue implements Serializable {
return true;
}
@Override
public int hashCode() {
return streams.hashCode();
public boolean equalStreamsAndIndex(@Nullable final PlayQueue other) {
if (equalStreams(other)) {
return other.getIndex() == getIndex();
}
return false;
}
public boolean isDisposed() {

View File

@ -8,6 +8,7 @@ import android.widget.ImageView;
import androidx.annotation.IntDef;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.graphics.BitmapCompat;
import androidx.core.math.MathUtils;
import androidx.preference.PreferenceManager;
@ -16,7 +17,6 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.util.DeviceUtils;
import java.lang.annotation.Retention;
import java.util.Optional;
import java.util.function.IntSupplier;
import static java.lang.annotation.RetentionPolicy.SOURCE;
@ -66,21 +66,19 @@ public final class SeekbarPreviewThumbnailHelper {
public static void tryResizeAndSetSeekbarPreviewThumbnail(
@NonNull final Context context,
@NonNull final Optional<Bitmap> optPreviewThumbnail,
@Nullable final Bitmap previewThumbnail,
@NonNull final ImageView currentSeekbarPreviewThumbnail,
@NonNull final IntSupplier baseViewWidthSupplier) {
if (!optPreviewThumbnail.isPresent()) {
if (previewThumbnail == null) {
currentSeekbarPreviewThumbnail.setVisibility(View.GONE);
return;
}
currentSeekbarPreviewThumbnail.setVisibility(View.VISIBLE);
final Bitmap srcBitmap = optPreviewThumbnail.get();
// Resize original bitmap
try {
final int srcWidth = srcBitmap.getWidth() > 0 ? srcBitmap.getWidth() : 1;
final int srcWidth = previewThumbnail.getWidth() > 0 ? previewThumbnail.getWidth() : 1;
final int newWidth = MathUtils.clamp(
// Use 1/4 of the width for the preview
Math.round(baseViewWidthSupplier.getAsInt() / 4f),
@ -90,15 +88,15 @@ public final class SeekbarPreviewThumbnailHelper {
Math.round(srcWidth * 2.5f));
final float scaleFactor = (float) newWidth / srcWidth;
final int newHeight = (int) (srcBitmap.getHeight() * scaleFactor);
final int newHeight = (int) (previewThumbnail.getHeight() * scaleFactor);
currentSeekbarPreviewThumbnail.setImageBitmap(BitmapCompat.createScaledBitmap(srcBitmap,
newWidth, newHeight, null, true));
currentSeekbarPreviewThumbnail.setImageBitmap(BitmapCompat
.createScaledBitmap(previewThumbnail, newWidth, newHeight, null, true));
} catch (final Exception ex) {
Log.e(TAG, "Failed to resize and set seekbar preview thumbnail", ex);
currentSeekbarPreviewThumbnail.setVisibility(View.GONE);
} finally {
srcBitmap.recycle();
previewThumbnail.recycle();
}
}
}

View File

@ -32,7 +32,6 @@ import android.view.KeyEvent;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewParent;
import android.view.WindowManager;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
@ -40,6 +39,8 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.view.WindowCompat;
import androidx.core.view.WindowInsetsCompat;
import androidx.fragment.app.FragmentActivity;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
@ -74,6 +75,7 @@ import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.KoreUtils;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
@ -451,11 +453,9 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
getParentActivity().map(Activity::getWindow).ifPresent(window -> {
window.setStatusBarColor(Color.TRANSPARENT);
window.setNavigationBarColor(Color.TRANSPARENT);
final int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION;
window.getDecorView().setSystemUiVisibility(visibility);
window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
WindowCompat.setDecorFitsSystemWindows(window, false);
WindowCompat.getInsetsController(window, window.getDecorView())
.show(WindowInsetsCompat.Type.systemBars());
});
}
}
@ -746,15 +746,10 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
}
private int getNearestStreamSegmentPosition(final long playbackPosition) {
//noinspection SimplifyOptionalCallChains
if (!player.getCurrentStreamInfo().isPresent()) {
return 0;
}
int nearestPosition = 0;
final List<StreamSegment> segments = player.getCurrentStreamInfo()
.get()
.getStreamSegments();
.map(StreamInfo::getStreamSegments)
.orElse(Collections.emptyList());
for (int i = 0; i < segments.size(); i++) {
if (segments.get(i).getStartTimeSeconds() * 1000L > playbackPosition) {
@ -866,14 +861,11 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
@Override
protected void onPlaybackSpeedClicked() {
final AppCompatActivity activity = getParentActivity().orElse(null);
if (activity == null) {
return;
}
PlaybackParameterDialog.newInstance(player.getPlaybackSpeed(), player.getPlaybackPitch(),
player.getPlaybackSkipSilence(), player::setPlaybackParameters)
.show(activity.getSupportFragmentManager(), null);
getParentActivity().ifPresent(activity ->
PlaybackParameterDialog.newInstance(player.getPlaybackSpeed(),
player.getPlaybackPitch(), player.getPlaybackSkipSilence(),
player::setPlaybackParameters)
.show(activity.getSupportFragmentManager(), null));
}
@Override
@ -973,22 +965,22 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
//////////////////////////////////////////////////////////////////////////*/
//region Getters
private Optional<Context> getParentContext() {
return Optional.ofNullable(binding.getRoot().getParent())
.filter(ViewGroup.class::isInstance)
.map(parent -> ((ViewGroup) parent).getContext());
}
public Optional<AppCompatActivity> getParentActivity() {
final ViewParent rootParent = binding.getRoot().getParent();
if (rootParent instanceof ViewGroup) {
final Context activity = ((ViewGroup) rootParent).getContext();
if (activity instanceof AppCompatActivity) {
return Optional.of((AppCompatActivity) activity);
}
}
return Optional.empty();
return getParentContext()
.filter(AppCompatActivity.class::isInstance)
.map(AppCompatActivity.class::cast);
}
public boolean isLandscape() {
// DisplayMetrics from activity context knows about MultiWindow feature
// while DisplayMetrics from app context doesn't
return DeviceUtils.isLandscape(
getParentActivity().map(Context.class::cast).orElse(player.getService()));
return DeviceUtils.isLandscape(getParentContext().orElse(player.getService()));
}
//endregion
}

View File

@ -566,7 +566,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
SeekbarPreviewThumbnailHelper
.tryResizeAndSetSeekbarPreviewThumbnail(
player.getContext(),
seekbarPreviewThumbnailHolder.getBitmapAt(progress),
seekbarPreviewThumbnailHolder.getBitmapAt(progress).orElse(null),
binding.currentSeekbarPreviewThumbnail,
binding.subtitleView::getWidth);
@ -982,61 +982,56 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
}
private void updateStreamRelatedViews() {
//noinspection SimplifyOptionalCallChains
if (!player.getCurrentStreamInfo().isPresent()) {
return;
}
final StreamInfo info = player.getCurrentStreamInfo().get();
player.getCurrentStreamInfo().ifPresent(info -> {
binding.qualityTextView.setVisibility(View.GONE);
binding.playbackSpeed.setVisibility(View.GONE);
binding.qualityTextView.setVisibility(View.GONE);
binding.playbackSpeed.setVisibility(View.GONE);
binding.playbackEndTime.setVisibility(View.GONE);
binding.playbackLiveSync.setVisibility(View.GONE);
binding.playbackEndTime.setVisibility(View.GONE);
binding.playbackLiveSync.setVisibility(View.GONE);
switch (info.getStreamType()) {
case AUDIO_STREAM:
case POST_LIVE_AUDIO_STREAM:
binding.surfaceView.setVisibility(View.GONE);
binding.endScreen.setVisibility(View.VISIBLE);
binding.playbackEndTime.setVisibility(View.VISIBLE);
break;
case AUDIO_LIVE_STREAM:
binding.surfaceView.setVisibility(View.GONE);
binding.endScreen.setVisibility(View.VISIBLE);
binding.playbackLiveSync.setVisibility(View.VISIBLE);
break;
case LIVE_STREAM:
binding.surfaceView.setVisibility(View.VISIBLE);
binding.endScreen.setVisibility(View.GONE);
binding.playbackLiveSync.setVisibility(View.VISIBLE);
break;
case VIDEO_STREAM:
case POST_LIVE_STREAM:
//noinspection SimplifyOptionalCallChains
if (player.getCurrentMetadata() != null
&& !player.getCurrentMetadata().getMaybeQuality().isPresent()
|| (info.getVideoStreams().isEmpty()
&& info.getVideoOnlyStreams().isEmpty())) {
switch (info.getStreamType()) {
case AUDIO_STREAM:
case POST_LIVE_AUDIO_STREAM:
binding.surfaceView.setVisibility(View.GONE);
binding.endScreen.setVisibility(View.VISIBLE);
binding.playbackEndTime.setVisibility(View.VISIBLE);
break;
}
buildQualityMenu();
case AUDIO_LIVE_STREAM:
binding.surfaceView.setVisibility(View.GONE);
binding.endScreen.setVisibility(View.VISIBLE);
binding.playbackLiveSync.setVisibility(View.VISIBLE);
break;
binding.qualityTextView.setVisibility(View.VISIBLE);
binding.surfaceView.setVisibility(View.VISIBLE);
// fallthrough
default:
binding.endScreen.setVisibility(View.GONE);
binding.playbackEndTime.setVisibility(View.VISIBLE);
break;
}
case LIVE_STREAM:
binding.surfaceView.setVisibility(View.VISIBLE);
binding.endScreen.setVisibility(View.GONE);
binding.playbackLiveSync.setVisibility(View.VISIBLE);
break;
buildPlaybackSpeedMenu();
binding.playbackSpeed.setVisibility(View.VISIBLE);
case VIDEO_STREAM:
case POST_LIVE_STREAM:
if (player.getCurrentMetadata() != null
&& player.getCurrentMetadata().getMaybeQuality().isEmpty()
|| (info.getVideoStreams().isEmpty()
&& info.getVideoOnlyStreams().isEmpty())) {
break;
}
buildQualityMenu();
binding.qualityTextView.setVisibility(View.VISIBLE);
binding.surfaceView.setVisibility(View.VISIBLE);
// fallthrough
default:
binding.endScreen.setVisibility(View.GONE);
binding.playbackEndTime.setVisibility(View.VISIBLE);
break;
}
buildPlaybackSpeedMenu();
binding.playbackSpeed.setVisibility(View.VISIBLE);
});
}
//endregion
@ -1065,12 +1060,11 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
qualityPopupMenu.getMenu().add(POPUP_MENU_ID_QUALITY, i, Menu.NONE, MediaFormat
.getNameById(videoStream.getFormatId()) + " " + videoStream.getResolution());
}
final VideoStream selectedVideoStream = player.getSelectedVideoStream();
if (selectedVideoStream != null) {
binding.qualityTextView.setText(selectedVideoStream.getResolution());
}
qualityPopupMenu.setOnMenuItemClickListener(this);
qualityPopupMenu.setOnDismissListener(this);
player.getSelectedVideoStream()
.ifPresent(s -> binding.qualityTextView.setText(s.getResolution()));
}
private void buildPlaybackSpeedMenu() {
@ -1176,12 +1170,9 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
qualityPopupMenu.show();
isSomePopupMenuVisible = true;
final VideoStream videoStream = player.getSelectedVideoStream();
if (videoStream != null) {
//noinspection SetTextI18n
binding.qualityTextView.setText(MediaFormat.getNameById(videoStream.getFormatId())
+ " " + videoStream.getResolution());
}
player.getSelectedVideoStream()
.map(s -> MediaFormat.getNameById(s.getFormatId()) + " " + s.getResolution())
.ifPresent(binding.qualityTextView::setText);
}
/**
@ -1198,8 +1189,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
if (menuItem.getGroupId() == POPUP_MENU_ID_QUALITY) {
final int menuItemIndex = menuItem.getItemId();
@Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata();
//noinspection SimplifyOptionalCallChains
if (currentMetadata == null || !currentMetadata.getMaybeQuality().isPresent()) {
if (currentMetadata == null || currentMetadata.getMaybeQuality().isEmpty()) {
return true;
}
@ -1238,10 +1228,9 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
Log.d(TAG, "onDismiss() called with: menu = [" + menu + "]");
}
isSomePopupMenuVisible = false; //TODO check if this works
final VideoStream selectedVideoStream = player.getSelectedVideoStream();
if (selectedVideoStream != null) {
binding.qualityTextView.setText(selectedVideoStream.getResolution());
}
player.getSelectedVideoStream()
.ifPresent(s -> binding.qualityTextView.setText(s.getResolution()));
if (player.isPlaying()) {
hideControls(DEFAULT_CONTROLS_DURATION, 0);
hideSystemUIIfNeeded();
@ -1300,9 +1289,8 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
// Build UI
buildCaptionMenu(availableLanguages);
//noinspection SimplifyOptionalCallChains
if (player.getTrackSelector().getParameters().getRendererDisabled(
player.getCaptionRendererIndex()) || !selectedTracks.isPresent()) {
player.getCaptionRendererIndex()) || selectedTracks.isEmpty()) {
binding.captionTextView.setText(R.string.caption_none);
} else {
binding.captionTextView.setText(selectedTracks.get().language);

View File

@ -27,14 +27,14 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment {
updateSeekOptions();
listener = (sharedPreferences, s) -> {
listener = (sharedPreferences, key) -> {
// on M and above, if user chooses to minimise to popup player on exit
// and the app doesn't have display over other apps permission,
// show a snackbar to let the user give permission
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
&& s.equals(getString(R.string.minimize_on_exit_key))) {
final String newSetting = sharedPreferences.getString(s, null);
&& getString(R.string.minimize_on_exit_key).equals(key)) {
final String newSetting = sharedPreferences.getString(key, null);
if (newSetting != null
&& newSetting.equals(getString(R.string.minimize_on_exit_popup_key))
&& !Settings.canDrawOverlays(getContext())) {
@ -46,7 +46,7 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment {
.show();
}
} else if (s.equals(getString(R.string.use_inexact_seek_key))) {
} else if (getString(R.string.use_inexact_seek_key).equals(key)) {
updateSeekOptions();
}
};

View File

@ -248,7 +248,7 @@ public abstract class Tab {
@DrawableRes
@Override
public int getTabIconRes(final Context context) {
return R.drawable.ic_rss_feed;
return R.drawable.ic_subscriptions;
}
@Override

View File

@ -20,6 +20,7 @@ public final class TabsJsonHelper {
private static final List<Tab> FALLBACK_INITIAL_TABS_LIST = List.of(
Tab.Type.DEFAULT_KIOSK.getTab(),
Tab.Type.FEED.getTab(),
Tab.Type.SUBSCRIPTIONS.getTab(),
Tab.Type.BOOKMARKS.getTab());

View File

@ -73,7 +73,7 @@ public final class TabsManager {
private SharedPreferences.OnSharedPreferenceChangeListener getPreferenceChangeListener() {
return (sp, key) -> {
if (key.equals(savedTabsKey)) {
if (savedTabsKey.equals(key)) {
if (savedTabsChangeListener != null) {
savedTabsChangeListener.onTabsChanged();
}

View File

@ -1,71 +0,0 @@
package org.schabi.newpipe.util;
import android.text.Layout;
import android.text.Selection;
import android.text.Spannable;
import android.text.Spanned;
import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
import android.view.MotionEvent;
import android.view.View;
import android.widget.TextView;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.external_communication.InternalUrlsHandler;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public class CommentTextOnTouchListener implements View.OnTouchListener {
public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener();
@Override
public boolean onTouch(final View v, final MotionEvent event) {
if (!(v instanceof TextView)) {
return false;
}
final TextView widget = (TextView) v;
final Object text = widget.getText();
if (text instanceof Spanned) {
final Spannable buffer = (Spannable) text;
final int action = event.getAction();
if (action == MotionEvent.ACTION_UP
|| action == MotionEvent.ACTION_DOWN) {
int x = (int) event.getX();
int y = (int) event.getY();
x -= widget.getTotalPaddingLeft();
y -= widget.getTotalPaddingTop();
x += widget.getScrollX();
y += widget.getScrollY();
final Layout layout = widget.getLayout();
final int line = layout.getLineForVertical(y);
final int off = layout.getOffsetForHorizontal(line, x);
final ClickableSpan[] link = buffer.getSpans(off, off,
ClickableSpan.class);
if (link.length != 0) {
if (action == MotionEvent.ACTION_UP) {
if (link[0] instanceof URLSpan) {
final String url = ((URLSpan) link[0]).getURL();
if (!InternalUrlsHandler.handleUrlCommentsTimestamp(
new CompositeDisposable(), v.getContext(), url)) {
ShareUtils.openUrlInBrowser(v.getContext(), url, false);
}
}
} else if (action == MotionEvent.ACTION_DOWN) {
Selection.setSelection(buffer,
buffer.getSpanStart(link[0]),
buffer.getSpanEnd(link[0]));
}
return true;
}
}
}
return false;
}
}

View File

@ -1,5 +1,7 @@
package org.schabi.newpipe.util;
import static android.content.Context.INPUT_SERVICE;
import android.annotation.SuppressLint;
import android.app.UiModeManager;
import android.content.Context;
@ -27,11 +29,10 @@ import org.schabi.newpipe.R;
import java.lang.reflect.Method;
import static android.content.Context.INPUT_SERVICE;
public final class DeviceUtils {
private static final String AMAZON_FEATURE_FIRE_TV = "amazon.hardware.fire_tv";
private static final boolean SAMSUNG = Build.MANUFACTURER.equals("samsung");
private static Boolean isTV = null;
private static Boolean isFireTV = null;
@ -120,6 +121,10 @@ public final class DeviceUtils {
return true;
}
if (!SAMSUNG) {
return false;
// DeX is Samsung-specific, skip the checks below on non-Samsung devices
}
// DeX check for standalone and multi-window mode, from:
// https://developer.samsung.com/samsung-dex/modify-optimizing.html
try {

View File

@ -20,6 +20,7 @@
package org.schabi.newpipe.util;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD;
import android.content.Context;
import android.util.Log;
@ -51,7 +52,7 @@ import org.schabi.newpipe.extractor.search.SearchInfo;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor;
import org.schabi.newpipe.util.external_communication.TextLinkifier;
import org.schabi.newpipe.util.text.TextLinkifier;
import java.util.Collections;
import java.util.List;
@ -319,8 +320,9 @@ public final class ExtractorHelper {
}
metaInfoSeparator.setVisibility(View.VISIBLE);
TextLinkifier.createLinksFromHtmlBlock(metaInfoTextView, stringBuilder.toString(),
HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, disposables);
TextLinkifier.fromHtml(metaInfoTextView, stringBuilder.toString(),
HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, null, disposables,
SET_LINK_MOVEMENT_METHOD);
}
}

View File

@ -156,8 +156,7 @@ public final class NavigationHelper {
public static void playOnPopupPlayer(final Context context,
final PlayQueue queue,
final boolean resumePlayback) {
if (!PermissionHelper.isPopupEnabled(context)) {
PermissionHelper.showPopupEnablementToast(context);
if (!PermissionHelper.isPopupEnabledElseAsk(context)) {
return;
}
@ -183,6 +182,10 @@ public final class NavigationHelper {
public static void enqueueOnPlayer(final Context context,
final PlayQueue queue,
final PlayerType playerType) {
if (playerType == PlayerType.POPUP && !PermissionHelper.isPopupEnabledElseAsk(context)) {
return;
}
Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show();
final Intent intent = getPlayerEnqueueIntent(context, PlayerService.class, queue);

View File

@ -9,8 +9,6 @@ import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.provider.Settings;
import android.view.Gravity;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.RequiresApi;
@ -128,18 +126,21 @@ public final class PermissionHelper {
}
}
public static boolean isPopupEnabled(final Context context) {
return Build.VERSION.SDK_INT < Build.VERSION_CODES.M
|| checkSystemAlertWindowPermission(context);
}
public static void showPopupEnablementToast(final Context context) {
final Toast toast =
Toast.makeText(context, R.string.msg_popup_permission, Toast.LENGTH_LONG);
final TextView messageView = toast.getView().findViewById(android.R.id.message);
if (messageView != null) {
messageView.setGravity(Gravity.CENTER);
/**
* Determines whether the popup is enabled, and if it is not, starts the system activity to
* request the permission with {@link #checkSystemAlertWindowPermission(Context)} and shows a
* toast to the user explaining why the permission is needed.
*
* @param context the Android context
* @return whether the popup is enabled
*/
public static boolean isPopupEnabledElseAsk(final Context context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M
|| checkSystemAlertWindowPermission(context)) {
return true;
} else {
Toast.makeText(context, R.string.msg_popup_permission, Toast.LENGTH_LONG).show();
return false;
}
toast.show();
}
}

View File

@ -41,6 +41,7 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.info_list.ItemViewMode;
public final class ThemeHelper {
private ThemeHelper() {
@ -332,7 +333,6 @@ public final class ThemeHelper {
}
}
/**
* Returns whether the grid layout or the list layout should be used. If the user set "auto"
* mode in settings, decides based on screen orientation (landscape) and size.
@ -341,19 +341,8 @@ public final class ThemeHelper {
* @return true:use grid layout, false:use list layout
*/
public static boolean shouldUseGridLayout(final Context context) {
final String listMode = PreferenceManager.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.list_view_mode_key),
context.getString(R.string.list_view_mode_value));
if (listMode.equals(context.getString(R.string.list_view_mode_list_key))) {
return false;
} else if (listMode.equals(context.getString(R.string.list_view_mode_grid_key))) {
return true;
} else /* listMode.equals("auto") */ {
final Configuration configuration = context.getResources().getConfiguration();
return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
&& configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE);
}
final ItemViewMode mode = getItemViewMode(context);
return mode == ItemViewMode.GRID;
}
/**
@ -367,6 +356,36 @@ public final class ThemeHelper {
context.getResources().getDimensionPixelSize(R.dimen.channel_item_grid_min_width));
}
/**
* Returns item view mode.
* @param context to read preference and parse string
* @return Returns one of ItemViewMode
*/
public static ItemViewMode getItemViewMode(final Context context) {
final String listMode = PreferenceManager.getDefaultSharedPreferences(context)
.getString(context.getString(R.string.list_view_mode_key),
context.getString(R.string.list_view_mode_value));
final ItemViewMode result;
if (listMode.equals(context.getString(R.string.list_view_mode_list_key))) {
result = ItemViewMode.LIST;
} else if (listMode.equals(context.getString(R.string.list_view_mode_grid_key))) {
result = ItemViewMode.GRID;
} else if (listMode.equals(context.getString(R.string.list_view_mode_card_key))) {
result = ItemViewMode.CARD;
} else {
// Auto mode - evaluate whether to use Grid based on screen real estate.
final Configuration configuration = context.getResources().getConfiguration();
final boolean useGrid = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
&& configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE);
if (useGrid) {
result = ItemViewMode.GRID;
} else {
result = ItemViewMode.LIST;
}
}
return result;
}
/**
* Calculates the number of grid stream info items that can fit horizontally on the screen. The
* width of a grid stream info item is obtained from the thumbnail width plus the right and left

View File

@ -90,19 +90,16 @@ public final class ShareUtils {
// No browser set as default (doesn't work on some devices)
openAppChooser(context, intent, true);
} else {
if (defaultPackageName.isEmpty()) {
// No app installed to open a web url
Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show();
return false;
} else {
try {
try {
// will be empty on Android 12+
if (!defaultPackageName.isEmpty()) {
intent.setPackage(defaultPackageName);
context.startActivity(intent);
} catch (final ActivityNotFoundException e) {
// Not a browser but an app chooser because of OEMs changes
intent.setPackage(null);
openAppChooser(context, intent, true);
}
context.startActivity(intent);
} catch (final ActivityNotFoundException e) {
// Not a browser but an app chooser because of OEMs changes
intent.setPackage(null);
openAppChooser(context, intent, true);
}
}
@ -313,10 +310,15 @@ public final class ShareUtils {
return;
}
clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text));
if (Build.VERSION.SDK_INT < 33) {
// Android 13 has its own "copied to clipboard" dialog
Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show();
try {
clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text));
if (Build.VERSION.SDK_INT < 33) {
// Android 13 has its own "copied to clipboard" dialog
Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show();
}
} catch (final Exception e) {
Log.e(TAG, "Error when trying to copy text to clipboard", e);
Toast.makeText(context, R.string.msg_failed_to_copy, Toast.LENGTH_SHORT).show();
}
}

View File

@ -1,289 +0,0 @@
package org.schabi.newpipe.util.external_communication;
import android.content.Context;
import android.text.SpannableStringBuilder;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.URLSpan;
import android.text.util.Linkify;
import android.util.Log;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.text.HtmlCompat;
import org.schabi.newpipe.extractor.Info;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.util.NavigationHelper;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import io.noties.markwon.Markwon;
import io.noties.markwon.linkify.LinkifyPlugin;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
import static org.schabi.newpipe.util.external_communication.InternalUrlsHandler.playOnPopup;
public final class TextLinkifier {
public static final String TAG = TextLinkifier.class.getSimpleName();
// Looks for hashtags with characters from any language (\p{L}), numbers, or underscores
private static final Pattern HASHTAGS_PATTERN =
Pattern.compile("(#[\\p{L}0-9_]+)");
private TextLinkifier() {
}
/**
* Create web links for contents with an HTML description.
* <p>
* This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence,
* Info, CompositeDisposable)} after having linked the URLs with
* {@link HtmlCompat#fromHtml(String, int)}.
*
* @param textView the TextView to set the htmlBlock linked
* @param htmlBlock the htmlBlock to be linked
* @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, int)}
* will be called
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at
* the specific time, and hashtags to search for the term in the correct
* service
* @param disposables disposables created by the method are added here and their lifecycle
* should be handled by the calling class
*/
public static void createLinksFromHtmlBlock(@NonNull final TextView textView,
final String htmlBlock,
final int htmlCompatFlag,
@Nullable final Info relatedInfo,
final CompositeDisposable disposables) {
changeIntentsOfDescriptionLinks(
textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfo, disposables);
}
/**
* Create web links for contents with a plain text description.
* <p>
* This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence,
* Info, CompositeDisposable)} after having linked the URLs with
* {@link TextView#setAutoLinkMask(int)} and
* {@link TextView#setText(CharSequence, TextView.BufferType)}.
*
* @param textView the TextView to set the plain text block linked
* @param plainTextBlock the block of plain text to be linked
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at
* the specific time, and hashtags to search for the term in the correct
* service
* @param disposables disposables created by the method are added here and their lifecycle
* should be handled by the calling class
*/
public static void createLinksFromPlainText(@NonNull final TextView textView,
final String plainTextBlock,
@Nullable final Info relatedInfo,
final CompositeDisposable disposables) {
textView.setAutoLinkMask(Linkify.WEB_URLS);
textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE);
changeIntentsOfDescriptionLinks(textView, textView.getText(), relatedInfo, disposables);
}
/**
* Create web links for contents with a markdown description.
* <p>
* This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence,
* Info, CompositeDisposable)} after creating an {@link Markwon} object and using
* {@link Markwon#setMarkdown(TextView, String)}.
*
* @param textView the TextView to set the plain text block linked
* @param markdownBlock the block of markdown text to be linked
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at
* the specific time, and hashtags to search for the term in the correct
* @param disposables disposables created by the method are added here and their lifecycle
* should be handled by the calling class
*/
public static void createLinksFromMarkdownText(@NonNull final TextView textView,
final String markdownBlock,
@Nullable final Info relatedInfo,
final CompositeDisposable disposables) {
final Markwon markwon = Markwon.builder(textView.getContext())
.usePlugin(LinkifyPlugin.create()).build();
changeIntentsOfDescriptionLinks(textView, markwon.toMarkdown(markdownBlock), relatedInfo,
disposables);
}
/**
* Add click listeners which opens a search on hashtags in a plain text.
* <p>
* This method finds all timestamps in the {@link SpannableStringBuilder} of the description
* using a regular expression, adds for each a {@link ClickableSpan} which opens
* {@link NavigationHelper#openSearch(Context, int, String)} and makes a search on the hashtag,
* in the service of the content.
*
* @param context the context to use
* @param spannableDescription the SpannableStringBuilder with the text of the
* content description
* @param relatedInfo used to search for the term in the correct service
*/
private static void addClickListenersOnHashtags(final Context context,
@NonNull final SpannableStringBuilder
spannableDescription,
final Info relatedInfo) {
final String descriptionText = spannableDescription.toString();
final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText);
while (hashtagsMatches.find()) {
final int hashtagStart = hashtagsMatches.start(1);
final int hashtagEnd = hashtagsMatches.end(1);
final String parsedHashtag = descriptionText.substring(hashtagStart, hashtagEnd);
// don't add a ClickableSpan if there is already one, which should be a part of an URL,
// already parsed before
if (spannableDescription.getSpans(hashtagStart, hashtagEnd,
ClickableSpan.class).length == 0) {
spannableDescription.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull final View view) {
NavigationHelper.openSearch(context, relatedInfo.getServiceId(),
parsedHashtag);
}
}, hashtagStart, hashtagEnd, 0);
}
}
}
/**
* Add click listeners which opens the popup player on timestamps in a plain text.
* <p>
* This method finds all timestamps in the {@link SpannableStringBuilder} of the description
* using a regular expression, adds for each a {@link ClickableSpan} which opens the popup
* player at the time indicated in the timestamps.
*
* @param context the context to use
* @param spannableDescription the SpannableStringBuilder with the text of the
* content description
* @param relatedInfo what to open in the popup player when timestamps are clicked
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
*/
private static void addClickListenersOnTimestamps(final Context context,
@NonNull final SpannableStringBuilder
spannableDescription,
final Info relatedInfo,
final CompositeDisposable disposables) {
final String descriptionText = spannableDescription.toString();
final Matcher timestampsMatches =
TimestampExtractor.TIMESTAMPS_PATTERN.matcher(descriptionText);
while (timestampsMatches.find()) {
final TimestampExtractor.TimestampMatchDTO timestampMatchDTO =
TimestampExtractor.getTimestampFromMatcher(
timestampsMatches,
descriptionText);
if (timestampMatchDTO == null) {
continue;
}
spannableDescription.setSpan(
new ClickableSpan() {
@Override
public void onClick(@NonNull final View view) {
playOnPopup(
context,
relatedInfo.getUrl(),
relatedInfo.getService(),
timestampMatchDTO.seconds(),
disposables);
}
},
timestampMatchDTO.timestampStart(),
timestampMatchDTO.timestampEnd(),
0);
}
}
/**
* Change links generated by libraries in the description of a content to a custom link action
* and add click listeners on timestamps in this description.
* <p>
* Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of
* a content, this method will parse the {@link CharSequence} and replace all current web links
* with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}.
* This method will also add click listeners on timestamps in this description, which will play
* the content in the popup player at the time indicated in the timestamp, by using
* {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder, Info,
* CompositeDisposable)} method and click listeners on hashtags, by using
* {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder, Info)},
* which will open a search on the current service with the hashtag.
* <p>
* This method is required in order to intercept links and e.g. show a confirmation dialog
* before opening a web link.
*
* @param textView the TextView in which the converted CharSequence will be applied
* @param chars the CharSequence to be parsed
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at
* the specific time, and hashtags to search for the term in the correct
* service
* @param disposables disposables created by the method are added here and their lifecycle
* should be handled by the calling class
*/
private static void changeIntentsOfDescriptionLinks(final TextView textView,
final CharSequence chars,
@Nullable final Info relatedInfo,
final CompositeDisposable disposables) {
disposables.add(Single.fromCallable(() -> {
final Context context = textView.getContext();
// add custom click actions on web links
final SpannableStringBuilder textBlockLinked = new SpannableStringBuilder(chars);
final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), URLSpan.class);
for (final URLSpan span : urls) {
final String url = span.getURL();
final ClickableSpan clickableSpan = new ClickableSpan() {
public void onClick(@NonNull final View view) {
if (!InternalUrlsHandler.handleUrlDescriptionTimestamp(
new CompositeDisposable(), context, url)) {
ShareUtils.openUrlInBrowser(context, url, false);
}
}
};
textBlockLinked.setSpan(clickableSpan, textBlockLinked.getSpanStart(span),
textBlockLinked.getSpanEnd(span), textBlockLinked.getSpanFlags(span));
textBlockLinked.removeSpan(span);
}
// add click actions on plain text timestamps only for description of contents,
// unneeded for meta-info or other TextViews
if (relatedInfo != null) {
if (relatedInfo instanceof StreamInfo) {
addClickListenersOnTimestamps(context, textBlockLinked, relatedInfo,
disposables);
}
addClickListenersOnHashtags(context, textBlockLinked, relatedInfo);
}
return textBlockLinked;
}).subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
textBlockLinked -> setTextViewCharSequence(textView, textBlockLinked),
throwable -> {
Log.e(TAG, "Unable to linkify text", throwable);
// this should never happen, but if it does, just fallback to it
setTextViewCharSequence(textView, chars);
}));
}
private static void setTextViewCharSequence(@NonNull final TextView textView,
final CharSequence charSequence) {
textView.setText(charSequence);
textView.setMovementMethod(LinkMovementMethod.getInstance());
textView.setVisibility(View.VISIBLE);
}
}

View File

@ -0,0 +1,42 @@
package org.schabi.newpipe.util.text;
import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine;
import android.annotation.SuppressLint;
import android.text.Spanned;
import android.text.style.ClickableSpan;
import android.view.MotionEvent;
import android.view.View;
import android.widget.TextView;
public class CommentTextOnTouchListener implements View.OnTouchListener {
public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener();
@SuppressLint("ClickableViewAccessibility")
@Override
public boolean onTouch(final View v, final MotionEvent event) {
if (!(v instanceof TextView)) {
return false;
}
final TextView widget = (TextView) v;
final CharSequence text = widget.getText();
if (text instanceof Spanned) {
final Spanned buffer = (Spanned) text;
final int action = event.getAction();
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
final int offset = getOffsetForHorizontalLine(widget, event);
final ClickableSpan[] links = buffer.getSpans(offset, offset, ClickableSpan.class);
if (links.length != 0) {
if (action == MotionEvent.ACTION_UP) {
links[0].onClick(widget);
}
// we handle events that intersect links, so return true
return true;
}
}
}
return false;
}
}

View File

@ -0,0 +1,36 @@
package org.schabi.newpipe.util.text;
import android.content.Context;
import android.view.View;
import androidx.annotation.NonNull;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
final class HashtagLongPressClickableSpan extends LongPressClickableSpan {
@NonNull
private final Context context;
@NonNull
private final String parsedHashtag;
private final int relatedInfoServiceId;
HashtagLongPressClickableSpan(@NonNull final Context context,
@NonNull final String parsedHashtag,
final int relatedInfoServiceId) {
this.context = context;
this.parsedHashtag = parsedHashtag;
this.relatedInfoServiceId = relatedInfoServiceId;
}
@Override
public void onClick(@NonNull final View view) {
NavigationHelper.openSearch(context, relatedInfoServiceId, parsedHashtag);
}
@Override
public void onLongClick(@NonNull final View view) {
ShareUtils.copyToClipboard(context, parsedHashtag);
}
}

View File

@ -1,4 +1,4 @@
package org.schabi.newpipe.util.external_communication;
package org.schabi.newpipe.util.text;
import android.content.Context;
import android.util.Log;

View File

@ -0,0 +1,12 @@
package org.schabi.newpipe.util.text;
import android.text.style.ClickableSpan;
import android.view.View;
import androidx.annotation.NonNull;
public abstract class LongPressClickableSpan extends ClickableSpan {
public abstract void onLongClick(@NonNull View view);
}

View File

@ -0,0 +1,77 @@
package org.schabi.newpipe.util.text;
import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine;
import android.os.Handler;
import android.os.Looper;
import android.text.Selection;
import android.text.Spannable;
import android.text.method.LinkMovementMethod;
import android.text.method.MovementMethod;
import android.view.MotionEvent;
import android.view.ViewConfiguration;
import android.widget.TextView;
import androidx.annotation.NonNull;
// Class adapted from https://stackoverflow.com/a/31786969
public class LongPressLinkMovementMethod extends LinkMovementMethod {
private static final int LONG_PRESS_TIME = ViewConfiguration.getLongPressTimeout();
private static LongPressLinkMovementMethod instance;
private Handler longClickHandler;
private boolean isLongPressed = false;
@Override
public boolean onTouchEvent(@NonNull final TextView widget,
@NonNull final Spannable buffer,
@NonNull final MotionEvent event) {
final int action = event.getAction();
if (action == MotionEvent.ACTION_CANCEL && longClickHandler != null) {
longClickHandler.removeCallbacksAndMessages(null);
}
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
final int offset = getOffsetForHorizontalLine(widget, event);
final LongPressClickableSpan[] link = buffer.getSpans(offset, offset,
LongPressClickableSpan.class);
if (link.length != 0) {
if (action == MotionEvent.ACTION_UP) {
if (longClickHandler != null) {
longClickHandler.removeCallbacksAndMessages(null);
}
if (!isLongPressed) {
link[0].onClick(widget);
}
isLongPressed = false;
} else {
Selection.setSelection(buffer, buffer.getSpanStart(link[0]),
buffer.getSpanEnd(link[0]));
if (longClickHandler != null) {
longClickHandler.postDelayed(() -> {
link[0].onLongClick(widget);
isLongPressed = true;
}, LONG_PRESS_TIME);
}
}
return true;
}
}
return super.onTouchEvent(widget, buffer, event);
}
public static MovementMethod getInstance() {
if (instance == null) {
instance = new LongPressLinkMovementMethod();
instance.longClickHandler = new Handler(Looper.myLooper());
}
return instance;
}
}

View File

@ -0,0 +1,369 @@
package org.schabi.newpipe.util.text;
import android.content.Context;
import android.text.SpannableStringBuilder;
import android.text.style.URLSpan;
import android.text.util.Linkify;
import android.util.Log;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.text.HtmlCompat;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import io.noties.markwon.Markwon;
import io.noties.markwon.linkify.LinkifyPlugin;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
public final class TextLinkifier {
public static final String TAG = TextLinkifier.class.getSimpleName();
// Looks for hashtags with characters from any language (\p{L}), numbers, or underscores
private static final Pattern HASHTAGS_PATTERN = Pattern.compile("(#[\\p{L}0-9_]+)");
public static final Consumer<TextView> SET_LINK_MOVEMENT_METHOD =
v -> v.setMovementMethod(LongPressLinkMovementMethod.getInstance());
private TextLinkifier() {
}
/**
* Create links for contents with an {@link Description} in the various possible formats.
* <p>
* This will call one of these three functions based on the format: {@link #fromHtml},
* {@link #fromMarkdown} or {@link #fromPlainText}.
*
* @param textView the TextView to set the htmlBlock linked
* @param description the htmlBlock to be linked
* @param htmlCompatFlag the int flag to be set if {@link HtmlCompat#fromHtml(String, int)}
* will be called (not used for formats different than HTML)
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
* service
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
* timestamps to open the stream in the popup player at the specific
* time
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
* @param onCompletion will be run when setting text to the textView completes; use {@link
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
*/
public static void fromDescription(@NonNull final TextView textView,
@NonNull final Description description,
final int htmlCompatFlag,
@Nullable final StreamingService relatedInfoService,
@Nullable final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables,
@Nullable final Consumer<TextView> onCompletion) {
switch (description.getType()) {
case Description.HTML:
TextLinkifier.fromHtml(textView, description.getContent(), htmlCompatFlag,
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
break;
case Description.MARKDOWN:
TextLinkifier.fromMarkdown(textView, description.getContent(),
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
break;
case Description.PLAIN_TEXT: default:
TextLinkifier.fromPlainText(textView, description.getContent(),
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
break;
}
}
/**
* Create links for contents with an HTML description.
*
* <p>
* This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService,
* String, CompositeDisposable, Consumer)} after having linked the URLs with
* {@link HtmlCompat#fromHtml(String, int)}.
* </p>
*
* @param textView the {@link TextView} to set the the HTML string block linked
* @param htmlBlock the HTML string block to be linked
* @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String,
* int)} will be called
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
* service
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
* timestamps to open the stream in the popup player at the specific
* time
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
* @param onCompletion will be run when setting text to the textView completes; use {@link
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
*/
public static void fromHtml(@NonNull final TextView textView,
@NonNull final String htmlBlock,
final int htmlCompatFlag,
@Nullable final StreamingService relatedInfoService,
@Nullable final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables,
@Nullable final Consumer<TextView> onCompletion) {
changeLinkIntents(
textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfoService,
relatedStreamUrl, disposables, onCompletion);
}
/**
* Create links for contents with a plain text description.
*
* <p>
* This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService,
* String, CompositeDisposable, Consumer)} after having linked the URLs with
* {@link TextView#setAutoLinkMask(int)} and
* {@link TextView#setText(CharSequence, TextView.BufferType)}.
* </p>
*
* @param textView the {@link TextView} to set the plain text block linked
* @param plainTextBlock the block of plain text to be linked
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
* service
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
* timestamps to open the stream in the popup player at the specific
* time
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
* @param onCompletion will be run when setting text to the textView completes; use {@link
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
*/
public static void fromPlainText(@NonNull final TextView textView,
@NonNull final String plainTextBlock,
@Nullable final StreamingService relatedInfoService,
@Nullable final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables,
@Nullable final Consumer<TextView> onCompletion) {
textView.setAutoLinkMask(Linkify.WEB_URLS);
textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE);
changeLinkIntents(textView, textView.getText(), relatedInfoService,
relatedStreamUrl, disposables, onCompletion);
}
/**
* Create links for contents with a markdown description.
*
* <p>
* This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService,
* String, CompositeDisposable, Consumer)} after creating a {@link Markwon} object and using
* {@link Markwon#setMarkdown(TextView, String)}.
* </p>
*
* @param textView the {@link TextView} to set the plain text block linked
* @param markdownBlock the block of markdown text to be linked
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
* service
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
* timestamps to open the stream in the popup player at the specific
* time
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
* @param onCompletion will be run when setting text to the textView completes; use {@link
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
*/
public static void fromMarkdown(@NonNull final TextView textView,
@NonNull final String markdownBlock,
@Nullable final StreamingService relatedInfoService,
@Nullable final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables,
@Nullable final Consumer<TextView> onCompletion) {
final Markwon markwon = Markwon.builder(textView.getContext())
.usePlugin(LinkifyPlugin.create()).build();
changeLinkIntents(textView, markwon.toMarkdown(markdownBlock),
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
}
/**
* Change links generated by libraries in the description of a content to a custom link action
* and add click listeners on timestamps in this description.
*
* <p>
* Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of
* a content, this method will parse the {@link CharSequence} and replace all current web links
* with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}.
* </p>
*
* <p>
* This method will also add click listeners on timestamps in this description, which will play
* the content in the popup player at the time indicated in the timestamp, by using
* {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder,
* StreamingService, String, CompositeDisposable)} method and click listeners on hashtags, by
* using {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder,
* StreamingService)}, which will open a search on the current service with the hashtag.
* </p>
*
* <p>
* This method is required in order to intercept links and e.g. show a confirmation dialog
* before opening a web link.
* </p>
*
* @param textView the {@link TextView} to which the converted {@link CharSequence}
* will be applied
* @param chars the {@link CharSequence} to be parsed
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
* service
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
* timestamps to open the stream in the popup player at the specific
* time
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
* @param onCompletion will be run when setting text to the textView completes; use {@link
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
*/
private static void changeLinkIntents(@NonNull final TextView textView,
@NonNull final CharSequence chars,
@Nullable final StreamingService relatedInfoService,
@Nullable final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables,
@Nullable final Consumer<TextView> onCompletion) {
disposables.add(Single.fromCallable(() -> {
final Context context = textView.getContext();
// add custom click actions on web links
final SpannableStringBuilder textBlockLinked =
new SpannableStringBuilder(chars);
final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(),
URLSpan.class);
for (final URLSpan span : urls) {
final String url = span.getURL();
final LongPressClickableSpan longPressClickableSpan =
new UrlLongPressClickableSpan(context, disposables, url);
textBlockLinked.setSpan(longPressClickableSpan,
textBlockLinked.getSpanStart(span),
textBlockLinked.getSpanEnd(span),
textBlockLinked.getSpanFlags(span));
textBlockLinked.removeSpan(span);
}
// add click actions on plain text timestamps only for description of contents,
// unneeded for meta-info or other TextViews
if (relatedInfoService != null) {
if (relatedStreamUrl != null) {
addClickListenersOnTimestamps(context, textBlockLinked,
relatedInfoService, relatedStreamUrl, disposables);
}
addClickListenersOnHashtags(context, textBlockLinked, relatedInfoService);
}
return textBlockLinked;
}).subscribeOn(Schedulers.computation())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
textBlockLinked ->
setTextViewCharSequence(textView, textBlockLinked, onCompletion),
throwable -> {
Log.e(TAG, "Unable to linkify text", throwable);
// this should never happen, but if it does, just fallback to it
setTextViewCharSequence(textView, chars, onCompletion);
}));
}
/**
* Add click listeners which opens a search on hashtags in a plain text.
*
* <p>
* This method finds all timestamps in the {@link SpannableStringBuilder} of the description
* using a regular expression, adds for each a {@link LongPressClickableSpan} which opens
* {@link NavigationHelper#openSearch(Context, int, String)} and makes a search on the hashtag,
* in the service of the content when pressed, and copy the hashtag to clipboard when
* long-pressed, if allowed by the caller method (parameter {@code addLongClickCopyListener}).
* </p>
*
* @param context the {@link Context} to use
* @param spannableDescription the {@link SpannableStringBuilder} with the text of the
* content description
* @param relatedInfoService used to search for the term in the correct service
*/
private static void addClickListenersOnHashtags(
@NonNull final Context context,
@NonNull final SpannableStringBuilder spannableDescription,
@NonNull final StreamingService relatedInfoService) {
final String descriptionText = spannableDescription.toString();
final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText);
while (hashtagsMatches.find()) {
final int hashtagStart = hashtagsMatches.start(1);
final int hashtagEnd = hashtagsMatches.end(1);
final String parsedHashtag = descriptionText.substring(hashtagStart, hashtagEnd);
// Don't add a LongPressClickableSpan if there is already one, which should be a part
// of an URL, already parsed before
if (spannableDescription.getSpans(hashtagStart, hashtagEnd,
LongPressClickableSpan.class).length == 0) {
final int serviceId = relatedInfoService.getServiceId();
spannableDescription.setSpan(
new HashtagLongPressClickableSpan(context, parsedHashtag, serviceId),
hashtagStart, hashtagEnd, 0);
}
}
}
/**
* Add click listeners which opens the popup player on timestamps in a plain text.
*
* <p>
* This method finds all timestamps in the {@link SpannableStringBuilder} of the description
* using a regular expression, adds for each a {@link LongPressClickableSpan} which opens the
* popup player at the time indicated in the timestamps and copy the timestamp in clipboard
* when long-pressed.
* </p>
*
* @param context the {@link Context} to use
* @param spannableDescription the {@link SpannableStringBuilder} with the text of the
* content description
* @param relatedInfoService the service of the {@code relatedStreamUrl}
* @param relatedStreamUrl what to open in the popup player when timestamps are clicked
* @param disposables disposables created by the method are added here and their
* lifecycle should be handled by the calling class
*/
private static void addClickListenersOnTimestamps(
@NonNull final Context context,
@NonNull final SpannableStringBuilder spannableDescription,
@NonNull final StreamingService relatedInfoService,
@NonNull final String relatedStreamUrl,
@NonNull final CompositeDisposable disposables) {
final String descriptionText = spannableDescription.toString();
final Matcher timestampsMatches = TimestampExtractor.TIMESTAMPS_PATTERN.matcher(
descriptionText);
while (timestampsMatches.find()) {
final TimestampExtractor.TimestampMatchDTO timestampMatchDTO =
TimestampExtractor.getTimestampFromMatcher(timestampsMatches, descriptionText);
if (timestampMatchDTO == null) {
continue;
}
spannableDescription.setSpan(
new TimestampLongPressClickableSpan(context, descriptionText, disposables,
relatedInfoService, relatedStreamUrl, timestampMatchDTO),
timestampMatchDTO.timestampStart(),
timestampMatchDTO.timestampEnd(),
0);
}
}
private static void setTextViewCharSequence(@NonNull final TextView textView,
@Nullable final CharSequence charSequence,
@Nullable final Consumer<TextView> onCompletion) {
textView.setText(charSequence);
textView.setVisibility(View.VISIBLE);
if (onCompletion != null) {
onCompletion.accept(textView);
}
}
}

View File

@ -1,4 +1,7 @@
package org.schabi.newpipe.util.external_communication;
package org.schabi.newpipe.util.text;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@ -15,17 +18,18 @@ public final class TimestampExtractor {
}
/**
* Get's a single timestamp from a matcher.
* Gets a single timestamp from a matcher.
*
* @param timestampMatches The matcher which was created using {@link #TIMESTAMPS_PATTERN}
* @param baseText The text where the pattern was applied to /
* where the matcher is based upon
* @return If a match occurred: a {@link TimestampMatchDTO} filled with information.<br/>
* If not <code>null</code>.
* @param timestampMatches the matcher which was created using {@link #TIMESTAMPS_PATTERN}
* @param baseText the text where the pattern was applied to / where the matcher is
* based upon
* @return if a match occurred, a {@link TimestampMatchDTO} filled with information, otherwise
* {@code null}.
*/
@Nullable
public static TimestampMatchDTO getTimestampFromMatcher(
final Matcher timestampMatches,
final String baseText) {
@NonNull final Matcher timestampMatches,
@NonNull final String baseText) {
int timestampStart = timestampMatches.start(1);
if (timestampStart == -1) {
timestampStart = timestampMatches.start(2);

View File

@ -0,0 +1,78 @@
package org.schabi.newpipe.util.text;
import static org.schabi.newpipe.util.text.InternalUrlsHandler.playOnPopup;
import android.content.Context;
import android.view.View;
import androidx.annotation.NonNull;
import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
final class TimestampLongPressClickableSpan extends LongPressClickableSpan {
@NonNull
private final Context context;
@NonNull
private final String descriptionText;
@NonNull
private final CompositeDisposable disposables;
@NonNull
private final StreamingService relatedInfoService;
@NonNull
private final String relatedStreamUrl;
@NonNull
private final TimestampExtractor.TimestampMatchDTO timestampMatchDTO;
TimestampLongPressClickableSpan(
@NonNull final Context context,
@NonNull final String descriptionText,
@NonNull final CompositeDisposable disposables,
@NonNull final StreamingService relatedInfoService,
@NonNull final String relatedStreamUrl,
@NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) {
this.context = context;
this.descriptionText = descriptionText;
this.disposables = disposables;
this.relatedInfoService = relatedInfoService;
this.relatedStreamUrl = relatedStreamUrl;
this.timestampMatchDTO = timestampMatchDTO;
}
@Override
public void onClick(@NonNull final View view) {
playOnPopup(context, relatedStreamUrl, relatedInfoService,
timestampMatchDTO.seconds(), disposables);
}
@Override
public void onLongClick(@NonNull final View view) {
ShareUtils.copyToClipboard(context, getTimestampTextToCopy(
relatedInfoService, relatedStreamUrl, descriptionText, timestampMatchDTO));
}
@NonNull
private static String getTimestampTextToCopy(
@NonNull final StreamingService relatedInfoService,
@NonNull final String relatedStreamUrl,
@NonNull final String descriptionText,
@NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) {
// TODO: use extractor methods to get timestamps when this feature will be implemented in it
if (relatedInfoService == ServiceList.YouTube) {
return relatedStreamUrl + "&t=" + timestampMatchDTO.seconds();
} else if (relatedInfoService == ServiceList.SoundCloud
|| relatedInfoService == ServiceList.MediaCCC) {
return relatedStreamUrl + "#t=" + timestampMatchDTO.seconds();
} else if (relatedInfoService == ServiceList.PeerTube) {
return relatedStreamUrl + "?start=" + timestampMatchDTO.seconds();
}
// Return timestamp text for other services
return descriptionText.subSequence(timestampMatchDTO.timestampStart(),
timestampMatchDTO.timestampEnd()).toString();
}
}

View File

@ -0,0 +1,38 @@
package org.schabi.newpipe.util.text;
import android.text.Layout;
import android.view.MotionEvent;
import android.widget.TextView;
import androidx.annotation.NonNull;
public final class TouchUtils {
private TouchUtils() {
}
/**
* Get the character offset on the closest line to the position pressed by the user of a
* {@link TextView} from a {@link MotionEvent} which was fired on this {@link TextView}.
*
* @param textView the {@link TextView} on which the {@link MotionEvent} was fired
* @param event the {@link MotionEvent} which was fired
* @return the character offset on the closest line to the position pressed by the user
*/
public static int getOffsetForHorizontalLine(@NonNull final TextView textView,
@NonNull final MotionEvent event) {
int x = (int) event.getX();
int y = (int) event.getY();
x -= textView.getTotalPaddingLeft();
y -= textView.getTotalPaddingTop();
x += textView.getScrollX();
y += textView.getScrollY();
final Layout layout = textView.getLayout();
final int line = layout.getLineForVertical(y);
return layout.getOffsetForHorizontal(line, x);
}
}

View File

@ -0,0 +1,41 @@
package org.schabi.newpipe.util.text;
import android.content.Context;
import android.view.View;
import androidx.annotation.NonNull;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
final class UrlLongPressClickableSpan extends LongPressClickableSpan {
@NonNull
private final Context context;
@NonNull
private final CompositeDisposable disposables;
@NonNull
private final String url;
UrlLongPressClickableSpan(@NonNull final Context context,
@NonNull final CompositeDisposable disposables,
@NonNull final String url) {
this.context = context;
this.disposables = disposables;
this.url = url;
}
@Override
public void onClick(@NonNull final View view) {
if (!InternalUrlsHandler.handleUrlDescriptionTimestamp(
disposables, context, url)) {
ShareUtils.openUrlInBrowser(context, url, false);
}
}
@Override
public void onLongClick(@NonNull final View view) {
ShareUtils.copyToClipboard(context, url);
}
}

View File

@ -13,9 +13,10 @@ import org.schabi.newpipe.util.external_communication.ShareUtils;
/**
* An {@link AppCompatEditText} which uses {@link ShareUtils#shareText(Context, String, String)}
* when sharing selected text by using the {@code Share} command of the floating actions.
*
* <p>
* This allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing text
* from {@link AppCompatEditText} on EMUI devices.
* This class allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing
* text from {@link AppCompatEditText} on EMUI devices.
* </p>
*/
public class NewPipeEditText extends AppCompatEditText {

View File

@ -1,6 +1,7 @@
package org.schabi.newpipe.views;
import android.content.Context;
import android.text.method.MovementMethod;
import android.util.AttributeSet;
import androidx.annotation.NonNull;
@ -13,9 +14,11 @@ import org.schabi.newpipe.util.external_communication.ShareUtils;
/**
* An {@link AppCompatTextView} which uses {@link ShareUtils#shareText(Context, String, String)}
* when sharing selected text by using the {@code Share} command of the floating actions.
*
* <p>
* This allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing text
* from {@link AppCompatTextView} on EMUI devices.
* This class allows NewPipe to show Android share sheet instead of EMUI share sheet when sharing
* text from {@link AppCompatTextView} on EMUI devices and also to keep movement method set when a
* text change occurs, if the text cannot be selected and text links are clickable.
* </p>
*/
public class NewPipeTextView extends AppCompatTextView {
@ -34,6 +37,16 @@ public class NewPipeTextView extends AppCompatTextView {
super(context, attrs, defStyleAttr);
}
@Override
public void setText(final CharSequence text, final BufferType type) {
// We need to set again the movement method after a text change because Android resets the
// movement method to the default one in the case where the text cannot be selected and
// text links are clickable (which is the default case in NewPipe).
final MovementMethod movementMethod = this.getMovementMethod();
super.setText(text, type);
setMovementMethod(movementMethod);
}
@Override
public boolean onTextContextMenuItem(final int id) {
if (id == android.R.id.shareText) {

View File

@ -310,7 +310,7 @@ public class DownloadManagerService extends Service {
}
private void handlePreferenceChange(SharedPreferences prefs, @NonNull String key) {
if (key.equals(getString(R.string.downloads_maximum_retry))) {
if (getString(R.string.downloads_maximum_retry).equals(key)) {
try {
String value = prefs.getString(key, getString(R.string.downloads_maximum_retry_default));
mManager.mPrefMaxRetry = value == null ? 0 : Integer.parseInt(value);
@ -318,13 +318,13 @@ public class DownloadManagerService extends Service {
mManager.mPrefMaxRetry = 0;
}
mManager.updateMaximumAttempts();
} else if (key.equals(getString(R.string.downloads_cross_network))) {
} else if (getString(R.string.downloads_cross_network).equals(key)) {
mManager.mPrefMeteredDownloads = prefs.getBoolean(key, false);
} else if (key.equals(getString(R.string.downloads_queue_limit))) {
} else if (getString(R.string.downloads_queue_limit).equals(key)) {
mManager.mPrefQueueLimit = prefs.getBoolean(key, true);
} else if (key.equals(getString(R.string.download_path_video_key))) {
} else if (getString(R.string.download_path_video_key).equals(key)) {
mManager.mMainStorageVideo = loadMainVideoStorage();
} else if (key.equals(getString(R.string.download_path_audio_key))) {
} else if (getString(R.string.download_path_audio_key).equals(key)) {
mManager.mMainStorageAudio = loadMainAudioStorage();
}
}

View File

@ -48,6 +48,7 @@ import androidx.appcompat.app.AlertDialog;
import androidx.core.app.NotificationCompat;
import androidx.core.content.ContextCompat;
import androidx.core.content.FileProvider;
import androidx.core.os.HandlerCompat;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.RecyclerView;
import androidx.recyclerview.widget.RecyclerView.Adapter;
@ -91,6 +92,10 @@ public class MissionAdapter extends Adapter<ViewHolder> implements Handler.Callb
private static final String UNDEFINED_PROGRESS = "--.-%";
private static final String DEFAULT_MIME_TYPE = "*/*";
private static final String UNDEFINED_ETA = "--:--";
private static final String UPDATER = "updater";
private static final String DELETE = "deleteFinishedDownloads";
private static final int HASH_NOTIFICATION_ID = 123790;
private final Context mContext;
@ -110,9 +115,6 @@ public class MissionAdapter extends Adapter<ViewHolder> implements Handler.Callb
private final ArrayList<Mission> mHidden;
private Snackbar mSnackbar;
private final Runnable rUpdater = this::updater;
private final Runnable rDelete = this::deleteFinishedDownloads;
private final CompositeDisposable compositeDisposable = new CompositeDisposable();
public MissionAdapter(Context context, @NonNull DownloadManager downloadManager, View emptyMessage, View root) {
@ -595,12 +597,12 @@ public class MissionAdapter extends Adapter<ViewHolder> implements Handler.Callb
i.remove();
}
applyChanges();
mHandler.removeCallbacks(rDelete);
mHandler.removeCallbacksAndMessages(DELETE);
});
mSnackbar.setActionTextColor(Color.YELLOW);
mSnackbar.show();
mHandler.postDelayed(rDelete, 5000);
HandlerCompat.postDelayed(mHandler, this::deleteFinishedDownloads, DELETE, 5000);
} else if (!delete) {
mDownloadManager.forgetFinishedDownloads();
applyChanges();
@ -786,15 +788,14 @@ public class MissionAdapter extends Adapter<ViewHolder> implements Handler.Callb
public void onResume() {
mDeleter.resume();
mHandler.post(rUpdater);
HandlerCompat.postDelayed(mHandler, this::updater, UPDATER, 0);
}
public void onPaused() {
mDeleter.pause();
mHandler.removeCallbacks(rUpdater);
mHandler.removeCallbacksAndMessages(UPDATER);
}
public void recoverMission(DownloadMission mission) {
ViewHolderItem h = getViewHolder(mission);
if (h == null) return;
@ -817,7 +818,7 @@ public class MissionAdapter extends Adapter<ViewHolder> implements Handler.Callb
updateProgress(h);
}
mHandler.postDelayed(rUpdater, 1000);
HandlerCompat.postDelayed(mHandler, this::updater, UPDATER, 1000);
}
private boolean isNotFinite(double value) {

View File

@ -6,6 +6,8 @@ import android.graphics.Color;
import android.os.Handler;
import android.view.View;
import androidx.core.os.HandlerCompat;
import com.google.android.material.snackbar.Snackbar;
import org.schabi.newpipe.R;
@ -19,6 +21,10 @@ import us.shandian.giga.service.DownloadManager.MissionIterator;
import us.shandian.giga.ui.adapter.MissionAdapter;
public class Deleter {
private static final String COMMIT = "commit";
private static final String NEXT = "next";
private static final String SHOW = "show";
private static final int TIMEOUT = 5000;// ms
private static final int DELAY = 350;// ms
private static final int DELAY_RESUME = 400;// ms
@ -34,10 +40,6 @@ public class Deleter {
private final Handler mHandler;
private final View mView;
private final Runnable rShow;
private final Runnable rNext;
private final Runnable rCommit;
public Deleter(View v, Context c, MissionAdapter a, DownloadManager d, MissionIterator i, Handler h) {
mView = v;
mContext = c;
@ -46,21 +48,15 @@ public class Deleter {
mIterator = i;
mHandler = h;
// use variables to know the reference of the lambdas
rShow = this::show;
rNext = this::next;
rCommit = this::commit;
items = new ArrayList<>(2);
}
public void append(Mission item) {
/* If a mission is removed from the list while the Snackbar for a previously
* removed item is still showing, commit the action for the previous item
* immediately. This prevents Snackbars from stacking up in reverse order.
*/
mHandler.removeCallbacks(rCommit);
mHandler.removeCallbacksAndMessages(COMMIT);
commit();
mIterator.hide(item);
@ -82,7 +78,7 @@ public class Deleter {
pause();
running = true;
mHandler.postDelayed(rNext, DELAY);
HandlerCompat.postDelayed(mHandler, this::next, NEXT, DELAY);
}
private void next() {
@ -95,7 +91,7 @@ public class Deleter {
snackbar.setActionTextColor(Color.YELLOW);
snackbar.show();
mHandler.postDelayed(rCommit, TIMEOUT);
HandlerCompat.postDelayed(mHandler, this::commit, COMMIT, TIMEOUT);
}
private void commit() {
@ -124,15 +120,16 @@ public class Deleter {
public void pause() {
running = false;
mHandler.removeCallbacks(rNext);
mHandler.removeCallbacks(rShow);
mHandler.removeCallbacks(rCommit);
mHandler.removeCallbacksAndMessages(NEXT);
mHandler.removeCallbacksAndMessages(SHOW);
mHandler.removeCallbacksAndMessages(COMMIT);
if (snackbar != null) snackbar.dismiss();
}
public void resume() {
if (running) return;
mHandler.postDelayed(rShow, DELAY_RESUME);
if (!running) {
HandlerCompat.postDelayed(mHandler, this::show, SHOW, DELAY_RESUME);
}
}
public void dispose() {

View File

@ -1,10 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="@color/defaultIconTint"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M2,17h2v0.5L3,17.5v1h1v0.5L2,19v1h3v-4L2,16v1zM3,8h1L4,4L2,4v1h1v3zM2,11h1.8L2,13.1v0.9h3v-1L3.2,13L5,10.9L5,10L2,10v1zM7,5v2h14L21,5L7,5zM7,19h14v-2L7,17v2zM7,13h14v-2L7,11v2z" />
</vector>

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:autoMirrored="true"
android:height="24dp"
android:tint="#000000"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp" >
<path android:fillColor="@android:color/white" android:pathData="M21,5c-1.11,-0.35 -2.33,-0.5 -3.5,-0.5c-1.95,0 -4.05,0.4 -5.5,1.5c-1.45,-1.1 -3.55,-1.5 -5.5,-1.5S2.45,4.9 1,6v14.65c0,0.25 0.25,0.5 0.5,0.5c0.1,0 0.15,-0.05 0.25,-0.05C3.1,20.45 5.05,20 6.5,20c1.95,0 4.05,0.4 5.5,1.5c1.35,-0.85 3.8,-1.5 5.5,-1.5c1.65,0 3.35,0.3 4.75,1.05c0.1,0.05 0.15,0.05 0.25,0.05c0.25,0 0.5,-0.25 0.5,-0.5V6C22.4,5.55 21.75,5.25 21,5zM21,18.5c-1.1,-0.35 -2.3,-0.5 -3.5,-0.5c-1.7,0 -4.15,0.65 -5.5,1.5V8c1.35,-0.85 3.8,-1.5 5.5,-1.5c1.2,0 2.4,0.15 3.5,0.5V18.5z"/>
<path android:fillColor="@android:color/white" android:pathData="M17.5,10.5c0.88,0 1.73,0.09 2.5,0.26V9.24C19.21,9.09 18.36,9 17.5,9c-1.7,0 -3.24,0.29 -4.5,0.83v1.66C14.13,10.85 15.7,10.5 17.5,10.5z"/>
<path android:fillColor="@android:color/white" android:pathData="M13,12.49v1.66c1.13,-0.64 2.7,-0.99 4.5,-0.99c0.88,0 1.73,0.09 2.5,0.26V11.9c-0.79,-0.15 -1.64,-0.24 -2.5,-0.24C15.8,11.66 14.26,11.96 13,12.49z"/>
<path android:fillColor="@android:color/white" android:pathData="M17.5,14.33c-1.7,0 -3.24,0.29 -4.5,0.83v1.66c1.13,-0.64 2.7,-0.99 4.5,-0.99c0.88,0 1.73,0.09 2.5,0.26v-1.52C19.21,14.41 18.36,14.33 17.5,14.33z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:height="24dp"
android:tint="@color/defaultIconTint"
android:viewportHeight="24"
android:viewportWidth="24"
android:width="24dp" >
<path
android:fillColor="@android:color/white"
android:pathData="M20,8L4,8L4,6h16v2zM18,2L6,2v2h12L18,2zM22,12v8c0,1.1 -0.9,2 -2,2L4,22c-1.1,0 -2,-0.9 -2,-2v-8c0,-1.1 0.9,-2 2,-2h16c1.1,0 2,0.9 2,2zM16,16l-6,-3.27v6.53L16,16z"/>
</vector>

View File

@ -34,11 +34,26 @@
tools:ignore="RtlHardcoded" />
</RelativeLayout>
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/playlist_duplicate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/newPlaylist"
android:layout_marginHorizontal="@dimen/video_item_search_padding"
android:layout_marginBottom="@dimen/video_item_search_padding"
android:gravity="center"
android:text="@string/duplicate_in_playlist"
android:textAppearance="?android:attr/textAppearanceMedium"
android:textSize="13sp"
android:visibility="gone"
tools:text="@tools:sample/lorem[20]"
tools:visibility="visible" />
<View
android:id="@+id/separator"
android:layout_width="match_parent"
android:layout_height="1dp"
android:layout_below="@+id/newPlaylist"
android:layout_below="@+id/playlist_duplicate"
android:layout_marginLeft="@dimen/video_item_search_padding"
android:layout_marginRight="@dimen/video_item_search_padding"
android:background="?attr/separator_color" />

View File

@ -6,7 +6,7 @@
android:layout_height="wrap_content"
android:paddingVertical="6dp">
<TextView
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/metadata_type_view"
android:layout_width="96dp"
android:layout_height="wrap_content"
@ -19,7 +19,7 @@
app:layout_constraintTop_toTopOf="parent"
tools:text="Licence" />
<TextView
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/metadata_content_view"
android:layout_width="0dp"
android:layout_height="wrap_content"

View File

@ -39,12 +39,24 @@
android:id="@+id/itemAdditionalDetails"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/itemTitleView"
android:layout_below="@id/itemTitleView"
android:layout_centerHorizontal="true"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textSize="@dimen/video_item_search_upload_date_text_size"
tools:ignore="RtlHardcoded"
tools:text="10M subscribers" />
tools:text="10M subscribers • 100 videos" />
<TextView
android:id="@+id/itemChannelDescriptionView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@id/itemAdditionalDetails"
android:layout_centerHorizontal="true"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textSize="@dimen/video_item_search_upload_date_text_size"
android:gravity="center"
tools:ignore="RtlHardcoded"
tools:text="@tools:sample/lorem/random" />
</RelativeLayout>

View File

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:id="@+id/itemRoot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:paddingTop="@dimen/margin_small"
android:paddingBottom="@dimen/spacing_micro"
android:clickable="true"
android:focusable="true">
<ImageView
android:id="@+id/itemThumbnailView"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="fitStart"
android:src="@drawable/placeholder_thumbnail_playlist"
app:layout_constraintDimensionRatio="16:9"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="RtlHardcoded" />
<View
android:id="@+id/videoCountOverlay"
android:layout_width="0dp"
android:layout_height="0dp"
android:background="@color/playlist_stream_count_background_color"
app:layout_constraintBottom_toBottomOf="@id/itemThumbnailView"
app:layout_constraintEnd_toEndOf="@id/itemThumbnailView"
app:layout_constraintTop_toTopOf="@id/itemThumbnailView"
app:layout_constraintWidth_percent="0.35" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/itemStreamCountView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="@color/duration_text_color"
android:textStyle="bold"
app:layout_constraintBottom_toTopOf="@id/playIcon"
app:layout_constraintEnd_toEndOf="@id/videoCountOverlay"
app:layout_constraintStart_toStartOf="@id/videoCountOverlay"
app:layout_constraintTop_toTopOf="@id/videoCountOverlay"
app:layout_constraintVertical_chainStyle="packed"
tools:text="314159" />
<!-- playIcon includes 8dp start margin to give center aligned look
when placed next to the video count -->
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/playIcon"
android:layout_width="@dimen/player_main_buttons_min_width"
android:layout_height="@dimen/player_main_buttons_min_width"
android:layout_marginStart="8dp"
android:src="@drawable/ic_playlist_play"
android:tint="@color/duration_text_color"
app:layout_constraintBottom_toBottomOf="@id/videoCountOverlay"
app:layout_constraintEnd_toEndOf="@id/videoCountOverlay"
app:layout_constraintStart_toStartOf="@id/videoCountOverlay"
app:layout_constraintTop_toBottomOf="@id/itemStreamCountView" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/itemTitleView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_small"
android:layout_marginTop="@dimen/spacing_nano"
android:layout_marginEnd="@dimen/margin_small"
android:ellipsize="end"
android:maxLines="2"
android:textAppearance="?android:textAppearanceListItem"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/itemThumbnailView"
tools:ignore="RtlHardcoded"
tools:text="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blanditLorem ipsumLorem ipsumLorem ipsumLorem ipsumLorem ipsumLorem ipsumLorem ipsum" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/itemUploaderView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:lines="1"
android:textAppearance="?android:textAppearanceSmall"
app:layout_constraintEnd_toEndOf="@id/itemTitleView"
app:layout_constraintStart_toStartOf="@id/itemTitleView"
app:layout_constraintTop_toBottomOf="@id/itemTitleView"
tools:ignore="RtlHardcoded"
tools:text="Uploader" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:id="@+id/itemRoot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="@dimen/channel_item_grid_padding"
android:paddingBottom="@dimen/channel_item_grid_padding"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true">
<ImageView
android:id="@+id/itemThumbnailView"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="2dp"
android:layout_marginEnd="2dp"
android:scaleType="centerCrop"
android:src="@drawable/placeholder_thumbnail_video"
app:layout_constraintBottom_toTopOf="@+id/itemProgressView"
app:layout_constraintDimensionRatio="16:9"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/itemDurationView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/video_item_search_duration_margin"
android:layout_marginBottom="@dimen/video_item_search_duration_margin"
android:background="@color/duration_background_color"
android:paddingHorizontal="@dimen/video_item_search_duration_horizontal_padding"
android:paddingVertical="@dimen/video_item_search_duration_vertical_padding"
android:textAllCaps="true"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="@color/duration_text_color"
android:textSize="@dimen/video_item_search_duration_text_size"
app:layout_constraintBottom_toBottomOf="@id/itemThumbnailView"
app:layout_constraintRight_toRightOf="@id/itemThumbnailView"
tools:text="1:09:10" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/itemVideoTitleView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_small"
android:layout_marginEnd="@dimen/margin_small"
android:ellipsize="end"
android:maxLines="2"
android:layout_marginTop="@dimen/margin_small"
android:textAppearance="?textAppearanceListItem"
app:layout_constraintEnd_toEndOf="@id/itemThumbnailView"
app:layout_constraintStart_toStartOf="@id/itemThumbnailView"
app:layout_constraintTop_toBottomOf="@id/itemThumbnailView"
tools:text="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blanditLorem ipsumLorem ipsumLorem ipsumLorem ipsumLorem ipsumLorem ipsumLorem ipsum" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/itemUploaderView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/spacing_micro"
android:layout_marginEnd="@dimen/margin_small"
android:ellipsize="end"
android:lines="1"
android:includeFontPadding="false"
android:textAppearance="?android:textAppearanceSmall"
app:layout_constraintEnd_toStartOf="@id/itemAdditionalDetails"
app:layout_constraintStart_toStartOf="@id/itemVideoTitleView"
app:layout_constraintTop_toBottomOf="@+id/itemVideoTitleView"
tools:text="Uploader" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/itemAdditionalDetails"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:lines="1"
android:includeFontPadding="false"
android:textAppearance="?android:textAppearanceSmall"
app:layout_constraintBottom_toBottomOf="@id/itemUploaderView"
app:layout_constraintEnd_toEndOf="@+id/itemVideoTitleView"
app:layout_constraintStart_toEndOf="@id/itemUploaderView"
app:layout_constraintTop_toTopOf="@id/itemUploaderView"
tools:text="2 years ago • 10M views" />
<org.schabi.newpipe.views.AnimatedProgressBar
android:id="@+id/itemProgressView"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="0dp"
android:layout_height="4dp"
android:progressDrawable="?progress_horizontal_drawable"
app:layout_constraintBottom_toBottomOf="@id/itemThumbnailView"
app:layout_constraintEnd_toEndOf="@+id/itemThumbnailView"
app:layout_constraintStart_toStartOf="@+id/itemThumbnailView" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:id="@+id/itemRoot"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/margin_normal"
android:layout_marginBottom="@dimen/margin_small"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true">
<ImageView
android:id="@+id/itemThumbnailView"
android:layout_width="0dp"
android:layout_height="0dp"
android:scaleType="centerCrop"
android:src="@drawable/placeholder_thumbnail_video"
app:layout_constraintDimensionRatio="16:9"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:ignore="RtlHardcoded" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/itemDurationView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/spacing_nano"
android:layout_marginBottom="@dimen/spacing_nano"
android:background="@color/duration_background_color"
android:paddingLeft="@dimen/video_item_search_duration_horizontal_padding"
android:paddingTop="@dimen/video_item_search_duration_vertical_padding"
android:paddingRight="@dimen/video_item_search_duration_horizontal_padding"
android:paddingBottom="@dimen/video_item_search_duration_vertical_padding"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="@color/duration_text_color"
android:textSize="@dimen/video_item_search_duration_text_size"
app:layout_constraintBottom_toBottomOf="@id/itemThumbnailView"
app:layout_constraintEnd_toEndOf="@id/itemThumbnailView"
tools:ignore="RtlHardcoded"
tools:text="1:09:10" />
<ImageView
android:id="@+id/itemHandle"
android:layout_width="48dp"
android:layout_height="36dp"
android:contentDescription="@string/detail_drag_description"
android:paddingLeft="@dimen/video_item_search_image_right_margin"
android:scaleType="center"
android:src="@drawable/ic_drag_handle"
app:layout_constraintEnd_toEndOf="@id/itemThumbnailView"
app:layout_constraintTop_toBottomOf="@id/itemThumbnailView"
tools:ignore="RtlHardcoded,RtlSymmetry" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/itemVideoTitleView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_below="@id/itemThumbnailView"
android:layout_marginStart="@dimen/margin_small"
android:layout_marginTop="@dimen/spacing_nano"
android:layout_marginEnd="@dimen/spacing_micro"
android:ellipsize="end"
android:maxLines="2"
android:textAppearance="?android:textAppearanceListItem"
app:layout_constraintEnd_toStartOf="@id/itemHandle"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/itemThumbnailView"
tools:text="Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique..." />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/itemAdditionalDetails"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_below="@+id/itemVideoTitleView"
android:layout_marginTop="@dimen/spacing_nano"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceSmall"
app:layout_constraintEnd_toEndOf="@id/itemVideoTitleView"
app:layout_constraintStart_toStartOf="@id/itemVideoTitleView"
app:layout_constraintTop_toBottomOf="@id/itemVideoTitleView"
tools:text="Uploader" />
<org.schabi.newpipe.views.AnimatedProgressBar
android:id="@+id/itemProgressView"
style="@style/Widget.AppCompat.ProgressBar.Horizontal"
android:layout_width="wrap_content"
android:layout_height="4dp"
android:progressDrawable="?progress_horizontal_drawable"
app:layout_constraintBottom_toBottomOf="@id/itemThumbnailView"
app:layout_constraintEnd_toEndOf="@id/itemThumbnailView"
app:layout_constraintStart_toStartOf="@id/itemThumbnailView" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -212,11 +212,11 @@
android:clickable="true"
android:focusable="true"
android:paddingStart="6dp"
android:paddingTop="5dp"
android:paddingEnd="6dp"
android:paddingBottom="3dp"
android:paddingTop="3dp"
android:scaleType="fitCenter"
android:src="@drawable/ic_format_list_numbered"
android:src="@drawable/ic_menu_book"
android:visibility="gone"
app:tint="@color/white"
tools:ignore="ContentDescription,RtlHardcoded" />

View File

@ -778,4 +778,10 @@
<string name="app_update_available_notification_text">انقر للتنزيل %s</string>
<string name="fast_mode">الوضع السريع</string>
<string name="import_subscriptions_hint">استيراد الاشتراكات أو تصديرها من القائمة المكونة من 3 نقاط</string>
<string name="night_theme_available">هذا الخيار متاح فقط إذا تم تحديد %s للسمة</string>
<string name="unset_playlist_thumbnail">إلغاء تعيين الصورة المصغرة الدائمة</string>
<string name="msg_failed_to_copy">فشل النسخ إلى الحافظة</string>
<string name="card">البطاقة</string>
<string name="playlist_add_stream_success_duplicate">تمت إضافة وقت (أوقات) مكررة %d</string>
<string name="duplicate_in_playlist">تحتوي قوائم التشغيل رمادية اللون بالفعل على هذا العنصر.</string>
</resources>

View File

@ -0,0 +1,101 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="no_player_found_toast">কোনো ষ্ট্ৰিম প্লেয়াৰ পোৱা নগ\'ল (আপুনি ইয়াক বজাবলৈ VLC ইনষ্টল কৰিব পাৰে)।</string>
<string name="install">ইনষ্টল</string>
<string name="cancel">বাতিল কৰক</string>
<string name="ok">ঠিক আছে</string>
<string name="open_in_browser">ব্ৰাউজাৰত খোলক</string>
<string name="open_in_popup_mode">POPUP অৱস্থাত খোলক</string>
<string name="open_with">...ৰ সৈতে খোলক</string>
<string name="share">চেয়াৰ</string>
<string name="upload_date_text">%1$s ত প্ৰকাশ কৰা হৈছে</string>
<string name="no_player_found">কোনো ষ্ট্ৰিম প্লেয়াৰ পোৱা নগ\'ল। VLC ইনষ্টল কৰক\?</string>
<string name="download">ডাউনল’ড</string>
<string name="controls_download_desc">ষ্ট্ৰিম কৰা ফাইল ডাউনলোড কৰক</string>
<string name="search">সন্ধান কৰক</string>
<string name="settings">ছেটিংছ</string>
<string name="search_showing_result_for">%s ৰ বাবে ফলাফল দেখুৱা হৈছে</string>
<string name="share_dialog_title">চেয়াৰ কৰক</string>
<string name="use_external_video_player_summary">কিছু ৰিজ’লিউচনত অডিঅ’ আঁতৰাওক</string>
<string name="subscribed_button_title">চাবস্ক্ৰাইব কৰা হ\'ল</string>
<string name="unsubscribe">আনচাবস্ক্ৰাইব</string>
<string name="main_bg_subtitle">আৰম্ভ কৰিবলৈ মেগনিফাইং গ্লাছৰ চিহ্নত টিপক।</string>
<string name="subscribe_button_title">চাবস্ক্ৰাইব</string>
<string name="mark_as_watched">চোৱা হ\'ল (চিহ্নিত কৰক)</string>
<string name="did_you_mean">আপুনি \"%1$s\" বুজাইছিল নেকি\?</string>
<string name="use_external_video_player_title">বাহ্যিক ভিডিঅ’ প্লেয়াৰ ব্যৱহাৰ কৰক</string>
<string name="use_external_audio_player_title">বাহ্যিক অডিঅ’ প্লেয়াৰ ব্যৱহাৰ কৰক</string>
<string name="channel_unsubscribed">Channel আনচাবস্ক্ৰাইব কৰা হ\'ল</string>
<string name="subscription_change_failed">subscription সলনি কৰিব পৰা নগ\'ল</string>
<string name="subscription_update_failed">subscription আপডেট কৰিব পৰা নগ\'ল</string>
<string name="show_info">তথ্য দেখুৱাওক</string>
<string name="tab_subscriptions">চাবস্ক্ৰিপচন</string>
<string name="tab_bookmarks">বুকমাৰ্ক কৰা প্লেলিষ্ট</string>
<string name="tab_choose">টেব নিৰ্বাচন কৰক</string>
<string name="controls_background_title">বেকগ্ৰাউণ্ড</string>
<string name="controls_popup_title">পপ-আপ</string>
<string name="default_resolution_title">স্থায়ী ৰিজ\'লিউচন</string>
<string name="default_popup_resolution_title">স্থায়ী পপআপ ৰিজোলিউচন</string>
<string name="show_higher_resolutions_title">উচ্চ ৰিজ\'লিউচন দেখুৱাওক</string>
<string name="show_higher_resolutions_summary">কেৱল কিছুমান ডিভাইচেহে 2K/4K ভিডিঅ’ বজাব পাৰে</string>
<string name="play_with_kodi_title">Kodi ৰ সৈতে বজাওক</string>
<string name="kore_not_found">Kore এপ ইনষ্টল\?</string>
<string name="show_play_with_kodi_title">\"Kodi ৰ সৈতে খোলক\" বিকল্প দেখুৱাওক</string>
<string name="show_play_with_kodi_summary">Kodi মিডিয়া চেণ্টাৰৰ জৰিয়তে এটা ভিডিঅ\' চলাবলৈ এটা বিকল্প প্ৰদৰ্শন কৰক</string>
<string name="crash_the_player">প্লেয়াৰটো ক্ৰেচ কৰক</string>
<string name="notification_action_buffering">বাফাৰিং</string>
<string name="notification_action_nothing">নথিং</string>
<string name="notification_colorize_title">জাননী ৰ‌ঙিণ কৰক</string>
<string name="play_audio">অডিঅ\'</string>
<string name="default_audio_format_title">অডিঅ\' ৰ প্ৰকাৰ</string>
<string name="default_video_format_title">ভিডিঅ\'ৰ প্ৰকাৰ</string>
<string name="theme_title">থিম</string>
<string name="night_theme_title">নিশাৰ থিম</string>
<string name="light_theme_title">পোহৰ</string>
<string name="dark_theme_title">অন্ধকাৰ</string>
<string name="black_theme_title">ক\'লা</string>
<string name="popup_remember_size_pos_title">পপ-আপ বৈশিষ্ট্যসমূহ মনত ৰাখিব</string>
<string name="popup_remember_size_pos_summary">পপ-আপৰ অন্তিম আকাৰ আৰু অৱস্থান মনত ৰাখিব</string>
<string name="use_inexact_seek_summary">Inexact seek য়ে প্লেয়াৰটোক দ্ৰুত গতিত স্থান সলনি কৰিবলৈ অনুমতি দিয়ে। ৫, ১৫ বা ২৫ ছেকেণ্ড সলনি কৰিবলৈ বিচাৰিলে ইয়াৰ প্ৰয়োজন নহয়</string>
<string name="seek_duration_title">ফাষ্ট-ফৰৱাৰ্ড/-ৰিৱাইণ্ড কৰিবলৈ বিচৰা সময়সীমা</string>
<string name="progressive_load_interval_title">প্লেবেক লোড কৰাৰ ব্যৱধানৰ আকাৰ</string>
<string name="progressive_load_interval_summary">লোড ব্যৱধানৰ আকাৰ সলনি কৰক (বৰ্তমানে %s) । এটা কম মানে প্ৰাৰম্ভিক ভিডিঅ\' লোডিং দ্ৰুত কৰিব পাৰে। পৰিৱৰ্তনৰ বাবে এটা খেলুৱৈ পুনৰাৰম্ভৰ প্ৰয়োজন</string>
<string name="notification_colorize_summary">থাম্বনেইলত থকা মূল ৰং অনুসৰি এণ্ড্ৰইডক জাননীৰ ৰং কাষ্টমাইজ কৰিবলৈ কওক (মন কৰিব যে এইটো সকলো ডিভাইচতে উপলব্ধ নহয়)</string>
<string name="clear_queue_confirmation_description">সক্ৰিয় প্লেয়াৰৰ queue সলনি কৰা হ’ব</string>
<string name="download_thumbnail_title">থাম্বনেইল লোড কৰক</string>
<string name="show_comments_title">মন্তব্য দেখুৱাওক</string>
<string name="show_description_title">বিৱৰণ দেখুৱাওক</string>
<string name="show_meta_info_title">মেটা তথ্য দেখুৱাওক</string>
<string name="thumbnail_cache_wipe_complete_notice">সংৰক্ষিত ছবি মচি পেলোৱা হ\'ল</string>
<string name="metadata_cache_wipe_title">সংৰক্ষিত কৰি থোৱা মেটাডাটা মচি পেলাওক</string>
<string name="metadata_cache_wipe_summary">সকলো সংৰক্ষণ কৰি ৰখা ৱেবপেজৰ তথ্য আঁতৰাওক</string>
<string name="metadata_cache_wipe_complete_notice">সংৰক্ষণ কৰি থোৱা মেটাডাটা মচি পেলোৱা হ\'ল</string>
<string name="auto_queue_title">পৰৱৰ্তী ষ্ট্ৰিম স্বয়ংক্ৰিয়ভাৱে enque কৰক</string>
<string name="sort">সজোৱা</string>
<string name="download_path_title">ভিডিঅ\' ডাউনলোড folder</string>
<string name="controls_add_to_playlist_title">যোগ কৰক</string>
<string name="download_path_audio_summary">ডাউনলোড কৰা অডিঅ\' ফাইলসমূহ ইয়াত সংৰক্ষণ কৰা হয়</string>
<string name="notification_scale_to_square_image_title">থাম্বনেইলক ১:১ অনুপাত লৈ ক্ৰপ কৰক</string>
<string name="download_path_summary">ডাউনলোড কৰা ভিডিঅ’ ফাইলসমূহ ইয়াত সংৰক্ষণ কৰা হয়</string>
<string name="download_path_dialog_title">ভিডিঅ\' ফাইলসমূহৰ বাবে ডাউনলোড folder বাছক</string>
<string name="download_path_audio_title">অডিঅ\' ডাউনলোড folder</string>
<string name="download_path_audio_dialog_title">অডিঅ\' ফাইলসমূহৰ বাবে ডাউনলোড folder নিৰ্বাচন কৰক</string>
<string name="notification_scale_to_square_image_summary">জাননীত দেখুওৱা ভিডিঅ’ থাম্বনেইলটো ১৬:৯ৰ পৰা ১:১ অনুপাতলৈ ক্ৰপ কৰক</string>
<string name="notification_action_0_title">First action button</string>
<string name="notification_action_4_title">Fifth action button</string>
<string name="notification_actions_summary">Edit each notification action below by tapping on it. Select up to three of them to be shown in the compact notification by using the checkboxes on the right</string>
<string name="notification_action_1_title">Second action button</string>
<string name="notification_actions_at_most_three">You can select at most three actions to show in the compact notification!</string>
<string name="notification_action_repeat">পুনৰাবৃত্তি</string>
<string name="notification_action_shuffle">শ্বাফেল</string>
<string name="use_inexact_seek_title">দ্ৰুত inexact seek ব্যৱহাৰ কৰক</string>
<string name="clear_queue_confirmation_title">এটা queue বিলুপ্তি কৰাৰ আগতে নিশ্চিতকৰণৰ বাবে সুধিব</string>
<string name="clear_queue_confirmation_summary">এটা প্লেয়াৰ পৰা আন এটালৈ সলনি কৰিলে আপোনাৰ queue সলনি হ\'ব পাৰে</string>
<string name="notification_action_3_title">Fourth action button</string>
<string name="notification_action_2_title">Third action button</string>
<string name="show_description_summary">ভিডিঅ\'ৰ বিৱৰণ আৰু অতিৰিক্ত তথ্য লুকুৱাবলৈ বন্ধ কৰক</string>
<string name="show_comments_summary">মন্তব্য লুকুৱাবলৈ বন্ধ কৰক</string>
<string name="show_next_and_similar_title">\'পৰৱৰ্তী\' আৰু \'সাদৃশ্য থকা\' ভিডিঅ\' দেখুৱাওক</string>
<string name="download_thumbnail_summary">থাম্বনেইলসমূহ লোড কৰা, তথ্য আৰু মেমৰি ব্যৱহাৰ সংৰক্ষণ কৰা ৰোধ কৰিবলে বন্ধ কৰক। পৰিবৰ্তনসমূহে ইন-মেমৰি আৰু অন-ডিস্ক কেশ্ব দুয়োটা পৰিষ্কাৰ কৰে</string>
<string name="show_meta_info_summary">ষ্ট্ৰিমৰ সৃষ্টিকৰ্তা, ষ্ট্ৰিমৰ বিষয়বস্তু বা এটা সন্ধান অনুৰোধৰ বিষয়ে অতিৰিক্ত তথ্যৰ সৈতে মেটা তথ্যৰ বাকচসমূহ লুকুৱাবলৈ বন্ধ কৰক</string>
</resources>

View File

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="main_bg_subtitle">Başlamaq üçün böyüdücüyə toxun.</string>
<string name="main_bg_subtitle">Başlamaq üçün böyüdücü güzgüyə toxun.</string>
<string name="upload_date_text">%1$s tarixində yayımlanıb</string>
<string name="no_player_found">Yayım oynadıcı tapılmadı. \"VLC\" yüklənilsin\?</string>
<string name="no_player_found_toast">Yayım oynadıcı tapılmadı (Oynatmaq üçün VLC\'ni quraşdıra bilərsiniz).</string>
<string name="no_player_found">Yayım oynadıcı tapılmadı. \"VLC\" quraşdırılsın\?</string>
<string name="no_player_found_toast">Yayım oynadıcı tapılmadı (Oynatmaq üçün VLC quraşdıra bilərsiniz).</string>
<string name="install">Yüklə</string>
<string name="cancel">Ləğv et</string>
<string name="open_in_browser">Brauzerdə aç</string>
@ -12,59 +12,59 @@
<string name="controls_download_desc">Yayım faylını endir</string>
<string name="search">Axtarış</string>
<string name="settings">Tənzimləmələr</string>
<string name="did_you_mean">Bunu nəzərdə tuturdunuz: \"%1$s\"\?</string>
<string name="did_you_mean">Bunu demək istəyirdiniz: \"%1$s\"\?</string>
<string name="share_dialog_title">ilə paylaş</string>
<string name="use_external_video_player_title">Xarici video oynadıcı istifadə et</string>
<string name="use_external_video_player_summary">Bəzi qətnamələrdə səsi silir</string>
<string name="use_external_video_player_summary">Bəzi ayırdetmələrdə səsi silir</string>
<string name="use_external_audio_player_title">Xarici səs oynadıcı istifadə et</string>
<string name="subscribe_button_title">Abunə Ol</string>
<string name="subscribed_button_title">Abunə olundu</string>
<string name="channel_unsubscribed">Kanal abunəliyi ləğv edildi</string>
<string name="show_info">Məlumat göstər</string>
<string name="tab_subscriptions">Abunəliklər</string>
<string name="tab_subscriptions">Abunələr</string>
<string name="tab_bookmarks">Əlfəcinlənmiş Pleylistlər</string>
<string name="fragment_feed_title">Yeniliklər</string>
<string name="controls_background_title">Fon</string>
<string name="download_path_title">Video endirmə qovluğu</string>
<string name="download_path_summary">Endirilmiş video fayllar burada saxlanılır</string>
<string name="download_path_dialog_title">Video faylları üçün endirmə qovluğunu seç</string>
<string name="download_path_dialog_title">Video fayllar üçün endirmə qovluğu seç</string>
<string name="download_path_audio_title">Səs endirmə qovluğu</string>
<string name="download_path_audio_summary">Endirilmiş səs faylları burada saxlanılır</string>
<string name="download_path_audio_dialog_title">Səs faylları üçün endirmə qovluğu seç</string>
<string name="default_resolution_title">Defolt keyfiyyət</string>
<string name="show_higher_resolutions_title">Daha böyük keyfiyyət seçimləri göstər</string>
<string name="default_resolution_title">Standart ayırdetmə</string>
<string name="show_higher_resolutions_title">Daha böyük ayırdetmələr göstər</string>
<string name="play_with_kodi_title">\"Kodi\" ilə Oynat</string>
<string name="kore_not_found">Çatışmayan \"Kore\" tətbiqi yüklənilsin\?</string>
<string name="show_play_with_kodi_title">\"Kodi ilə Oynat\" seçimini göstər</string>
<string name="show_play_with_kodi_summary">Videonu Kodi media mərkəzi ilə oynatmaq üçün seçim göstər</string>
<string name="show_play_with_kodi_summary">Kodi media mərkəzindən video oynatmaq üçün seçim göstər</string>
<string name="play_audio">Səs</string>
<string name="default_audio_format_title">Defolt səs formatı</string>
<string name="default_video_format_title">Defolt video formatı</string>
<string name="default_audio_format_title">Standart səs formatı</string>
<string name="default_video_format_title">Standart video formatı</string>
<string name="theme_title">Tema</string>
<string name="light_theme_title">İşıqlı</string>
<string name="dark_theme_title">Qaranlıq</string>
<string name="black_theme_title">Qara</string>
<string name="unsubscribe">Abunəlikdən çıxın</string>
<string name="open_in_popup_mode">Ani pəncərə rejimində aç</string>
<string name="unsubscribe">Abunə olma</string>
<string name="open_in_popup_mode">Ani görüntü rejimində aç</string>
<string name="autoplay_title">Avtomatik oynat</string>
<string name="download_dialog_title">Endir</string>
<string name="resume_on_audio_focus_gain_summary">Fasilələrdən sonra (məsələn, telefon zəngləri) oynatmağa davam etdir</string>
<string name="download_dialog_title">Yüklə</string>
<string name="resume_on_audio_focus_gain_summary">Fasilələr ardınca (məsələn, telefon zəngləri) oynatmağa davam etdir</string>
<string name="resume_on_audio_focus_gain_title">Oynatmanı davam etdir</string>
<string name="enable_watch_history_summary">Baxılmış videoların saxlanılması</string>
<string name="settings_category_clear_data_title">Məlumat təmizlə</string>
<string name="enable_playback_state_lists_summary">Siyahılarda oynatma mövqelərini göstər</string>
<string name="enable_playback_state_lists_summary">Siyahılarda oynatma mövqe göstəricilərini göstər</string>
<string name="enable_playback_state_lists_title">Siyahılardakı mövqelər</string>
<string name="enable_playback_resume_summary">Son oynatma mövqeyini qaytar</string>
<string name="enable_playback_resume_title">Oynatmanı davam etdir</string>
<string name="enable_watch_history_title">Baxış tarixçəsi</string>
<string name="enable_search_history_summary">Axtarış sorğularını yerli olaraq saxla</string>
<string name="enable_search_history_title">Axtarış tarixçəsi</string>
<string name="show_search_suggestions_summary">Axtarış edərkən göstəriləcək təklifləri seç</string>
<string name="show_search_suggestions_summary">Axtarış zamanı göstərmək üçün təklifləri seç</string>
<string name="show_search_suggestions_title">Axtarış təklifləri</string>
<string name="brightness_gesture_control_summary">Oynadıcının parlaqlığını nizamlamaq üçün jestləri istifadə et</string>
<string name="brightness_gesture_control_title">Parlaqlığı jestlə nizamlamaq</string>
<string name="volume_gesture_control_summary">Oynadıcı səsini nizamlamaq üçün jestləri istifadə et</string>
<string name="volume_gesture_control_title">Səsi jestlə idarə etmək</string>
<string name="brightness_gesture_control_summary">Oynadıcı parlaqlığını nizamlamaq üçün jestlər istifadə et</string>
<string name="brightness_gesture_control_title">Parlaqlıq jesti idarəetməsi</string>
<string name="volume_gesture_control_summary">Oynadıcı səsini nizamlamaq üçün jestlər istifadə et</string>
<string name="volume_gesture_control_title">Səs səviyyəsi jesti idarəetməsi</string>
<string name="auto_queue_toggle">Avto-növbələ</string>
<string name="auto_queue_title">Növbəti Yayımı Avto-növbələ</string>
<string name="metadata_cache_wipe_complete_notice">Üst məlumat keşi silindi</string>
@ -73,10 +73,10 @@
<string name="thumbnail_cache_wipe_complete_notice">Şəkil keşi silindi</string>
<string name="show_comments_summary">Şərhləri gizlətmək üçün bağla</string>
<string name="show_comments_title">Şərhləri göstər</string>
<string name="clear_queue_confirmation_description">Aktiv oynadıcının növbəsi dəyişdiriləcək</string>
<string name="clear_queue_confirmation_description">Aktiv oynadıcı növbəsi dəyişdiriləcək</string>
<string name="clear_queue_confirmation_summary">Bir oynadıcıdan digərinə keçid növbənizi dəyişdirə bilər</string>
<string name="clear_queue_confirmation_title">Növbəni təmizləməzdən əvvəl təsdiq üçün soruş</string>
<string name="use_inexact_seek_title">Sürətli qeyri-dəqiq axtarışdan istifadə et</string>
<string name="use_inexact_seek_title">Sürətli qeyri-dəqiq axtarış istifadə et</string>
<string name="use_inexact_seek_summary">Qeyri-dəqiq axtarış oynadıcıya azaldılmış dəqiqliklə mövqeləri daha sürətli axtarmağa imkan verir. 5, 15 və ya 25 saniyəlik axtarış bununla işləmir</string>
<string name="seek_duration_title">Sürətli irəli/geri çəkmə axtarış müddəti</string>
<string name="notification_action_nothing">Heç nə</string>
@ -89,9 +89,9 @@
<string name="notification_action_1_title">İkinci fəaliyyət düyməsi</string>
<string name="notification_action_0_title">Birinci fəaliyyət düyməsi</string>
<string name="show_higher_resolutions_summary">Yalnız bəzi cihazlar 2K/4K videoları oynada bilir</string>
<string name="default_popup_resolution_title">Defolt ani pəncərə keyfiyyəti</string>
<string name="default_popup_resolution_title">Standart ani görüntü ayırdetməsi</string>
<string name="controls_add_to_playlist_title">Əlavə Et</string>
<string name="controls_popup_title">Ani Pəncərə</string>
<string name="controls_popup_title">Ani Görüntü</string>
<string name="tab_choose">Paneli Seç</string>
<string name="subscription_update_failed">Abunəliyi yeniləmək alınmadı</string>
<string name="subscription_change_failed">Abunəliyi dəyişmək alınmadı</string>
@ -102,7 +102,7 @@
<string name="show_age_restricted_content_summary">Yaş həddi səbəbiylə (məsələn, 18+) uşaqlar üçün uyğun olmayan məzmunu göstər</string>
<string name="show_age_restricted_content_title">Yaş məhdudiyyətli məzmunu göstər</string>
<string name="content">Məzmun</string>
<string name="popup_playing_toast">Ani pəncərə rejimində oynadılır</string>
<string name="popup_playing_toast">Ani görüntü rejimində oynadılır</string>
<string name="background_player_playing_toast">Fonda oynadılır</string>
<string name="settings_category_updates_title">Yeniləmələr</string>
<string name="settings_category_debug_title">Sazlama</string>
@ -111,23 +111,23 @@
<string name="settings_category_video_audio_title">Video və səs</string>
<string name="settings_category_player_behavior_title">Davranış</string>
<string name="settings_category_player_title">Oynadıcı</string>
<string name="content_language_title">Defolt məzmun dili</string>
<string name="default_content_country_title">Defolt məzmun ölkəsi</string>
<string name="content_language_title">Cari məzmun dili</string>
<string name="default_content_country_title">Cari məzmun ölkəsi</string>
<string name="unsupported_url_dialog_message">URL\'i tanımaq olmadı. Başqa tətbiqlə açılsın\?</string>
<string name="unsupported_url">Dəstəklənməyən URL\'i</string>
<string name="show_hold_to_append_title">\"Növbələmək üçün basılı saxla\" tövsiyəsin göstər</string>
<string name="show_next_and_similar_title">\"Növbəti\" və \"Bənzər\" videoları göstər</string>
<string name="export_data_summary">Tarixçəni, abunəlikləri, pleylistləri və tənzimləmələri ixrac edin</string>
<string name="export_data_summary">Tarixçəni, abunəlikləri, pleylistləri və tənzimləmələri ixrac et</string>
<string name="import_data_summary">Cari tarixçənizi, abunəliklərinizi, pleylistlərinizi və (könüllü) tənzimləmələrinizi etibarsız edir</string>
<string name="recaptcha_cookies_cleared">reCAPTCHA kukiləri təmizləndi</string>
<string name="clear_cookie_title">reCAPTCHA kukilərini təmizlə</string>
<string name="recaptcha_cookies_cleared">reCAPTCHA bazaları təmizləndi</string>
<string name="clear_cookie_title">reCAPTCHA bazalarını təmizlə</string>
<string name="export_data_title">Məlumat bazasını ixrac et</string>
<string name="import_data_title">Məlumat bazasını idxal et</string>
<string name="switch_to_main">Əsas Görünüşə Keçid</string>
<string name="switch_to_popup">Ani Pəncərəyə Keçid</string>
<string name="switch_to_popup">Ani Görüntüyə Keçid</string>
<string name="switch_to_background">Fona Keçid</string>
<string name="unknown_content">[Naməlum]</string>
<string name="app_update_notification_channel_description">Yeni \"NewPipe\" versiyası üçün bildirişlər</string>
<string name="app_update_notification_channel_description">Yeni \"NewPipe\" versiyaları üçün bildirişlər</string>
<string name="app_update_notification_channel_name">Tətbiq yeniləmə bildirişi</string>
<string name="notification_channel_description">NewPipe oynadıcısı üçün bildirişlər</string>
<string name="all">Hamısı</string>
@ -141,7 +141,7 @@
<string name="youtube_restricted_mode_enabled_summary">YouTube potensial yetkin məzmunu gizlədən \"Məhdud Rejim\" təmin edir</string>
<string name="peertube_instance_url_title">\"PeerTube\" nümunələri</string>
<string name="download_thumbnail_title">Miniatürləri yüklə</string>
<string name="notification_actions_at_most_three">Siz yığcam bildirişdə göstərilməsi üçün ən çoxu üç fəaliyyət seçə bilərsiniz!</string>
<string name="notification_actions_at_most_three">Yığcam bildirişdə göstərmək üçün ən çoxu üç fəaliyyət seçə bilərsiniz!</string>
<string name="feed_update_threshold_option_always_update">Həmişə yenilə</string>
<string name="settings_category_feed_title">Axın</string>
<string name="feed_group_show_only_ungrouped_subscriptions">Yalnız qruplaşdırılmamış abunəlikləri göstər</string>
@ -153,7 +153,7 @@
<item quantity="other">%d seçildi</item>
</plurals>
<string name="feed_group_dialog_empty_selection">Abunəlik seçilməyib</string>
<string name="feed_group_dialog_select_subscriptions">Abunəlikləri seçin</string>
<string name="feed_group_dialog_select_subscriptions">Abunəlikləri seç</string>
<string name="feed_processing_message">Axın emal edilir…</string>
<string name="feed_notification_loading">Axın yüklənir…</string>
<string name="feed_subscription_not_loaded_count">Yüklənmədi: %d</string>
@ -179,8 +179,8 @@
<string name="general_error">Xəta</string>
<string name="search_history_deleted">Axtarış tarixçəsi silindi</string>
<string name="delete_search_history_alert">Bütün axtarış tarixçəsi silinsin\?</string>
<string name="clear_search_history_summary">Açar sözləri axtarışının tarixçəsini silir</string>
<string name="clear_search_history_title">Axtarış tarixçəsini silin</string>
<string name="clear_search_history_summary">Açar sözləri axtarışı tarixçəsini silir</string>
<string name="clear_search_history_title">Axtarış tarixçəsini sil</string>
<string name="watch_history_states_deleted">Oynatma mövqeləri silindi</string>
<string name="delete_playback_states_alert">Bütün oynatma mövqeləri silinsin\?</string>
<string name="clear_playback_states_summary">Bütün oynatma mövqelərini silir</string>
@ -188,7 +188,7 @@
<string name="watch_history_deleted">Baxış tarixçəsi silindi</string>
<string name="delete_view_history_alert">Bütün baxış tarixçəsi silinsin\?</string>
<string name="clear_views_history_title">Baxış tarixçəsini təmizlə</string>
<string name="clear_cookie_summary">reCAPTCHA həll edərkən NewPipe\'ın saxladığı kukiləri silin</string>
<string name="clear_cookie_summary">reCAPTCHA həll edərkən NewPipe saxladığı bazaları sil</string>
<string name="channel_created_by">%s tərəfindən yaradıldı</string>
<string name="resize_zoom">Yaxınlaşdır</string>
<string name="resize_fill">Doldur</string>
@ -198,22 +198,22 @@
<string name="no_channel_subscribed_yet">Hələ ki, kanal abunəliyi yoxdur</string>
<string name="select_a_channel">Kanal seç</string>
<string name="channel_page_summary">Kanal Səhifəsi</string>
<string name="default_kiosk_page_summary">Defolt Köşk</string>
<string name="kiosk_page_summary">Köşk Səhifəsi</string>
<string name="default_kiosk_page_summary">Standart Köşk</string>
<string name="kiosk_page_summary">Köşk Səhifə</string>
<string name="blank_page_summary">Boş Səhifə</string>
<string name="main_page_content_summary">Əsas səhifədə hansı tablar göstərilir</string>
<string name="main_page_content">Əsas səhifənin məzmunu</string>
<string name="main_page_content">Əsas səhifə məzmunu</string>
<string name="updates_setting_description">Yeni versiya mövcud olduqda tətbiq yeniləməsini xatırlatmaq üçün bildiriş göstər</string>
<string name="updates_setting_title">Yeniləmələr</string>
<string name="limit_mobile_data_usage_title">Mobil internet istifadə edərkən görüntü keyfiyyətini məhdudlaşdır</string>
<string name="limit_mobile_data_usage_title">Mobil internet istifadə edərkən ayırdetməni məhdudlaşdır</string>
<string name="limit_data_usage_none_description">Limitsiz</string>
<string name="one_item_deleted">1 element silindi.</string>
<string name="peertube_instance_add_title">Nümunə əlavə et</string>
<string name="peertube_instance_url_summary">Sevimli \"PeerTube\" nümunələrinizi seçin</string>
<string name="delete_downloaded_files">Endirilmiş faylları silin</string>
<string name="confirm_prompt">Endirmə tarixçənizi təmizləmək və ya endirilmiş bütün faylları silmək istəyirsiniz\?</string>
<string name="delete_downloaded_files">Endirilmiş faylları sil</string>
<string name="confirm_prompt">Endirmə tarixçənizi təmizləmək və ya bütün endirilmiş faylları silmək istəyirsiniz\?</string>
<string name="clear_download_history">Endirmə tarixçəsini təmizlə</string>
<string name="start_downloads">Endirmələrə başla</string>
<string name="start_downloads">Endirmələri başlat</string>
<string name="pause_downloads">Endirmələri dayandır</string>
<string name="downloads_storage_ask_title">Haraya endiriləcəyini soruş</string>
<string name="downloads_storage_ask_summary">Sizdən hər endirmənin harada saxlanılacağı soruşulacaq.
@ -241,13 +241,13 @@
<string name="feed_update_threshold_title">Axın yeniləmə astanası</string>
<string name="feed_use_dedicated_fetch_method_enable_button">Sürətli rejimi aktivləşdir</string>
<string name="feed_use_dedicated_fetch_method_disable_button">Sürətli rejimi deaktiv et</string>
<string name="feed_use_dedicated_fetch_method_help_text">Axının çox yavaş yükləndiyini düşünürsünüz\? Əgər elədirsə, sürətli yükləməni işə salmağı sınayın (tənzimləmələrdən dəyişə və ya aşağıdakı düyməni basa bilərsiniz).
<string name="feed_use_dedicated_fetch_method_help_text">Axının çox yavaş yükləndiyini düşünürsünüz\? Əgər elədirsə, sürətli yükləməni işə salmağı sınayın (tənzimləmələrdə dəyişə və ya aşağıdakı düyməni basa bilərsiniz).
\n
\nNewPipe axını yükləmək üçün 2 metod təklif edir:
\n• Bütün abunəlik kanallarını gətirtmək, bu yavaş olsa da tamdır;
\n• Ayrılmış xidmət uc nöqtəsi istifadə etmək, bu sürətlidir, amma tam deyil.
\n
\nBu ikisi arasında fərq odur ki, sürətlisində, adətən elementin müddəti və növü kimi bəzi məlumatlar çatışmır (canlı video ilə adisini ayırd edə bilmir) və daha az element gətirir.
\nBu ikisi arasında fərq odur ki, sürətlisində, adətən elementin müddəti və növü kimi bəzi məlumatlar çatışmır (canlı video ilə adisini ayırd edə bilmir) və daha az elementlər gətirir.
\n
\nYouTube öz RSS axını ilə bu sürətli metodu təklif edən xidmətlərdən biridir.
\n
@ -262,9 +262,9 @@
<string name="download_to_sdcard_error_title">Xarici yaddaş əlçatan deyil</string>
<string name="clear_views_history_summary">Oynadılmış yayımlar tarixçəsini və oynatma mövqelərini silir</string>
<string name="show_meta_info_title">Üst məlumatı göstər</string>
<string name="show_description_summary">Video açıqlamasını və əlavə məlumatı gizlətmək üçün bağla</string>
<string name="show_description_summary">Video açıqlamanı və əlavə məlumatı gizlətmək üçün bağla</string>
<string name="show_description_title">ıqlamanı göstər</string>
<string name="notification_colorize_title">Bildirişi rəngləndir</string>
<string name="notification_colorize_title">Bildirişi rənglə</string>
<string name="invalid_directory">Belə qovluq yoxdur</string>
<string name="start_main_player_fullscreen_title">Əsas oynadıcını tam ekranda başlat</string>
<string name="external_player_unsupported_link_type">Xarici oynadıcılar bu cür linkləri dəstəkləmir</string>
@ -281,7 +281,7 @@
<string name="description_tab_description">ıqlama</string>
<string name="empty_list_subtitle">Burada kriketlərdən başqa heç nə yoxdur</string>
<string name="search_no_results">Nəticə yoxdur</string>
<string name="restore_defaults">İlkin tənzimləmələri qaytar</string>
<string name="restore_defaults">Standartları qaytar</string>
<string name="missing_file">Fayl köçürüldü və ya silindi</string>
<string name="player_recoverable_failure">Oynadıcı xətası bərpa edilir</string>
<string name="player_unrecoverable_failure">Bərpa olunmayan oynatma xətası baş verdi</string>
@ -291,28 +291,28 @@
<string name="audio_streams_empty">Səs yayımı tapılmadı</string>
<string name="permission_display_over_apps">Digər tətbiqlərin üzərində göstərməyə icazə ver</string>
<string name="restore_defaults_confirmation">İlkin tənzimləmələri qaytarmaq istəyirsiniz\?</string>
<string name="download_thumbnail_summary">Miniatürlərin yüklənməsini, dataya qənaət etmək və yaddaşdan istifadəni azaltmaq üçün söndürün. Dəyişikliklər həm yaddaşdaxili, həm də diskdə olan təsvir keşini təmizləyir</string>
<string name="download_thumbnail_summary">Miniatürləri yükləməyi, məlumata qənaət və yaddaş istifadəsin azaltmaq üçün söndür. Dəyişikliklər həm yaddaşdaxilində, həm də diskdə təsvir keşini təmizləyir</string>
<string name="enqueue_next_stream">Növbətini növbələ</string>
<string name="retry">Yenidən Cəhd Et</string>
<string name="retry">Təkrar Cəhd Et</string>
<string name="settings_category_player_notification_summary">Cari oynatma yayımı bildirişini konfiqurasiya et</string>
<string name="notifications">Bildirişlər</string>
<string name="hash_channel_name">Video fayl xülasəsi bildirişi</string>
<string name="streams_notification_channel_description">Abunəliklər üçün yeni yayımlar haqqında bildirişlər</string>
<string name="error_report_channel_description">Xəta hesabatları üçün bildirişlər</string>
<string name="file_name_empty_error">Fayl adı boş ola bilməz</string>
<string name="saved_tabs_invalid_json">Yadda saxlanmış tabları oxumaq mümkün olmadı, buna görə defolt tablardan istifadə edin</string>
<string name="saved_tabs_invalid_json">Saxlanmış tabları oxumaq mümkün olmadı, buna görə standart tabları istifadə et</string>
<string name="error_report_notification_title">NewPipe xəta ilə qarşılaşdı, bildirmək üçün toxun</string>
<string name="sorry_string">Bağışlayın, o baş verməməli idi.</string>
<string name="error_report_button_text">Bu xətanı e-poçt vasitəsilə bildirin</string>
<string name="error_report_open_issue_button_text">GitHub\'da Hesabat Ver</string>
<string name="sorry_string">Bağışla, o baş verməməli idi.</string>
<string name="error_report_button_text">Bu xətanı e-poçt-dan bildir</string>
<string name="error_report_open_issue_button_text">GitHub\'da Məlumat Ver</string>
<string name="error_report_open_github_notice">Zəhmət olmasa, xətanızı müzakirə edən məsələnin mövcud olub-olmadığını yoxlayın. Dublikat biletləri yaradarkən, bizdən faktiki səhvi düzəltməyə sərf edəcəyimiz vaxt alırsınız.</string>
<string name="error_snackbar_action">Hesabat Bildir</string>
<string name="error_snackbar_action">Məlumat Ver</string>
<string name="what_device_headline">Məlumat:</string>
<string name="what_happened_headline">Nə baş verdi:</string>
<string name="detail_uploader_thumbnail_view_description">Yükləyənin avatar miniatürü</string>
<string name="detail_likes_img_view_description">Bəyən</string>
<string name="detail_dislikes_img_view_description">Bəyənmə</string>
<string name="detail_drag_description">Yenidən sıralamaq üçün sürüşdür</string>
<string name="detail_drag_description">Yenidən sıralamaq üçün sürüklə</string>
<string name="short_thousand">min</string>
<string name="short_million">Mln</string>
<string name="short_billion">Mlrd</string>
@ -324,13 +324,13 @@
<string name="no_videos">Video yoxdur</string>
<string name="comments_are_disabled">Şərhlər qeyri-aktivdir</string>
<string name="start">Başlat</string>
<string name="pause">Fasilə</string>
<string name="pause">Dayandır</string>
<string name="checksum">Təsdiqləmə</string>
<string name="dismiss">İmtina</string>
<string name="msg_error">Xəta</string>
<string name="msg_running_detail">Detallar üçün toxun</string>
<string name="msg_wait">Zəhmət olmasa, gözləyin…</string>
<string name="no_dir_yet">Hələ endirmə qovluğu təyin edilməyib, indi defolt endirmə qovluğunu seç</string>
<string name="no_dir_yet">Hələ endirmə qovluğu təyin edilməyib, indi standart endirmə qovluğu seç</string>
<string name="title_activity_recaptcha">reCAPTCHA çağırışı</string>
<string name="recaptcha_request_toast">reCAPTCHA sorğusu göndərildi</string>
<string name="recaptcha_done_button">Bitdi</string>
@ -340,15 +340,15 @@
<string name="title_licenses">Üçüncü Tərəf Lisenziyaları</string>
<string name="tab_about">Haqqında &amp; T-TSS</string>
<string name="contribution_title">Töhfə Ver</string>
<string name="contribution_encouragement">Fikirlərinizin olub-olmaması, tərcümə, dizayn dəyişiklikləri, kodun təmizlənməsi və ya real ağırlıqlı kod dəyişiklikləri və.s kömək həmişə xoşdur. Nə qədər çox edilsə, bir o qədər yaxşı olar!</string>
<string name="contribution_encouragement">Fikirlərinizin olub-olmaması, tərcümə, dizayn dəyişiklikləri, kod təmizlənməsi və ya real ağır kod dəyişiklikləri və.s kömək həmişə xoşdur. Nə qədər çox edilsə, bir o qədər yaxşı olar!</string>
<string name="give_back">İanə Et</string>
<string name="website_title">Veb sayt</string>
<string name="website_encouragement">Əlavə məlumat və xəbərlər üçün NewPipe Veb saytına daxil olun.</string>
<string name="privacy_policy_title">NewPipe\'ın Məxfilik Siyasəti</string>
<string name="privacy_policy_encouragement">NewPipe layihəsi məxfiliyinizə çox ciddi yanaşır. Buna görə də, tətbiq sizin razılığınız olmadan heç bir məlumat toplamır.
\nNewPipe\'ın məxfilik siyasəti qəza hesabatı göndərdiyiniz zaman hansı məlumatların göndərildiyini və saxlanıldığını ətraflı izah edir.</string>
<string name="read_privacy_policy">Məxfilik siyasətini oxu</string>
<string name="app_license_title">NewPipe\'ın Lisenziyası</string>
<string name="website_encouragement">Əlavə məlumat və xəbərlər üçün NewPipe Veb saytını ziyarət et.</string>
<string name="privacy_policy_title">NewPipe Məxfilik Siyasəti</string>
<string name="privacy_policy_encouragement">NewPipe layihəsi məxfiliyinizə çox ciddi yanaşır. Nəticə etibarı ilə, tətbiq sizin razılığınız olmadan heç bir məlumat toplamır.
\nNewPipe məxfilik siyasəti xəta məlumatı göndərdiyiniz zaman hansı məlumatların göndərildiyini və saxlanıldığını ətraflı izah edir.</string>
<string name="read_privacy_policy">Məxfilik Siyasətini Oxu</string>
<string name="app_license_title">NewPipe Lisenziyası</string>
<string name="action_history">Tarixçə</string>
<string name="delete_item_search_history">Bu elementi axtarış tarixçəsindən silmək istəyirsiniz\?</string>
<string name="title_last_played">Son Oynadılan</string>
@ -367,7 +367,7 @@
<string name="title_activity_play_queue">Oynatma növbəsi</string>
<string name="play_queue_stream_detail">Detallar</string>
<string name="show_channel_details">Kanal təfərrüatlarını göstər</string>
<string name="start_here_on_popup">Ani pəncərədə oynatmağa başla</string>
<string name="start_here_on_popup">Ani görüntüdə oynatmağa başla</string>
<string name="preferred_open_action_settings_title">\"Açıq\" fəaliyyətə üstünlük verilir</string>
<string name="background_player">Fon oynadıcı</string>
<string name="always_ask_open_action">Həmişə soruş</string>
@ -403,7 +403,7 @@
\nDavam etmək istəyirsiniz\?</string>
<string name="skip_silence_checkbox">Səssizlik zamanı sürətlə irəli</string>
<string name="enable_streams_notifications_title">Yeni yayım bildirişləri</string>
<string name="enable_streams_notifications_summary">Abunəliklərdən yeni yayımlar haqqında bildiriş göndər</string>
<string name="enable_streams_notifications_summary">Abunəliklərdən yeni yayımlar haqqında bildir</string>
<string name="streams_notifications_interval_title">Yoxlama tezliyi</string>
<string name="streams_notifications_network_title">Tələb olunan şəbəkə bağlantısı</string>
<string name="any_network">İstənilən şəbəkə</string>
@ -426,7 +426,7 @@
<string name="content_not_supported">Bu məzmun hələ NewPipe tərəfindən dəstəklənmir.
\n
\nÜmid edirik ki, gələcək versiyada dəstəklənəcək.</string>
<string name="show_thumbnail_summary">Həm kilid ekranı fonu, həm də bildirişlər üçün miniatürdən istifadə et</string>
<string name="show_thumbnail_summary">Həm kilid ekranı fonu, həm də bildirişlər üçün miniatür istifadə et</string>
<string name="recent">Ən Yeni</string>
<string name="georestricted_content">Bu məzmun ölkənizdə mövcud deyil.</string>
<string name="paid_content">Bu məzmun yalnız ödəniş etmiş istifadəçilər üçün əlçatandır, ona görə də NewPipe tərəfindən yayımlana və ya endirilə bilməz.</string>
@ -468,10 +468,10 @@
<string name="notification_colorize_summary">Android\'in bildiriş rəngini miniatürdəki əsas rəngə uyğun fərdiləşdirməsini təmin et (qeyd edək ki, bu, bütün cihazlarda mövcud deyil)</string>
<string name="view_on_github">GitHub\'da Bax</string>
<string name="donation_title">İanə Et</string>
<string name="donation_encouragement">NewPipe, sizə ən yaxşı istifadəçi təcrübəsini göstərmək üçün boş vaxtlarını sərf edən könüllülər tərəfindən hazırlanmışdır. Tərtibatçılara bir fincan qəhvə içərkən NewPipe-ı daha da yaxşılaşdırmağa ianə etməklə kömək edin.</string>
<string name="donation_encouragement">NewPipe, sizə ən yaxşı istifadəçi təcrübəsi göstərmək üçün boş vaxtlarını sərf edən könüllülər tərəfindən hazırlanmışdır. Tərtibatçılara bir fincan qəhvə içərkən NewPipe-ı daha da yaxşılaşdırmağa ianə etməklə kömək edin.</string>
<string name="most_liked">Ən çox bəyənildi</string>
<string name="enqueued">Növbəyə salındı</string>
<string name="preferred_open_action_settings_summary">Məzmunu açarkən defolt hərəkət — %s</string>
<string name="preferred_open_action_settings_summary">Məzmunu açarkən standart hərəkət — %s</string>
<string name="name">Ad</string>
<string name="set_as_playlist_thumbnail">Pleylist miniatürü kimi təyin et</string>
<string name="wifi_only">Yalnız Wi-Fi\'da</string>
@ -488,7 +488,7 @@
<string name="metadata_licence">Lisenziya</string>
<string name="feed_load_error_terminated">Müəllifin hesabı bağlanıb.
\nNewPipe gələcəkdə bu axını yükləyə bilməyəcək.
\nBu kanala abunəlikdən çıxmaq istəyirsiniz\?</string>
\nBu kanaldan abunəliyi çıxarmaq istəyirsiniz\?</string>
<string name="feed_toggle_show_played_items">Baxılan elementləri göstər</string>
<string name="featured">Seçilmiş</string>
<string name="drawer_close">Çəkməcəni Bağla</string>
@ -496,12 +496,12 @@
<string name="hash_channel_description">Video fayl xülasəsi prosesi üçün bildirişlər</string>
<string name="on"></string>
<string name="notification_scale_to_square_image_title">Miniatürü 1:1 görünüş nisbətinə kəs</string>
<string name="progressive_load_interval_summary">Yükləmə intervalının həcmini dəyişdir (hazırda %s). Daha aşağı dəyər ilkin video yükləməni sürətləndirə bilər. Dəyişikliklər oynadıcının yenidən başladılmasını tələb edir</string>
<string name="show_meta_info_summary">Yayım yaradıcısı, məzmunu və ya axtarış sorğusu haqqında əlavə məlumat olan üst məlumat qutularını gizlətmək üçün söndürün</string>
<string name="progressive_load_interval_summary">Yükləmə intervalı həcmini dəyişdir (hazırda %s). Daha aşağı dəyər ilkin video yükləməni sürətləndirə bilər. Dəyişikliklər oynadıcını yenidən başlatmağı tələb edir</string>
<string name="show_meta_info_summary">Yayım yaradıcısı, məzmunu və ya axtarış sorğusu haqqında əlavə məlumat olan üst məlumat qutularını gizlətmək üçün söndür</string>
<string name="auto_queue_summary">Əlaqəli yayımı əlavə etməklə (təkrarlanmayan) sonlanacaq oynatma növbəsini davam etdir</string>
<string name="remote_search_suggestions">Kənar axtarış təklifləri</string>
<string name="peertube_instance_add_exists">Nümunə artıq mövcuddur</string>
<string name="start_main_player_fullscreen_summary">Videoları mini oynadıcıda başlatma, avtomatik fırlatma kilidlidirsə, birbaşa tam ekran rejiminə keçid. Siz hələ də tam ekrandan çıxmaqla mini oynadıcıya daxil ola bilərsiniz</string>
<string name="start_main_player_fullscreen_summary">Videoları kiçik oynadıcıda başlatma, avtomatik fırlatma kilidlidirsə, birbaşa tam ekran rejiminə keçid. Siz hələ də tam ekrandan çıxmaqla mini oynadıcıya daxil ola bilərsiniz</string>
<string name="more_than_100_videos">100+ video</string>
<string name="infinite_videos">∞ video</string>
<string name="no_comments">Şərhlər yoxdur</string>
@ -532,7 +532,7 @@
<string name="metadata_tags">Etiketlər</string>
<string name="tablet_mode_title">Planşet rejimi</string>
<string name="off">Bağla</string>
<string name="detail_heart_img_view_description">Müəllifə ürəkləndi</string>
<string name="detail_heart_img_view_description">Müəllifdən ürəkləndi</string>
<string name="open_website_license">Veb saytı</string>
<plurals name="views">
<item quantity="one">%s baxış</item>
@ -550,11 +550,11 @@
<item quantity="one">Endirmə tamamlandı</item>
<item quantity="other">%s endirmə tamamlandı</item>
</plurals>
<string name="progressive_load_interval_exoplayer_default">Defolt ExoPlayer</string>
<string name="progressive_load_interval_exoplayer_default">Standart ExoPlayer</string>
<string name="feed_use_dedicated_fetch_method_title">Mövcud olduqda xüsusi axından al</string>
<string name="remove_watched_popup_title">Baxılmış videolar silinsin\?</string>
<string name="remove_watched">İzləniləni sil</string>
<string name="downloads_storage_use_saf_title">Sistem qovluğu seçicisini (SAF) istifadə edin</string>
<string name="downloads_storage_use_saf_title">Sistem qovluğu seçicisini (SAF) istifadə et</string>
<string name="error_timeout">Bağlantı fasiləsi</string>
<string name="error_insufficient_storage">Cihazda yer qalmayıb</string>
<string name="error_postprocessing_stopped">Fayl üzərində işləyərkən NewPipe bağlandı</string>
@ -582,7 +582,7 @@
<string name="import_ongoing">İdxal edilir…</string>
<string name="playlist_add_stream_success">Pleylistə salındı</string>
<string name="mute">Səsi bağla</string>
<string name="popup_player">Ani pəncərə oynadıcı</string>
<string name="popup_player">Ani görüntü oynadıcı</string>
<string name="drawer_open">Çəkməcəni Aç</string>
<string name="hold_to_append">Növbələşdirmək üçün basılı tut</string>
<string name="play_queue_remove">Sil</string>
@ -590,7 +590,7 @@
<string name="copyright">© %1$s, %2$s tərəfindən %3$s altında</string>
<string name="toast_no_player">Bu faylı oynatmaq üçün heç bir tətbiq quraşdırılmayıb</string>
<string name="settings_category_downloads_title">Endirmə</string>
<string name="msg_popup_permission">Bu icazə, ani pəncərə rejimində
<string name="msg_popup_permission">Bu icazə, ani görüntü rejimində
\naçmaq üçün lazımdır</string>
<string name="msg_copied">Buferə kopyalandı</string>
<string name="msg_threads">Parçalar</string>
@ -608,22 +608,22 @@
<string name="peertube_instance_add_help">Nümunə URL\'sini daxil et</string>
<string name="peertube_instance_add_fail">Nümunəni doğrulamaq mümkün olmadı</string>
<string name="peertube_instance_url_help">%s-də bəyəndiyiniz nümunələri tapın</string>
<string name="show_hold_to_append_summary">Video \"Təfsilatlar:\"səhifəsində fon və ya ani pəncərə düyməsini basarkən ipucu göstər</string>
<string name="caption_setting_description">Oynadıcı altyazı mətn miqyasını və arxa fon üslublarını dəyişdirin. Effektiv olması üçün tətbiqi yenidən başlatmaq tələb olunur</string>
<string name="show_hold_to_append_summary">Video \"Təfsilatlar:\"səhifəsində fon və ya ani görüntü düyməsin basarkən ipucu göstər</string>
<string name="caption_setting_description">Oynadıcı altyazı mətn miqyasını və arxa fon üslublarını dəyişdir. Effektiv olması üçün tətbiqi yenidən başlatmaq tələb olunur</string>
<string name="error_occurred_detail">Xəta baş verdi: %1$s</string>
<string name="invalid_file">Fayl mövcud deyil, yaxud oxumaq və ya yazmaq icazəsi yoxdur</string>
<string name="parsing_error">Veb saytı təhlil etmək alınmadı</string>
<string name="playback_pitch">Səs ucalığı</string>
<string name="playback_pitch">Ucalıq</string>
<string name="radio">Radio</string>
<string name="show_crash_the_player_title">\"Oynadıcını çökdür\" Göstər</string>
<string name="show_crash_the_player_summary">Oynadıcıdan istifadə edərkən çökdürmə seçimini göstər</string>
<string name="show_crash_the_player_summary">Oynadıcını istifadə edərkən çökdürmə seçimini göstər</string>
<string name="show_error_snackbar">Xəta balonu göstər</string>
<string name="create_error_notification">Xəta bildirişi yarat</string>
<string name="import_from">Burdan idxal edin</string>
<string name="export_to">Bura ixrac edin</string>
<string name="import_from">Burdan idxal et</string>
<string name="export_to">Bura ixrac et</string>
<string name="import_file_title">Faylı idxal et</string>
<string name="subscriptions_import_unsuccessful">Abunəlikləri idxal etmək mümkün olmadı</string>
<string name="start_accept_privacy_policy">Avropa Ümumi Məlumat Mühafizəsi Qaydasına (GDPR) riayət etmək üçün diqqətinizi NewPipe\'ın məxfilik siyasətinə cəlb edirik. Zəhmət olmasa, diqqətlə oxuyun. Xəta hesabatını bizə göndərmək üçün onu qəbul etməlisiniz.</string>
<string name="start_accept_privacy_policy">Avropa Ümumi Məlumat Mühafizəsi Qaydasına (GDPR) riayət etmək üçün diqqətinizi NewPipe məxfilik siyasətinə cəlb edirik. Zəhmət olmasa, diqqətlə oxuyun. Xəta hesabatın bizə göndərmək üçün qəbul etməlisiniz.</string>
<string name="overwrite_unrelated_warning">Bu adda fayl artıq mövcuddur</string>
<string name="download_already_pending">Bu adla gözlənilən bir endirmə var</string>
<string name="error_path_creation">Təyinat qovluğu yaradıla bilməz</string>
@ -643,7 +643,7 @@
<string name="metadata_language">Dil</string>
<string name="metadata_privacy_public">İctimai</string>
<string name="subscribers_count_not_available">Abunəçi sayı əlçatan deyil</string>
<string name="read_full_license">Lisenziyanı oxuyun</string>
<string name="read_full_license">Lisenziyanı Oxu</string>
<string name="title_activity_history">Tarixçə</string>
<string name="charset_letters_and_digits">Hərflər və rəqəmlər</string>
<string name="crash_the_player">Oynadıcını çökdür</string>
@ -651,18 +651,18 @@
<string name="settings_category_player_notification_title">Oynadıcı bildirişi</string>
<string name="streams_notification_channel_name">Yeni yayımlar</string>
<string name="error_report_channel_name">Xəta hesabatı bildirişi</string>
<string name="youtube_signature_deobfuscation_error">Video URL\'i imzasının şifrəsi qırılmadı</string>
<string name="no_streams_available_download">Endirmək üçün heç bir yayım yoxdur</string>
<string name="error_report_notification_toast">Xəta baş verdi, bildirişə baxın</string>
<string name="youtube_signature_deobfuscation_error">Video URL\'i imzası şifrəsi qırılmadı</string>
<string name="no_streams_available_download">Endirmək üçün yayım mövcud deyil</string>
<string name="error_report_notification_toast">Xəta baş verdi, bildirişi gör</string>
<string name="your_comment">Şərhiniz (İngiliscə):</string>
<string name="detail_thumbnail_view_description">Video oynat, müddət:</string>
<string name="no_available_dir">Zəhmət olmasa, daha sonra tənzimləmələrdə endirmə qovluğunu təyin et</string>
<string name="no_available_dir">Zəhmət olmasa, endirmə qovluğunu daha sonra tənzimləmələrdə təyin et</string>
<string name="msg_running">NewPipe Endirilir</string>
<string name="msg_calculating_hash">Hash hesablanır</string>
<string name="settings_file_charset_title">Fayl adlarında icazə verilən simvollar</string>
<string name="title_activity_about">NewPipe Haqqında</string>
<string name="tab_licenses">Lisenziyalar</string>
<string name="app_license">NewPipe müəllif hüquqlu sərbəst tətbiqdir: Siz onu istədiyiniz zaman istifadə edə, öyrənə, paylaşa və təkmilləşdirə bilərsiniz. Xüsusilə, siz Lisenziyanın 3-cü versiyası və ya (seçiminizə görə) hər hansı sonrakı versiyada Azad Proqram Təminatı Fondu tərəfindən dərc edilən GNU Ümumi İctimai Lisenziyanın şərtlərinə uyğun olaraq onu yenidən paylaya və/yaxud dəyişdirə bilərsiniz.</string>
<string name="app_license">NewPipe müəllif hüquqlu sərbəst tətbiqdir: Siz onu istədiyiniz zaman istifadə edə, öyrənə, paylaşa və təkmilləşdirə bilərsiniz. Xüsusilə, siz Lisenziyanın 3-cü versiyası və ya (seçiminizə görə) hər hansı sonrakı versiyada Azad Proqram Təminatı Fondu tərəfindən dərc edilən GNU Ümumi İctimai Lisenziya şərtlərinə uyğun olaraq onu yenidən paylaya və/yaxud dəyişdirə bilərsiniz.</string>
<string name="export_complete_toast">İxrac edildi</string>
<string name="main_page_content_swipe_remove">Elementləri silmək üçün sürüşdür</string>
<string name="no_playlist_bookmarked_yet">Hələ,əlfəcinlənmiş pleylistlər yoxdur</string>
@ -705,7 +705,7 @@
<string name="youtube_music_premium_content">Bu video yalnız YouTube Music Premium üzvləri üçün əlçatandır, ona görə də NewPipe tərəfindən yayımlamaq və ya endirmək mümkün deyil.</string>
<string name="description_select_note">İndi açıqlamadakı mətni seçə bilərsiniz. Nəzərə alın ki, seçim rejimində səhifə titrəyə və keçidlər kliklənməyə bilər.</string>
<string name="notification_scale_to_square_image_summary">Bildirişdə göstərilən video miniatürünü 16:9-dan 1:1 görünüş nisbətinə qədər kəs</string>
<string name="notification_actions_summary">Aşağıdakı bildiriş fəaliyyətini hər birinin üzərinə toxunaraq redaktə edin. Sağdakı təsdiq qutularından istifadə edərək yığcam bildirişdə göstərilməsi üçün onların üçə qədərini seç</string>
<string name="notification_actions_summary">Aşağıdakı hər bir bildiriş fəaliyyətini üzərinə toxunaraq redaktə et. Sağdakı təsdiq qutularından istifadə edərək yığcam bildirişdə göstərmək üçün onların üçünü seç</string>
<string name="invalid_source">Belə fayl/məzmun mənbəyi yoxdur</string>
<string name="selected_stream_external_player_not_supported">Seçilmiş yayım xarici oynadıcılar tərəfindən dəstəklənmir</string>
<string name="streams_not_yet_supported_removed">Yükləyici tərəfindən hələ dəstəklənməyən yayımlar göstərilmir</string>
@ -718,7 +718,7 @@
<string name="feed_toggle_show_future_items">Gələcək elementləri göstər</string>
<string name="feed_toggle_hide_played_items">Baxılan elementləri gizlət</string>
<string name="feed_toggle_hide_future_items">Gələcək elementləri gizlət</string>
<string name="faq_description">Tətbiqdən istifadə etməkdə çətinlik çəkirsinizsə, ümumi suallara bu cavabların yoxlanıldığına əmin olun!</string>
<string name="faq_description">Tətbiqi istifadə etməkdə çətinlik çəkirsinizsə, ümumi suallara bu cavabları yoxladığınıza əmin olun!</string>
<string name="faq_title">Tez-tez soruşulan suallar</string>
<string name="faq">Veb Saytında bax</string>
<string name="sort">Çeşidlə</string>
@ -726,4 +726,10 @@
<string name="fast_mode">Sürətli rejim</string>
<string name="import_subscriptions_hint">3 nöqtə menyudan abunələri idxal və ya ixrac et</string>
<string name="app_update_available_notification_text">%s endirmək üçün toxun</string>
<string name="night_theme_available">Bu seçim yalnız tema üçün %s seçildikdə əlçatandır</string>
<string name="unset_playlist_thumbnail">Daimi miniatürü ləğv et</string>
<string name="card">Kart</string>
<string name="msg_failed_to_copy">Buferə kopyalamaq alınmadı</string>
<string name="duplicate_in_playlist">Boz rəngdə olan pleylistlərdə artıq bu element var.</string>
<string name="playlist_add_stream_success_duplicate">Dublikat %d dəfə əlavə edildi</string>
</resources>

View File

@ -644,4 +644,5 @@
<string name="main_page_content_swipe_remove">ভুক্তি মুছতে ডানে-বামে সরাও</string>
<string name="loading_stream_details">সম্প্রচার বিষয়ক তথ্য প্রক্রিয়ারত…</string>
<string name="feed_toggle_show_future_items">ভবিষ্যৎ ভুক্তি দেখাও</string>
<string name="progressive_load_interval_title">প্লেব্যাক লোড বিরতির আকার</string>
</resources>

View File

@ -701,4 +701,6 @@
<string name="unknown_format">Format desconegut</string>
<string name="unknown_quality">Cualitat desconeguda</string>
<string name="sort">Ordenar</string>
<string name="settings_category_player_notification_summary">Configura la notificació de reproducció actual.</string>
<string name="progressive_load_interval_summary">Canvia la mida de l\'interval de càrrega (actualment %s). Un valor inferior pot accelerar la càrrega inicial del vídeo. Els canvis requereixen un reinici del jugador.</string>
</resources>

View File

@ -11,16 +11,16 @@
<string name="settings">Nastavení</string>
<string name="did_you_mean">Mysleli jste „%1$s“\?</string>
<string name="share_dialog_title">Sdílet s</string>
<string name="use_external_video_player_title">Použít externí video přehrávač</string>
<string name="use_external_video_player_title">Použít externí přehrávač videí</string>
<string name="use_external_audio_player_title">Použít externí audio přehrávač</string>
<string name="download_path_audio_summary">Stažené audio je uloženo zde</string>
<string name="download_path_audio_summary">Sem bude ukládáno stažené audio</string>
<string name="download_path_audio_dialog_title">Zvolte adresář pro stažené audio soubory</string>
<string name="download_path_audio_title">Adresář pro stažené audio</string>
<string name="default_resolution_title">Výchozí rozlišení</string>
<string name="play_with_kodi_title">Přehrát pomocí Kodi</string>
<string name="kore_not_found">Nainstalovat chybějící aplikaci Kore\?</string>
<string name="download_path_title">Adresář pro stažená videa</string>
<string name="download_path_summary">Stažená videa jsou uložena tady</string>
<string name="download_path_summary">Sem budou ukládána stažená videa</string>
<string name="download_path_dialog_title">Zvolte adresář pro stažená videa</string>
<string name="show_play_with_kodi_title">Zobrazit možnost „Přehrát pomocí Kodi“</string>
<string name="show_play_with_kodi_summary">Zobrazit možnost přehrání videa pomocí multimediálního centra Kodi</string>
@ -213,7 +213,7 @@
<string name="no_valid_zip_file">Žádný platný soubor ZIP</string>
<string name="could_not_import_all_files">Upozornění: Nelze importovat všechny soubory.</string>
<string name="override_current_data">Tímto se anuluje vaše aktuální nastavení.</string>
<string name="video_player">Video přehrávač</string>
<string name="video_player">Přehrávač videa</string>
<string name="background_player">Přehrávač na pozadí</string>
<string name="popup_player">Přehrávač v okně</string>
<string name="preferred_player_fetcher_notification_title">Získávám informace…</string>
@ -298,7 +298,7 @@
<string name="playback_pitch">Výška tónu</string>
<string name="unhook_checkbox">Odpojit (může způsobit zkreslení)</string>
<string name="no_streams_available_download">Ke stažení nejsou dostupné žádné streamy</string>
<string name="preferred_open_action_settings_title">Preferovaná \'otevřít\' akce</string>
<string name="preferred_open_action_settings_title">Preferovaná akce „otevření“</string>
<string name="preferred_open_action_settings_summary">Výchozí chování při otevírání obsahu — %s</string>
<string name="caption_setting_title">Titulky</string>
<string name="caption_setting_description">Upravuje velikost textu titulků a styly pozadí. Změny se projeví po restartu aplikace</string>
@ -550,7 +550,7 @@
<string name="search_showing_result_for">Ukazuji výsledky pro: %s</string>
<string name="never">Nikdy</string>
<string name="wifi_only">Pouze na Wi-Fi</string>
<string name="autoplay_summary">Zahájit playback automaticky — %s</string>
<string name="autoplay_summary">Automaticky zahájit přehrávání — %s</string>
<string name="title_activity_play_queue">Přehrát frontu</string>
<string name="unsupported_url_dialog_message">Nelze rozpoznat zadané URL. Otevřít pomocí jiné aplikace\?</string>
<string name="auto_queue_toggle">Auto-fronta</string>
@ -598,7 +598,7 @@
<string name="show_description_summary">Vypnout pro skrytí popisu videa a doplňkové informace</string>
<string name="crash_the_app">Zbořit aplikaci</string>
<string name="download_has_started">Stahování bylo zahájeno</string>
<string name="select_night_theme_toast">Můžete si zvolit svůj oblíbený motiv níže</string>
<string name="select_night_theme_toast">Níže si můžete zvolit svůj oblíbený motiv</string>
<string name="night_theme_summary">Zvolte si svůj oblíbený noční motiv - %s</string>
<string name="auto_device_theme_title">Automatický (motiv zařízení)</string>
<string name="radio">Radio</string>
@ -668,7 +668,7 @@
<string name="show_image_indicators_title">Ukázat indikátory obrázků</string>
<string name="remote_search_suggestions">Vzdálené návrhy vyhledávání</string>
<string name="local_search_suggestions">Lokální návrhy vyhledávání</string>
<string name="start_main_player_fullscreen_summary">Pokud je vypnuté automatické otáčení, nespouštějte video v mini přehrávači, ale přepněte se přímo do režimu celé obrazovky. Do mini přehrávače se lze i nadále dostat ukončením režimu celé obrazovky</string>
<string name="start_main_player_fullscreen_summary">Pokud je vypnuté automatické otáčení, nespouštět video v mini přehrávači, ale přepnout se přímo do režimu celé obrazovky. Do mini přehrávače se lze i nadále dostat ukončením režimu celé obrazovky</string>
<string name="enqueued_next">Další ve frontě</string>
<string name="enqueue_next_stream">Přidat do fronty (další)</string>
<string name="main_page_content_swipe_remove">Tažením položky odstraníte</string>
@ -728,7 +728,7 @@
<string name="feed_toggle_show_future_items">Zobrazit nadcházející položky</string>
<string name="streams_not_yet_supported_removed">Streamy, které zatím nejsou podporovány systémem stahování, nebudou zobrazeny</string>
<string name="select_quality_external_players">Vyberte kvalitu pro externí přehrávače</string>
<string name="no_video_streams_available_for_external_players">U externích přehrávačů nejsou dostupné žádné video streamy</string>
<string name="no_video_streams_available_for_external_players">U externích přehrávačů nejsou k dispozici žádné videostreamy</string>
<string name="feed_toggle_hide_played_items">Skrýt zhlédnuté položky</string>
<string name="feed_toggle_hide_future_items">Skrýt nadcházející položky</string>
<string name="faq_title">Často kladené dotazy</string>
@ -739,4 +739,10 @@
<string name="fast_mode">Rychlý režim</string>
<string name="app_update_unavailable_toast">Používáte nejnovější verzi NewPipe</string>
<string name="import_subscriptions_hint">Import nebo export odběrů z 3-tečkové nabídky</string>
<string name="night_theme_available">Tato možnost je dostupná pouze při vybraném motivu %s</string>
<string name="unset_playlist_thumbnail">Zrušení nastavení trvalého náhledu</string>
<string name="card">Karta</string>
<string name="msg_failed_to_copy">Kopírování do schránky se nezdařilo</string>
<string name="duplicate_in_playlist">Zašedlé playlisty již obsahují tuto položku.</string>
<string name="playlist_add_stream_success_duplicate">Duplikát přidán %dkrát</string>
</resources>

View File

@ -32,12 +32,12 @@
<string name="controls_background_title">Baggrund</string>
<string name="controls_popup_title">Pop op</string>
<string name="controls_add_to_playlist_title">Føj til</string>
<string name="download_path_title">Placering af videodownloads</string>
<string name="download_path_title">Mappe til download af video</string>
<string name="download_path_summary">Downloadede videoer gemmes her</string>
<string name="download_path_dialog_title">Angiv downloadmappe for videofiler</string>
<string name="download_path_audio_title">Downloadmappe for lydfiler</string>
<string name="download_path_dialog_title">Angiv download-mappe for videofiler</string>
<string name="download_path_audio_title">Download-mappe for lydfiler</string>
<string name="download_path_audio_summary">Downloadede lydfiler gemmes her</string>
<string name="download_path_audio_dialog_title">Angiv downloadmappe for lydfiler</string>
<string name="download_path_audio_dialog_title">Angiv download-mappe for lydfiler</string>
<string name="default_resolution_title">Standardopløsning</string>
<string name="default_popup_resolution_title">Standardopløsning for pop op</string>
<string name="show_higher_resolutions_title">Vis højere opløsninger</string>
@ -45,7 +45,7 @@
<string name="play_with_kodi_title">Afspil med Kodi</string>
<string name="kore_not_found">Installer manglede Kore-app\?</string>
<string name="show_play_with_kodi_title">Vis valgmuligheden \"Afspil med Kodi\"</string>
<string name="show_play_with_kodi_summary">Vis en knap til at afspille en video via Kodi</string>
<string name="show_play_with_kodi_summary">Vis en knap til at afspille en video via Kodi-mediecenteret</string>
<string name="play_audio">Lyd</string>
<string name="default_audio_format_title">Standardformat for lydfiler</string>
<string name="default_video_format_title">Standardformat for videofiler</string>
@ -56,7 +56,7 @@
<string name="popup_remember_size_pos_title">Husk størrelse og placering af pop op</string>
<string name="popup_remember_size_pos_summary">Husk sidste størrelse og placering af pop op-afspiller</string>
<string name="use_inexact_seek_title">Brug hurtig og upræcis søgning</string>
<string name="use_inexact_seek_summary">Upræcis søgning lader afspilleren finde placeringer hurtigere, men mindre præcist. Søgninger på 5, 15 eller 25 sekunder fungerer ikke med denne indstilling, slået til</string>
<string name="use_inexact_seek_summary">Upræcis søgning lader afspilleren finde placeringer hurtigere, men mindre præcist. Søgninger på 5, 15 eller 25 sekunder fungerer ikke med denne indstilling slået til</string>
<string name="download_thumbnail_title">Indlæs miniaturebilleder</string>
<string name="download_thumbnail_summary">Slå fra for at undgå indlæsning af billeder, hvorved der spares data og hukommelse. Ændringer sletter billedcachen i både ram og lager</string>
<string name="thumbnail_cache_wipe_complete_notice">Billedcache slettet</string>
@ -64,7 +64,7 @@
<string name="metadata_cache_wipe_summary">Slet alle websidedata fra cachen</string>
<string name="metadata_cache_wipe_complete_notice">Metadata-cache slettet</string>
<string name="auto_queue_title">Føj automatisk næste stream til køen</string>
<string name="auto_queue_summary">Fortsæt nedlukningen af en (ikke-gentagende) playback kø ved at tilføje et relateret stream</string>
<string name="auto_queue_summary">Fortsæt en afspilningskø, der afsluttes (ikke-gentagende), ved at tilføje en lignende stream</string>
<string name="volume_gesture_control_title">Juster lydstyrke ved hjælp af fingerbevægelser</string>
<string name="volume_gesture_control_summary">Brug fingerbevægelser til at kontrollere afspillerens lydstyrke</string>
<string name="brightness_gesture_control_title">Styr lysstyrken med fingerbevægelser</string>
@ -101,7 +101,7 @@
<string name="error_report_title">Fejlrapport</string>
<string name="all">Alle</string>
<string name="channels">Kanaler</string>
<string name="playlists">Playlister</string>
<string name="playlists">Spillelister</string>
<plurals name="videos">
<item quantity="one">Én video</item>
<item quantity="other">%s videoer</item>
@ -130,7 +130,7 @@
<string name="import_data_summary">Overskriver din nuværende historik, abonnementer, spillelister og (hvis det ønskes) indstillinger</string>
<string name="export_data_summary">Eksporter historik, abonnementer, spillelister og indstillinger</string>
<string name="clear_views_history_title">Slet visningshistorik</string>
<string name="clear_views_history_summary">Sletter historikken og positioner af tidligere viste videoer</string>
<string name="clear_views_history_summary">Sletter historikken over afspillede streams og afspilningspositionerne</string>
<string name="delete_view_history_alert">Slet hele visningshistorikken\?</string>
<string name="watch_history_deleted">Visningshistorikken blev slettet</string>
<string name="clear_search_history_title">Slet søgehistorik</string>
@ -217,7 +217,7 @@
<string name="title_activity_about">Om NewPipe</string>
<string name="title_licenses">Tredjepartslicenser</string>
<string name="copyright" formatted="true">© %1$s af %2$s under %3$s</string>
<string name="tab_about">Om</string>
<string name="tab_about">Om &amp; Ofte stillede spørgsmål</string>
<string name="tab_licenses">Licenser</string>
<string name="app_description">Åben letvægtsstreaming på Android.</string>
<string name="contribution_title">Bidrag til projektet</string>
@ -330,7 +330,7 @@
<string name="drawer_close">Luk skuffe</string>
<string name="info_labels">Hvad:\\nForespørgsel:\\nIndholdssprog:\\nIndholdsland:\\nAppsprog:\\nTjeneste:\\nGMT-tid:\\nPakke:\\nVersion:\\nOS-version:</string>
<string name="preferred_open_action_settings_summary">Standardhandling når indhold åbnes %s</string>
<string name="set_as_playlist_thumbnail">Anvend som playlistens miniature</string>
<string name="set_as_playlist_thumbnail">Anvend som spillelistens miniaturebillede</string>
<string name="bookmark_playlist">Bogmærk spilleliste</string>
<string name="unbookmark_playlist">Fjern bogmærke</string>
<string name="playlist_add_stream_success">Føjet til spillelisten</string>
@ -386,7 +386,7 @@
<string name="autoplay_title">Afspil automatisk</string>
<string name="settings_category_clear_data_title">Ryd data</string>
<string name="enable_playback_state_lists_title">Positioner i lister</string>
<string name="enable_playback_resume_summary">Genopret forrige afspilningsposition</string>
<string name="enable_playback_resume_summary">Gendan sidste afspilningsposition</string>
<string name="enable_playback_resume_title">Fortsæt afspilning</string>
<string name="show_comments_summary">Slå fra for at skjule kommentarer</string>
<string name="show_comments_title">Vis kommentarer</string>
@ -398,7 +398,7 @@
<string name="notification_action_1_title">Anden handlingstast</string>
<string name="notification_action_2_title">Tredje handlingstast</string>
<string name="search_showing_result_for">Viser resultater for: %s</string>
<string name="open_with">Åben med</string>
<string name="open_with">Åbn med</string>
<string name="leak_canary_not_available">LeakCanary er ikke tilgængelig</string>
<string name="mark_as_watched">Markér som set</string>
<string name="description_tab_description">Beskrivelse</string>
@ -447,7 +447,7 @@
<string name="streams_notification_channel_name">Nye streams</string>
<string name="streams_notification_channel_description">Notifikationer om nye streams fra abonnementer</string>
<string name="recaptcha_cookies_cleared">reCAPTCHA cookies er ryddet</string>
<string name="delete_playback_states_alert">Slet alle playback positioner\?</string>
<string name="delete_playback_states_alert">Slet alle afspilningspositioner\?</string>
<string name="missing_file">Filen er flyttet eller slettet</string>
<string name="error_report_notification_title">NewPipe stødte ind i en fejl, tryk for at rapportere</string>
<string name="error_report_open_issue_button_text">Rapporter på GitHub</string>
@ -463,9 +463,9 @@
<item quantity="one">Download fuldført</item>
<item quantity="other">%s downloads fuldført</item>
</plurals>
<string name="progressive_load_interval_summary">Lav indlæsningsintervallets størrelse, (som nu ligger på %s) om. En højere værdi kan øge videoindlæsningshastigheden. Ændringer af værdien kræver genstart.</string>
<string name="progressive_load_interval_summary">Ændr indlæsningsintervallets størrelse (som nu er på %s). En lavere værdi kan øge videoindlæsningshastigheden. Ændringer kræver en genstart af afspiller</string>
<string name="clear_queue_confirmation_description">Den aktive spilleliste bliver udskiftet</string>
<string name="clear_queue_confirmation_summary">At skifte fra en afspiller til en anden kan udskifte din kø</string>
<string name="clear_queue_confirmation_summary">Hvis du skifter fra en spiller til en anden, kan din kø blive erstattet</string>
<string name="show_meta_info_title">Vis metainformation</string>
<string name="local_search_suggestions">Lokale søgeforslag</string>
<string name="remote_search_suggestions">Fjerne søgeforslag</string>
@ -475,8 +475,8 @@
<string name="hash_channel_description">Notifikationer om videohashfunktioners status</string>
<string name="error_report_channel_name">Fejlrapport-notifikation</string>
<string name="error_report_channel_description">Notifikationer for at rapportere fejl</string>
<string name="clear_playback_states_title">Slet playback positioner</string>
<string name="clear_playback_states_summary">Sletter alle playback positioner</string>
<string name="clear_playback_states_title">Slet afspilningspositioner</string>
<string name="clear_playback_states_summary">Sletter alle afspilningspositioner</string>
<string name="downloads_storage_ask_title">Spørg hvor filen skal downloades</string>
<string name="enable_queue_limit_desc">Et download ad gangen</string>
<string name="delete_downloaded_files">Slet downloadede filer</string>
@ -487,23 +487,23 @@
\nNewPipes fortrolighedspolitik forklarer i detaljer, hvilke data der bliver sendt og opbevaret når du sender en nedbrudsrapport.</string>
<string name="copy_for_github">Kopier en formatteret rapport</string>
<string name="permission_display_over_apps">Giv tilladelse til at vise over andre apps</string>
<string name="enable_playback_state_lists_summary">Vis playback positionsvisere i lister</string>
<string name="watch_history_states_deleted">Playback positioner slettet</string>
<string name="enable_playback_state_lists_summary">Vis indikatorer for afspilningsposition i lister</string>
<string name="watch_history_states_deleted">Afspilningspositioner slettet</string>
<string name="clear_cookie_title">Ryd reCAPTCHA cookies</string>
<string name="download_already_pending">Der er en afventende download med dette navn</string>
<string name="start_downloads">Start downloads</string>
<string name="notification_scale_to_square_image_title">Skaler miniaturebilledet til 1:1 format</string>
<string name="notification_scale_to_square_image_summary">Skaler notifikationsminiaturebillederne fra 16:9 til 1:1 format (dette kan medføre forvrængninger)</string>
<string name="notification_actions_summary">Rediger hver eneste varselshandling nedenunder ved at trykke på dem. Vælg op til tre af dem som bliver vist i den lille notifikation, via kasserne til højre</string>
<string name="notification_scale_to_square_image_title">Beskær miniaturebillede til 1:1 format</string>
<string name="notification_scale_to_square_image_summary">Beskær video-miniaturebillede i notifikationen fra 16:9 til 1:1 format</string>
<string name="notification_actions_summary">Rediger hver eneste varselshandling nedenunder ved at trykke på dem. Vælg op til tre af dem som bliver vist i den lille notifikation, via afkrydsningsfelterne til højre</string>
<string name="notification_actions_at_most_three">Du kan kun vælge op til tre handlinger som kan vises i den lille notifikation!</string>
<string name="notification_action_buffering">Buffer</string>
<string name="notification_colorize_summary">Få Android til at vælge notifikationens farve ud fra den primære farve i miniaturebilledet (virker ikke på alle enheder)</string>
<string name="night_theme_title">Nattetema</string>
<string name="night_theme_title">Nattema</string>
<string name="seek_duration_title">Frem- og tilbagesøgningstid</string>
<string name="restricted_video_no_stream">Denne video er aldersbegrænset.
\nPga. YouTubes politik om aldersbegrænsede videoer har NewPipe ikke adgang til videoen.</string>
<string name="crash_the_player">Crash afspilleren</string>
<string name="clear_queue_confirmation_title">Spørg om bekræftelse før du tømmer en kø</string>
<string name="clear_queue_confirmation_title">Spørg om bekræftelse før du rydder en kø</string>
<string name="seekbar_preview_thumbnail_title">Forhåndsvisning af miniaturebilleder på statuslinjen</string>
<string name="enqueue_next_stream">Sæt i kø som næste</string>
<string name="enqueued_next">Er sat som næste i køen</string>
@ -514,8 +514,8 @@
<string name="comments_tab_description">Kommentarer</string>
<string name="related_items_tab_description">Relaterede objekter</string>
<string name="main_page_content_swipe_remove">Stryg på elementer for at fjerne dem</string>
<string name="select_a_playlist">Vælg en playliste</string>
<string name="no_playlist_bookmarked_yet">Ingen playliste bogmærker endnu</string>
<string name="select_a_playlist">Vælg en spilleliste</string>
<string name="no_playlist_bookmarked_yet">Ingen spilleliste-bogmærker endnu</string>
<string name="localization_changes_requires_app_restart">Sproget ændres når appen genstarter</string>
<string name="title_activity_play_queue">Spillekø</string>
<string name="show_channel_details">Vis kanalens detaljer</string>
@ -530,7 +530,7 @@
<string name="any_network">Alle netværk</string>
<string name="streams_notifications_interval_title">Kontrolfrekvens</string>
<string name="enable_streams_notifications_title">Notifikationer ved nye streams</string>
<string name="enable_streams_notifications_summary">Notifikationer om ny streams fra abonnomenter</string>
<string name="enable_streams_notifications_summary">Giv besked om nye streams fra abonnementer</string>
<string name="manual_update_description">Tjek manuelt efter opdateringer</string>
<string name="checking_updates_toast">Tjekker efter opdateringer…</string>
<string name="recovering">Gendanner</string>
@ -555,8 +555,8 @@
<string name="feed_toggle_show_played_items">Vis sete elementer</string>
<string name="georestricted_content">Dette indhold er ikke tilgængeligt i dit land.</string>
<string name="video_detail_by">Af %s</string>
<string name="remove_watched_popup_warning">Videoer på playlisten som allerede er blevet set fjernes.
\nDette kan ikke fortrydes!</string>
<string name="remove_watched_popup_warning">Videoer, der er blevet set før og efter, at de er blevet tilføjet til spillelisten, vil blive fjernet.
\nEr du sikker\? Dette kan ikke gøres om!</string>
<string name="show_thumbnail_title">Vis miniaturebillede</string>
<string name="metadata_tags">Tags</string>
<string name="metadata_age_limit">Aldersbegrænsning</string>
@ -653,4 +653,78 @@
<string name="show_crash_the_player_summary">Vis et crash alternativ når afspilleren er i brug</string>
<string name="show_error_snackbar">Vis en fejl snackbar</string>
<string name="downloads_storage_use_saf_title">Brug system mappevælger (SAF)</string>
<string name="detail_sub_channel_thumbnail_view_description">Kanalens avatar-miniaturebillede</string>
<string name="soundcloud_go_plus_content">Dette er et SoundCloud Go+-nummer, i hvert fald i dit land, så det kan ikke streames eller downloades af NewPipe.</string>
<string name="no_appropriate_file_manager_message_android_10">Der blev ikke fundet nogen passende filhåndtering til denne handling.
\nInstaller en Storage Access Framework-kompatibel filhåndtering</string>
<string name="no_appropriate_file_manager_message">Der blev ikke fundet nogen passende filhåndtering til denne handling.
\nInstaller et filhåndteringsprogram eller prøv at deaktivere \'%s\' i download-indstillingerne</string>
<string name="description_select_enable">Aktivér valg af tekst i beskrivelsen</string>
<string name="auto_device_theme_title">Automatisk (enhedstema)</string>
<string name="description_select_disable">Deaktiver valg af tekst i beskrivelsen</string>
<string name="detail_pinned_comment_view_description">Fastgjort kommentar</string>
<string name="you_successfully_subscribed">Du abonnerer nu på denne kanal</string>
<string name="enumeration_comma">,</string>
<string name="get_notified">Få besked</string>
<string name="downloads_storage_ask_summary_no_saf_notice">Du vil blive spurgt, hvor du vil gemme hver enkelt download</string>
<string name="feed_use_dedicated_fetch_method_summary">Den er tilgængelig i nogle tjenester og er normalt meget hurtigere, men kan returnere et begrænset antal elementer og ofte ufuldstændige oplysninger (f.eks. ingen varighed, elementtype, ingen live-status)</string>
<string name="unknown_format">Ukendt format</string>
<string name="unknown_quality">Ukendt kvalitet</string>
<string name="detail_heart_img_view_description">Hjertemarkeret af indholdsskaberen</string>
<string name="progressive_load_interval_title">Intervalstørrelse for afspilningsindlæsning</string>
<string name="progressive_load_interval_exoplayer_default">ExoPlayer-standard</string>
<string name="feed_group_dialog_empty_name">Tomt gruppenavn</string>
<string name="downloads_storage_ask_summary">Du vil blive spurgt, hvor du vil gemme hver enkelt download.
\nAktiver systemet mappevælger (SAF), hvis du vil downloade til et eksternt SD-kort</string>
<string name="show_original_time_ago_summary">Originaltekster fra tjenester vil være synlige i stream-emner</string>
<string name="no_video_streams_available_for_external_players">Ingen videostreams er tilgængelige for eksterne afspillere</string>
<string name="metadata_thumbnail_url">URL til miniaturebillede</string>
<string name="off">Fra</string>
<string name="tablet_mode_title">Tablet-tilstand</string>
<string name="feed_toggle_hide_future_items">Skjul fremtidige elementer</string>
<string name="youtube_music_premium_content">Denne video er kun tilgængelig for YouTube Music Premium-medlemmer, så den kan ikke streames eller downloades af NewPipe.</string>
<string name="downloads_storage_use_saf_summary">\"Storage Access Framework\" gør det muligt at downloade til et eksternt SD-kort</string>
<string name="enable_disposed_exceptions_summary">Fremtving indberetning af ikke-leverbare Rx-undtagelser uden for fragmentets eller aktivitetens livscyklus efter bortskaffelse</string>
<string name="app_update_available_notification_text">Tryk for at downloade %s</string>
<string name="downloads_storage_use_saf_summary_api_29">Fra og med Android 10 understøttes kun \"Storage Access Framework\"</string>
<string name="feed_use_dedicated_fetch_method_help_text">Synes du, at feed-indlæsning er for langsom\? Hvis det er tilfældet, så prøv at aktivere hurtig indlæsning (du kan ændre det i indstillingerne eller ved at trykke på knappen nedenfor).
\n
\nNewPipe tilbyder to strategier til feed-indlæsning:
\n- Hentning af hele abonnementskanalen, hvilket er langsomt, men komplet.
\n- Brug af et dedikeret service endpoint, hvilket er hurtigt, men normalt ikke komplet.
\n
\nForskellen mellem de to er, at den hurtige metode normalt mangler nogle oplysninger, f.eks. elementets varighed eller type (kan ikke skelne mellem livevideoer og normale videoer), og den returnerer muligvis færre elementer.
\n
\nYouTube er et eksempel på en tjeneste, der tilbyder denne hurtige metode med sit RSS-feed.
\n
\nValget er altså et spørgsmål om, hvad du foretrækker: hastighed eller præcise oplysninger.</string>
<string name="selected_stream_external_player_not_supported">Den valgte stream er ikke understøttet af eksterne afspillere</string>
<string name="night_theme_available">Denne indstilling er kun tilgængelig, hvis %s er valgt som tema</string>
<string name="description_select_note">Du kan nu vælge tekst i beskrivelsen. Bemærk, at siden kan flimre, og at links muligvis ikke kan klikkes på, mens du er i valgtilstand.</string>
<string name="streams_not_yet_supported_removed">Streams, som endnu ikke understøttes af downloaderen, vises ikke</string>
<string name="fast_mode">Hurtig tilstand</string>
<string name="import_subscriptions_hint">Importér eller eksportér abonnementer fra 3-punktsmenuen</string>
<string name="faq_title">Ofte stillede spørgsmål</string>
<string name="faq_description">Hvis du har problemer med at bruge appen, bør du tjekke disse svar på almindelige spørgsmål!</string>
<string name="faq">Se på hjemmeside</string>
<string name="show_image_indicators_summary">Vis Picasso-farvede bånd oven på billeder, der angiver deres kilde: rød for netværk, blå for disk og grøn for hukommelse</string>
<string name="app_update_unavailable_toast">Du kører den nyeste version af NewPipe</string>
<string name="new_seek_duration_toast">På grund af ExoPlayer-begrænsninger blev søgetiden sat til %d sekunder</string>
<string name="feed_group_show_only_ungrouped_subscriptions">Vis kun ikke-grupperede abonnementer</string>
<string name="feed_toggle_hide_played_items">Skjul sete elementer</string>
<string name="playlist_page_summary">Side med spillelister</string>
<string name="select_night_theme_toast">Du kan vælge dit foretrukne nattema nedenfor</string>
<string name="night_theme_summary">Vælg dit foretrukne nattema - %s</string>
<string name="metadata_support">Support</string>
<string name="metadata_host">Host</string>
<string name="metadata_privacy_public">Offentlig</string>
<string name="metadata_privacy_unlisted">Ikke oplyst</string>
<string name="metadata_privacy_private">Privat</string>
<string name="metadata_privacy_internal">Intern</string>
<string name="on">Til</string>
<string name="toggle_all">Skift alle</string>
<string name="no_audio_streams_available_for_external_players">Ingen lydstreams er tilgængelige for eksterne afspillere</string>
<string name="select_quality_external_players">Vælg kvalitet til eksterne afspillere</string>
<string name="feed_toggle_show_future_items">Vis fremtidige elementer</string>
<string name="sort">Sortér</string>
</resources>

View File

@ -726,4 +726,10 @@
<string name="app_update_unavailable_toast">Du verwendest die neueste Version von NewPipe</string>
<string name="app_update_available_notification_text">Antippen um %s herunterzuladen</string>
<string name="import_subscriptions_hint">Importieren oder Exportieren von Abonnements über das 3-Punkte-Menü</string>
<string name="night_theme_available">Diese Option ist nur verfügbar, wenn %s als Design ausgewählt wird</string>
<string name="unset_playlist_thumbnail">Dauerhaftes Vorschaubild aufheben</string>
<string name="msg_failed_to_copy">Kopieren in die Zwischenablage fehlgeschlagen</string>
<string name="card">Karte</string>
<string name="playlist_add_stream_success_duplicate">Duplikat %d mal hinzugefügt</string>
<string name="duplicate_in_playlist">Die ausgegrauten Wiedergabelisten enthalten dieses Element bereits.</string>
</resources>

View File

@ -726,4 +726,10 @@
<string name="import_subscriptions_hint">Εισάγετε ή εξάγετε συνδρομές από το μενού 3 κουκκίδων</string>
<string name="app_update_available_notification_text">Πατήστε για λήψη %s</string>
<string name="app_update_unavailable_toast">Έχετε την πιο πρόσφατη έκδοση του NewPipe</string>
<string name="night_theme_available">Αυτή η επιλογή είναι διαθέσιμη μόνο εάν έχει επιλεγεί %s για Θέμα</string>
<string name="unset_playlist_thumbnail">Κατάργηση μόνιμης μικρογραφίας</string>
<string name="msg_failed_to_copy">Αποτυχία αντιγραφής στο πρόχειρο</string>
<string name="card">Κάρτα</string>
<string name="duplicate_in_playlist">Οι λίστες αναπαραγωγής που είναι γκριζαρισμένες περιέχουν ήδη αυτό το στοιχείο.</string>
<string name="playlist_add_stream_success_duplicate">Προστέθηκε διπλότυπο %d φορά(ες)</string>
</resources>

View File

@ -742,4 +742,10 @@
<string name="import_subscriptions_hint">Importa o exporta las suscripciones desde el menú con los tres puntos</string>
<string name="app_update_unavailable_toast">Está ejecutando la última versión de NewPipe</string>
<string name="app_update_available_notification_text">Pulsa para descargar %s</string>
<string name="night_theme_available">Esta opción sólo está disponible si %s está seleccionado para el tema</string>
<string name="unset_playlist_thumbnail">Desactivar las miniaturas permanente</string>
<string name="msg_failed_to_copy">Error al copiar al portapapeles</string>
<string name="card">Tarjeta</string>
<string name="playlist_add_stream_success_duplicate">Duplicado añadido %d vez/veces</string>
<string name="duplicate_in_playlist">Las listas de reproducción que están en gris ya contienen este elemento.</string>
</resources>

View File

@ -726,4 +726,10 @@
<string name="fast_mode">Kiirrežiim</string>
<string name="import_subscriptions_hint">Tellimusi saad importida või eksportida 3 punktiga menüüst</string>
<string name="app_update_unavailable_toast">Sa kasutad NewPipe\'i uusimat versiooni</string>
<string name="night_theme_available">See valik on kasutusel vaid %s teema puhul</string>
<string name="msg_failed_to_copy">Lõikelauale kopeerimine ei õnnestunud</string>
<string name="unset_playlist_thumbnail">Eemalda püsiv pisipilt</string>
<string name="card">Kaart</string>
<string name="duplicate_in_playlist">Hallina kuvatud esitusloendid juba sisaldavad seda kirjet.</string>
<string name="playlist_add_stream_success_duplicate">Topeltkirje lisatud %d kord(a)</string>
</resources>

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