Merge branch 'master' of https://github.com/TeamNewPipe/NewPipe into sponsorblock

Signed-off-by: baalajimaestro <me@baalajimaestro.me>
This commit is contained in:
baalajimaestro 2024-04-24 22:34:52 +05:30
commit 14079f0ae1
Signed by: baalajimaestro
GPG key ID: F93C394FE9BBAFD5
348 changed files with 9391 additions and 2778 deletions

View file

@ -1,6 +1,3 @@
name: Question
description: Ask about anything NewPipe-related
labels: [question]
body:
- type: markdown
attributes:

View file

@ -36,8 +36,8 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v3
- uses: gradle/wrapper-validation-action@v1
- uses: actions/checkout@v4
- uses: gradle/wrapper-validation-action@v2
- name: create and checkout branch
# push events already checked out the branch
@ -47,7 +47,7 @@ jobs:
run: git checkout -B "$BRANCH"
- name: set up JDK 17
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
java-version: 17
distribution: "temurin"
@ -57,7 +57,7 @@ jobs:
run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint
- name: Upload APK
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: app
path: app/build/outputs/apk/debug/*.apk
@ -80,10 +80,10 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- name: set up JDK 17
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
java-version: 17
distribution: "temurin"
@ -98,7 +98,7 @@ jobs:
script: ./gradlew connectedCheck --stacktrace
- name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: failure()
with:
name: android-test-report-api${{ matrix.api-level }}
@ -111,19 +111,19 @@ jobs:
contents: read
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Set up JDK 17
uses: actions/setup-java@v3
uses: actions/setup-java@v4
with:
java-version: 17
distribution: "temurin"
cache: 'gradle'
- name: Cache SonarCloud packages
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar

View file

@ -17,9 +17,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
node-version: 16
@ -27,7 +27,7 @@ jobs:
run: npm i probe-image-size@7.2.3 --ignore-scripts
- name: Minimize simple images
uses: actions/github-script@v6
uses: actions/github-script@v7
timeout-minutes: 3
with:
script: |

View file

@ -1,5 +1,5 @@
name: "PR size labeler"
on: [pull_request]
on: [pull_request_target]
permissions:
contents: read
pull-requests: write

View file

@ -20,8 +20,8 @@ android {
resValue "string", "app_name", "NewPipe SponsorBlock"
minSdk 21
targetSdk 33
versionCode 996
versionName "0.26.1"
versionCode 997
versionName "0.27.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -198,7 +198,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:v0.23.1'
implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.24.0'
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
/** Checkstyle **/
@ -238,7 +238,7 @@ dependencies {
kapt "frankiesardo:icepick-processor:${icepickVersion}"
// HTML parser
implementation "org.jsoup:jsoup:1.16.2"
implementation "org.jsoup:jsoup:1.17.2"
// HTTP client
implementation "com.squareup.okhttp3:okhttp:4.12.0"

View file

@ -0,0 +1,737 @@
{
"formatVersion": 1,
"database": {
"version": 8,
"identityHash": "012fc8e7ad3333f1597347f34e76a513",
"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": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"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": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"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": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"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": {
"autoGenerate": false,
"columnNames": [
"stream_id",
"access_date"
]
},
"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": {
"autoGenerate": false,
"columnNames": [
"stream_id"
]
},
"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, `is_thumbnail_permanent` INTEGER NOT NULL, `thumbnail_stream_id` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isThumbnailPermanent",
"columnName": "is_thumbnail_permanent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "thumbnailStreamId",
"columnName": "thumbnail_stream_id",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"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": {
"autoGenerate": false,
"columnNames": [
"playlist_id",
"join_index"
]
},
"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": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"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": {
"autoGenerate": false,
"columnNames": [
"stream_id",
"subscription_id"
]
},
"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": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"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": {
"autoGenerate": false,
"columnNames": [
"group_id",
"subscription_id"
]
},
"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": {
"autoGenerate": false,
"columnNames": [
"subscription_id"
]
},
"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, '012fc8e7ad3333f1597347f34e76a513')"
]
}
}

View file

@ -0,0 +1,730 @@
{
"formatVersion": 1,
"database": {
"version": 9,
"identityHash": "7591e8039faa74d8c0517dc867af9d3e",
"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": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"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": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"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": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"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": {
"autoGenerate": false,
"columnNames": [
"stream_id",
"access_date"
]
},
"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": {
"autoGenerate": false,
"columnNames": [
"stream_id"
]
},
"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, `is_thumbnail_permanent` INTEGER NOT NULL, `thumbnail_stream_id` INTEGER NOT NULL, `display_index` INTEGER NOT NULL)",
"fields": [
{
"fieldPath": "uid",
"columnName": "uid",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "isThumbnailPermanent",
"columnName": "is_thumbnail_permanent",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "thumbnailStreamId",
"columnName": "thumbnail_stream_id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "displayIndex",
"columnName": "display_index",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [],
"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": {
"autoGenerate": false,
"columnNames": [
"playlist_id",
"join_index"
]
},
"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, `display_index` INTEGER NOT NULL, `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": "displayIndex",
"columnName": "display_index",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "streamCount",
"columnName": "stream_count",
"affinity": "INTEGER",
"notNull": false
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"indices": [
{
"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": {
"autoGenerate": false,
"columnNames": [
"stream_id",
"subscription_id"
]
},
"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": {
"autoGenerate": true,
"columnNames": [
"uid"
]
},
"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": {
"autoGenerate": false,
"columnNames": [
"group_id",
"subscription_id"
]
},
"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": {
"autoGenerate": false,
"columnNames": [
"subscription_id"
]
},
"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, '7591e8039faa74d8c0517dc867af9d3e')"
]
}
}

View file

@ -8,10 +8,14 @@ import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNull
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.schabi.newpipe.database.playlist.model.PlaylistEntity
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.stream.StreamType
@RunWith(AndroidJUnit4::class)
@ -20,13 +24,17 @@ class DatabaseMigrationTest {
private const val DEFAULT_SERVICE_ID = 0
private const val DEFAULT_URL = "https://www.youtube.com/watch?v=cDphUib5iG4"
private const val DEFAULT_TITLE = "Test Title"
private const val DEFAULT_NAME = "Test Name"
private val DEFAULT_TYPE = StreamType.VIDEO_STREAM
private const val DEFAULT_DURATION = 480L
private const val DEFAULT_UPLOADER_NAME = "Uploader Test"
private const val DEFAULT_THUMBNAIL = "https://example.com/example.jpg"
private const val DEFAULT_SECOND_SERVICE_ID = 0
private const val DEFAULT_SECOND_SERVICE_ID = 1
private const val DEFAULT_SECOND_URL = "https://www.youtube.com/watch?v=ncQU6iBn5Fc"
private const val DEFAULT_THIRD_SERVICE_ID = 2
private const val DEFAULT_THIRD_URL = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
}
@get:Rule
@ -106,6 +114,20 @@ class DatabaseMigrationTest {
Migrations.MIGRATION_6_7
)
testHelper.runMigrationsAndValidate(
AppDatabase.DATABASE_NAME,
Migrations.DB_VER_8,
true,
Migrations.MIGRATION_7_8
)
testHelper.runMigrationsAndValidate(
AppDatabase.DATABASE_NAME,
Migrations.DB_VER_9,
true,
Migrations.MIGRATION_8_9
)
val migratedDatabaseV3 = getMigratedDatabase()
val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst()
@ -140,6 +162,157 @@ class DatabaseMigrationTest {
assertNull(secondStreamFromMigratedDatabase.isUploadDateApproximation)
}
@Test
fun migrateDatabaseFrom7to8() {
val databaseInV7 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_7)
val defaultSearch1 = " abc "
val defaultSearch2 = " abc"
val serviceId = DEFAULT_SERVICE_ID // YouTube
// Use id different to YouTube because two searches with the same query
// but different service are considered not equal.
val otherServiceId = ServiceList.SoundCloud.serviceId
databaseInV7.run {
insert(
"search_history", SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", serviceId)
put("search", defaultSearch1)
}
)
insert(
"search_history", SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", serviceId)
put("search", defaultSearch2)
}
)
insert(
"search_history", SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", otherServiceId)
put("search", defaultSearch1)
}
)
insert(
"search_history", SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", otherServiceId)
put("search", defaultSearch2)
}
)
close()
}
testHelper.runMigrationsAndValidate(
AppDatabase.DATABASE_NAME, Migrations.DB_VER_8,
true, Migrations.MIGRATION_7_8
)
testHelper.runMigrationsAndValidate(
AppDatabase.DATABASE_NAME, Migrations.DB_VER_9,
true, Migrations.MIGRATION_8_9
)
val migratedDatabaseV8 = getMigratedDatabase()
val listFromDB = migratedDatabaseV8.searchHistoryDAO().all.blockingFirst()
assertEquals(2, listFromDB.size)
assertEquals("abc", listFromDB[0].search)
assertEquals("abc", listFromDB[1].search)
assertNotEquals(listFromDB[0].serviceId, listFromDB[1].serviceId)
}
@Test
fun migrateDatabaseFrom8to9() {
val databaseInV8 = testHelper.createDatabase(AppDatabase.DATABASE_NAME, Migrations.DB_VER_8)
val localUid1: Long
val localUid2: Long
val remoteUid1: Long
val remoteUid2: Long
databaseInV8.run {
localUid1 = insert(
"playlists", SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("name", DEFAULT_NAME + "1")
put("is_thumbnail_permanent", false)
put("thumbnail_stream_id", -1)
}
)
localUid2 = insert(
"playlists", SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("name", DEFAULT_NAME + "2")
put("is_thumbnail_permanent", false)
put("thumbnail_stream_id", -1)
}
)
delete(
"playlists", "uid = ?",
Array(1) { localUid1 }
)
remoteUid1 = insert(
"remote_playlists", SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", DEFAULT_SERVICE_ID)
put("url", DEFAULT_URL)
}
)
remoteUid2 = insert(
"remote_playlists", SQLiteDatabase.CONFLICT_FAIL,
ContentValues().apply {
put("service_id", DEFAULT_SECOND_SERVICE_ID)
put("url", DEFAULT_SECOND_URL)
}
)
delete(
"remote_playlists", "uid = ?",
Array(1) { remoteUid2 }
)
close()
}
testHelper.runMigrationsAndValidate(
AppDatabase.DATABASE_NAME,
Migrations.DB_VER_9,
true,
Migrations.MIGRATION_8_9
)
val migratedDatabaseV9 = getMigratedDatabase()
var localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst()
var remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst()
assertEquals(1, localListFromDB.size)
assertEquals(localUid2, localListFromDB[0].uid)
assertEquals(-1, localListFromDB[0].displayIndex)
assertEquals(1, remoteListFromDB.size)
assertEquals(remoteUid1, remoteListFromDB[0].uid)
assertEquals(-1, remoteListFromDB[0].displayIndex)
val localUid3 = migratedDatabaseV9.playlistDAO().insert(
PlaylistEntity(DEFAULT_NAME + "3", false, -1, -1)
)
val remoteUid3 = migratedDatabaseV9.playlistRemoteDAO().insert(
PlaylistRemoteEntity(
DEFAULT_THIRD_SERVICE_ID, DEFAULT_NAME, DEFAULT_THIRD_URL,
DEFAULT_THUMBNAIL, DEFAULT_UPLOADER_NAME, -1, 10
)
)
localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst()
remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst()
assertEquals(2, localListFromDB.size)
assertEquals(localUid3, localListFromDB[1].uid)
assertEquals(-1, localListFromDB[1].displayIndex)
assertEquals(2, remoteListFromDB.size)
assertEquals(remoteUid3, remoteListFromDB[1].uid)
assertEquals(-1, remoteListFromDB[1].displayIndex)
}
private fun getMigratedDatabase(): AppDatabase {
val database: AppDatabase = Room.databaseBuilder(
ApplicationProvider.getApplicationContext(),

View file

@ -85,7 +85,13 @@ class FeedDAOTest {
private fun assertEqual(streams: List<StreamWithState>?, allowedStreams: List<StreamEntity>) {
assertNotNull(streams)
assertEquals(allowedStreams, streams!!.stream().map { it.stream }.toList().sortedBy { it.uid })
assertEquals(
allowedStreams,
streams!!
.map { it.stream }
.sortedBy { it.uid }
.toList()
)
}
private fun setupUnlinkDelete(time: String) {

View file

@ -60,6 +60,8 @@ import io.reactivex.rxjava3.plugins.RxJavaPlugins;
public class App extends Application {
public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID;
private static final String TAG = App.class.toString();
private boolean isFirstRun = false;
private static App app;
@NonNull
@ -85,7 +87,13 @@ public class App extends Application {
return;
}
// Initialize settings first because others inits can use its values
// check if the last used preference version is set
// to determine whether this is the first app run
final int lastUsedPrefVersion = PreferenceManager.getDefaultSharedPreferences(this)
.getInt(getString(R.string.last_used_preferences_version), -1);
isFirstRun = lastUsedPrefVersion == -1;
// Initialize settings first because other initializations can use its values
NewPipeSettings.initSettings(this);
NewPipe.init(getDownloader(),
@ -255,4 +263,7 @@ public class App extends Application {
return false;
}
public boolean isFirstRun() {
return isFirstRun;
}
}

View file

@ -79,6 +79,7 @@ import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.event.OnKeyDownListener;
import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.settings.UpdateSettingsFragment;
import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.KioskTranslator;
@ -86,6 +87,7 @@ import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PeertubeHelper;
import org.schabi.newpipe.util.PermissionHelper;
import org.schabi.newpipe.util.ReleaseVersionUtil;
import org.schabi.newpipe.util.SerializedCache;
import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.util.StateSaver;
@ -167,6 +169,11 @@ public class MainActivity extends AppCompatActivity {
// if this is enabled by the user.
NotificationWorker.initialize(this);
}
if (!UpdateSettingsFragment.wasUserAskedForConsent(this)
&& !App.getApp().isFirstRun()
&& ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
UpdateSettingsFragment.askForConsentToUpdateChecks(this);
}
}
@Override
@ -176,7 +183,8 @@ public class MainActivity extends AppCompatActivity {
final App app = App.getApp();
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
if (prefs.getBoolean(app.getString(R.string.update_app_key), true)) {
if (prefs.getBoolean(app.getString(R.string.update_app_key), false)
&& prefs.getBoolean(app.getString(R.string.update_check_consent_key), false)) {
// Start the worker which is checking all conditions
// and eventually searching for a new version.
NewVersionWorker.enqueueNewVersionCheckingWork(app, false);

View file

@ -7,6 +7,8 @@ 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 static org.schabi.newpipe.database.Migrations.MIGRATION_6_7;
import static org.schabi.newpipe.database.Migrations.MIGRATION_7_8;
import static org.schabi.newpipe.database.Migrations.MIGRATION_8_9;
import android.content.Context;
import android.database.Cursor;
@ -27,7 +29,7 @@ public final class NewPipeDatabase {
return Room
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
MIGRATION_5_6, MIGRATION_6_7)
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9)
.build();
}

View file

@ -20,8 +20,8 @@ import com.grack.nanojson.JsonParser
import com.grack.nanojson.JsonParserException
import org.schabi.newpipe.extractor.downloader.Response
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
import org.schabi.newpipe.util.ReleaseVersionUtil
import org.schabi.newpipe.util.ReleaseVersionUtil.coerceUpdateCheckExpiry
import org.schabi.newpipe.util.ReleaseVersionUtil.isLastUpdateCheckExpired
import org.schabi.newpipe.util.ReleaseVersionUtil.isReleaseApk
import org.schabi.newpipe.util.Version
import java.io.IOException
@ -86,7 +86,7 @@ class NewVersionWorker(
@Throws(IOException::class, ReCaptchaException::class)
private fun checkNewVersion() {
// Check if the current apk is a github one or not.
if (!isReleaseApk()) {
if (!ReleaseVersionUtil.isReleaseApk) {
return
}
@ -95,7 +95,7 @@ class NewVersionWorker(
// Check if the last request has happened a certain time ago
// to reduce the number of API requests.
val expiry = prefs.getLong(applicationContext.getString(R.string.update_expiry_key), 0)
if (!isLastUpdateCheckExpired(expiry)) {
if (!ReleaseVersionUtil.isLastUpdateCheckExpired(expiry)) {
return
}
}
@ -110,7 +110,7 @@ class NewVersionWorker(
try {
// Store a timestamp which needs to be exceeded,
// before a new request to the API is made.
val newExpiry = coerceUpdateCheckExpiry(response.getHeader("expires"))
val newExpiry = ReleaseVersionUtil.coerceUpdateCheckExpiry(response.getHeader("expires"))
prefs.edit {
putLong(applicationContext.getString(R.string.update_expiry_key), newExpiry)
}

View file

@ -1,6 +1,6 @@
package org.schabi.newpipe.database;
import static org.schabi.newpipe.database.Migrations.DB_VER_7;
import static org.schabi.newpipe.database.Migrations.DB_VER_9;
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_7
version = DB_VER_9
)
public abstract class AppDatabase extends RoomDatabase {
public static final String DATABASE_NAME = "newpipe.db";

View file

@ -25,6 +25,8 @@ public final class Migrations {
public static final int DB_VER_5 = 5;
public static final int DB_VER_6 = 6;
public static final int DB_VER_7 = 7;
public static final int DB_VER_8 = 8;
public static final int DB_VER_9 = 9;
private static final String TAG = Migrations.class.getName();
public static final boolean DEBUG = MainActivity.DEBUG;
@ -186,7 +188,7 @@ public final class Migrations {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` "
+ "INTEGER NOT NULL DEFAULT 0");
+ "INTEGER NOT NULL DEFAULT 0");
}
};
@ -235,6 +237,71 @@ public final class Migrations {
}
};
public static final Migration MIGRATION_7_8 = new Migration(DB_VER_7, DB_VER_8) {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
database.execSQL("DELETE FROM search_history WHERE id NOT IN (SELECT id FROM (SELECT "
+ "MIN(id) as id FROM search_history GROUP BY trim(search), service_id ) tmp)");
database.execSQL("UPDATE search_history SET search = trim(search)");
}
};
public static final Migration MIGRATION_8_9 = new Migration(DB_VER_8, DB_VER_9) {
@Override
public void migrate(@NonNull final SupportSQLiteDatabase database) {
try {
database.beginTransaction();
// Update playlists.
// Create a temp table to initialize display_index.
database.execSQL("CREATE TABLE `playlists_tmp` "
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
+ "`name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, "
+ "`thumbnail_stream_id` INTEGER NOT NULL, "
+ "`display_index` INTEGER NOT NULL)");
database.execSQL("INSERT INTO `playlists_tmp` "
+ "(`uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, "
+ "`display_index`) "
+ "SELECT `uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, "
+ "-1 "
+ "FROM `playlists`");
// Replace the old table, note that this also removes the index on the name which
// we don't need anymore.
database.execSQL("DROP TABLE `playlists`");
database.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`");
// Update remote_playlists.
// Create a temp table to initialize display_index.
database.execSQL("CREATE TABLE `remote_playlists_tmp` "
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
+ "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, "
+ "`thumbnail_url` TEXT, `uploader` TEXT, "
+ "`display_index` INTEGER NOT NULL,"
+ "`stream_count` INTEGER)");
database.execSQL("INSERT INTO `remote_playlists_tmp` (`uid`, `service_id`, "
+ "`name`, `url`, `thumbnail_url`, `uploader`, `display_index`, "
+ "`stream_count`)"
+ "SELECT `uid`, `service_id`, `name`, `url`, `thumbnail_url`, `uploader`, "
+ "-1, `stream_count` FROM `remote_playlists`");
// Replace the old table, note that this also removes the index on the name which
// we don't need anymore.
database.execSQL("DROP TABLE `remote_playlists`");
database.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`");
// Create index on the new table.
database.execSQL("CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` "
+ "ON `remote_playlists` (`service_id`, `url`)");
database.setTransactionSuccessful();
} finally {
database.endTransaction();
}
}
};
private Migrations() {
}
}

View file

@ -13,12 +13,17 @@ public class PlaylistDuplicatesEntry extends PlaylistMetadataEntry {
@ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED)
public final long timesStreamIsContained;
@SuppressWarnings("checkstyle:ParameterNumber")
public PlaylistDuplicatesEntry(final long uid,
final String name,
final String thumbnailUrl,
final boolean isThumbnailPermanent,
final long thumbnailStreamId,
final long displayIndex,
final long streamCount,
final long timesStreamIsContained) {
super(uid, name, thumbnailUrl, streamCount);
super(uid, name, thumbnailUrl, isThumbnailPermanent, thumbnailStreamId, displayIndex,
streamCount);
this.timesStreamIsContained = timesStreamIsContained;
}
}

View file

@ -1,22 +1,13 @@
package org.schabi.newpipe.database.playlist;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public interface PlaylistLocalItem extends LocalItem {
String getOrderingName();
static List<PlaylistLocalItem> merge(
final List<PlaylistMetadataEntry> localPlaylists,
final List<PlaylistRemoteEntity> remotePlaylists) {
return Stream.concat(localPlaylists.stream(), remotePlaylists.stream())
.sorted(Comparator.comparing(PlaylistLocalItem::getOrderingName,
Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)))
.collect(Collectors.toList());
}
long getDisplayIndex();
long getUid();
void setDisplayIndex(long displayIndex);
}

View file

@ -2,27 +2,40 @@ package org.schabi.newpipe.database.playlist;
import androidx.room.ColumnInfo;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_DISPLAY_INDEX;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL;
public class PlaylistMetadataEntry implements PlaylistLocalItem {
public static final String PLAYLIST_STREAM_COUNT = "streamCount";
@ColumnInfo(name = PLAYLIST_ID)
public final long uid;
private final long uid;
@ColumnInfo(name = PLAYLIST_NAME)
public final String name;
@ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT)
private final boolean isThumbnailPermanent;
@ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID)
private final long thumbnailStreamId;
@ColumnInfo(name = PLAYLIST_THUMBNAIL_URL)
public final String thumbnailUrl;
@ColumnInfo(name = PLAYLIST_DISPLAY_INDEX)
private long displayIndex;
@ColumnInfo(name = PLAYLIST_STREAM_COUNT)
public final long streamCount;
public PlaylistMetadataEntry(final long uid, final String name, final String thumbnailUrl,
final long streamCount) {
final boolean isThumbnailPermanent, final long thumbnailStreamId,
final long displayIndex, final long streamCount) {
this.uid = uid;
this.name = name;
this.thumbnailUrl = thumbnailUrl;
this.isThumbnailPermanent = isThumbnailPermanent;
this.thumbnailStreamId = thumbnailStreamId;
this.displayIndex = displayIndex;
this.streamCount = streamCount;
}
@ -35,4 +48,27 @@ public class PlaylistMetadataEntry implements PlaylistLocalItem {
public String getOrderingName() {
return name;
}
public boolean isThumbnailPermanent() {
return isThumbnailPermanent;
}
public long getThumbnailStreamId() {
return thumbnailStreamId;
}
@Override
public long getDisplayIndex() {
return displayIndex;
}
@Override
public long getUid() {
return uid;
}
@Override
public void setDisplayIndex(final long displayIndex) {
this.displayIndex = displayIndex;
}
}

View file

@ -2,6 +2,7 @@ package org.schabi.newpipe.database.playlist.dao;
import androidx.room.Dao;
import androidx.room.Query;
import androidx.room.Transaction;
import org.schabi.newpipe.database.BasicDAO;
import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
@ -36,4 +37,17 @@ public interface PlaylistDAO extends BasicDAO<PlaylistEntity> {
@Query("SELECT COUNT(*) FROM " + PLAYLIST_TABLE)
Flowable<Long> getCount();
@Transaction
default long upsertPlaylist(final PlaylistEntity playlist) {
final long playlistId = playlist.getUid();
if (playlistId == -1) {
// This situation is probably impossible.
return insert(playlist);
} else {
update(playlist);
return playlistId;
}
}
}

View file

@ -11,6 +11,7 @@ import java.util.List;
import io.reactivex.rxjava3.core.Flowable;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_DISPLAY_INDEX;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE;
@ -31,10 +32,18 @@ public interface PlaylistRemoteDAO extends BasicDAO<PlaylistRemoteEntity> {
+ " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
Flowable<List<PlaylistRemoteEntity>> listByService(int serviceId);
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
+ REMOTE_PLAYLIST_ID + " = :playlistId")
Flowable<List<PlaylistRemoteEntity>> getPlaylist(long playlistId);
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
+ REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
Flowable<List<PlaylistRemoteEntity>> getPlaylist(long serviceId, String url);
@Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE
+ " ORDER BY " + REMOTE_PLAYLIST_DISPLAY_INDEX)
Flowable<List<PlaylistRemoteEntity>> getPlaylists();
@Query("SELECT " + REMOTE_PLAYLIST_ID + " FROM " + REMOTE_PLAYLIST_TABLE
+ " WHERE " + REMOTE_PLAYLIST_URL + " = :url "
+ "AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")

View file

@ -18,10 +18,12 @@ 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_DISPLAY_INDEX;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.DEFAULT_THUMBNAIL;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL;
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_INDEX;
@ -91,7 +93,9 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
Flowable<List<PlaylistStreamEntry>> getOrderedStreamsOf(long playlistId);
@Transaction
@Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ","
@Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", "
+ PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", "
+ PLAYLIST_DISPLAY_INDEX + ", "
+ " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = "
+ PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'"
@ -105,7 +109,7 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
+ " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID
+ " GROUP BY " + PLAYLIST_ID
+ " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC")
+ " ORDER BY " + PLAYLIST_DISPLAY_INDEX)
Flowable<List<PlaylistMetadataEntry>> getPlaylistMetadata();
@RewriteQueriesToDropUnusedColumns
@ -126,8 +130,9 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
Flowable<List<PlaylistStreamEntry>> getStreamsWithoutDuplicates(long playlistId);
@Transaction
@Query("SELECT " + PLAYLIST_TABLE + "." + PLAYLIST_ID + ", "
+ PLAYLIST_NAME + ", "
@Query("SELECT " + PLAYLIST_TABLE + "." + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", "
+ PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", "
+ PLAYLIST_DISPLAY_INDEX + ", "
+ " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = "
+ PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'"
@ -149,6 +154,6 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
+ " AND :streamUrl = :streamUrl"
+ " GROUP BY " + JOIN_PLAYLIST_ID
+ " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC")
+ " ORDER BY " + PLAYLIST_DISPLAY_INDEX)
Flowable<List<PlaylistDuplicatesEntry>> getPlaylistDuplicatesMetadata(String streamUrl);
}

View file

@ -2,16 +2,15 @@ package org.schabi.newpipe.database.playlist.model;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Index;
import androidx.room.Ignore;
import androidx.room.PrimaryKey;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME;
import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
@Entity(tableName = PLAYLIST_TABLE,
indices = {@Index(value = {PLAYLIST_NAME})})
@Entity(tableName = PLAYLIST_TABLE)
public class PlaylistEntity {
public static final String DEFAULT_THUMBNAIL = "drawable://"
@ -22,6 +21,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_DISPLAY_INDEX = "display_index";
public static final String PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent";
public static final String PLAYLIST_THUMBNAIL_STREAM_ID = "thumbnail_stream_id";
@ -38,11 +38,24 @@ public class PlaylistEntity {
@ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID)
private long thumbnailStreamId;
@ColumnInfo(name = PLAYLIST_DISPLAY_INDEX)
private long displayIndex;
public PlaylistEntity(final String name, final boolean isThumbnailPermanent,
final long thumbnailStreamId) {
final long thumbnailStreamId, final long displayIndex) {
this.name = name;
this.isThumbnailPermanent = isThumbnailPermanent;
this.thumbnailStreamId = thumbnailStreamId;
this.displayIndex = displayIndex;
}
@Ignore
public PlaylistEntity(final PlaylistMetadataEntry item) {
this.uid = item.getUid();
this.name = item.name;
this.isThumbnailPermanent = item.isThumbnailPermanent();
this.thumbnailStreamId = item.getThumbnailStreamId();
this.displayIndex = item.getDisplayIndex();
}
public long getUid() {
@ -77,4 +90,11 @@ public class PlaylistEntity {
this.isThumbnailPermanent = isThumbnailSet;
}
public long getDisplayIndex() {
return displayIndex;
}
public void setDisplayIndex(final long displayIndex) {
this.displayIndex = displayIndex;
}
}

View file

@ -21,7 +21,6 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.RE
@Entity(tableName = REMOTE_PLAYLIST_TABLE,
indices = {
@Index(value = {REMOTE_PLAYLIST_NAME}),
@Index(value = {REMOTE_PLAYLIST_SERVICE_ID, REMOTE_PLAYLIST_URL}, unique = true)
})
public class PlaylistRemoteEntity implements PlaylistLocalItem {
@ -32,6 +31,7 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
public static final String REMOTE_PLAYLIST_URL = "url";
public static final String REMOTE_PLAYLIST_THUMBNAIL_URL = "thumbnail_url";
public static final String REMOTE_PLAYLIST_UPLOADER_NAME = "uploader";
public static final String REMOTE_PLAYLIST_DISPLAY_INDEX = "display_index";
public static final String REMOTE_PLAYLIST_STREAM_COUNT = "stream_count";
@PrimaryKey(autoGenerate = true)
@ -53,6 +53,9 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
@ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME)
private String uploader;
@ColumnInfo(name = REMOTE_PLAYLIST_DISPLAY_INDEX)
private long displayIndex = -1; // Make sure the new item is on the top
@ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT)
private Long streamCount;
@ -67,6 +70,19 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
this.streamCount = streamCount;
}
@Ignore
public PlaylistRemoteEntity(final int serviceId, final String name, final String url,
final String thumbnailUrl, final String uploader,
final long displayIndex, final Long streamCount) {
this.serviceId = serviceId;
this.name = name;
this.url = url;
this.thumbnailUrl = thumbnailUrl;
this.uploader = uploader;
this.displayIndex = displayIndex;
this.streamCount = streamCount;
}
@Ignore
public PlaylistRemoteEntity(final PlaylistInfo info) {
this(info.getServiceId(), info.getName(), info.getUrl(),
@ -93,6 +109,7 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
&& TextUtils.equals(getUploader(), info.getUploaderName());
}
@Override
public long getUid() {
return uid;
}
@ -141,6 +158,16 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
this.uploader = uploader;
}
@Override
public long getDisplayIndex() {
return displayIndex;
}
@Override
public void setDisplayIndex(final long displayIndex) {
this.displayIndex = displayIndex;
}
public Long getStreamCount() {
return streamCount;
}

View file

@ -1,6 +1,7 @@
package org.schabi.newpipe.database.subscription;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.room.ColumnInfo;
import androidx.room.Entity;
import androidx.room.Ignore;
@ -95,11 +96,12 @@ public class SubscriptionEntity {
this.name = name;
}
@Nullable
public String getAvatarUrl() {
return avatarUrl;
}
public void setAvatarUrl(final String avatarUrl) {
public void setAvatarUrl(@Nullable final String avatarUrl) {
this.avatarUrl = avatarUrl;
}

View file

@ -8,8 +8,6 @@ import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.app.Activity;
import android.content.ComponentName;
import android.content.Context;
import android.content.DialogInterface;
import android.content.DialogInterface.OnDismissListener;
import android.content.Intent;
import android.content.ServiceConnection;
import android.content.SharedPreferences;
@ -17,6 +15,7 @@ import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.os.IBinder;
import android.provider.Settings;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
@ -118,14 +117,11 @@ public class DownloadDialog extends DialogFragment
@State
int selectedSubtitleIndex = 0; // default to the first item
@Nullable
private OnDismissListener onDismissListener = null;
private StoredDirectoryHelper mainStorageAudio = null;
private StoredDirectoryHelper mainStorageVideo = null;
private DownloadManager downloadManager = null;
private ActionMenuItemView okButton = null;
private Context context;
private Context context = null;
private boolean askForSavePath;
private AudioTrackAdapter audioTrackAdapter;
@ -155,7 +151,6 @@ public class DownloadDialog extends DialogFragment
registerForActivityResult(
new StartActivityForResult(), this::requestDownloadPickVideoFolderResult);
/*//////////////////////////////////////////////////////////////////////////
// Instance creation
//////////////////////////////////////////////////////////////////////////*/
@ -207,13 +202,6 @@ public class DownloadDialog extends DialogFragment
this.segments = seg;
}
/**
* @param onDismissListener the listener to call in {@link #onDismiss(DialogInterface)}
*/
public void setOnDismissListener(@Nullable final OnDismissListener onDismissListener) {
this.onDismissListener = onDismissListener;
}
/*//////////////////////////////////////////////////////////////////////////
// Android lifecycle
@ -233,6 +221,8 @@ public class DownloadDialog extends DialogFragment
return;
}
// context will remain null if dismiss() was called above, allowing to check whether the
// dialog is being dismissed in onViewCreated()
context = getContext();
setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(context));
@ -317,6 +307,9 @@ public class DownloadDialog extends DialogFragment
@Nullable final Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
dialogBinding = DownloadDialogBinding.bind(view);
if (context == null) {
return; // the dialog is being dismissed, see the call to dismiss() in onCreate()
}
dialogBinding.fileName.setText(FilenameUtils.createFilename(getContext(),
currentInfo.getName()));
@ -378,14 +371,6 @@ public class DownloadDialog extends DialogFragment
});
}
@Override
public void onDismiss(@NonNull final DialogInterface dialog) {
super.onDismiss(dialog);
if (onDismissListener != null) {
onDismissListener.onDismiss(dialog);
}
}
@Override
public void onDestroy() {
super.onDestroy();
@ -579,7 +564,6 @@ public class DownloadDialog extends DialogFragment
}
}
/*//////////////////////////////////////////////////////////////////////////
// Listeners
//////////////////////////////////////////////////////////////////////////*/
@ -798,6 +782,7 @@ public class DownloadDialog extends DialogFragment
final StoredDirectoryHelper mainStorage;
final MediaFormat format;
final String selectedMediaType;
final long size;
// first, build the filename and get the output folder (if possible)
// later, run a very very very large file checking logic
@ -809,6 +794,7 @@ public class DownloadDialog extends DialogFragment
selectedMediaType = getString(R.string.last_download_type_audio_key);
mainStorage = mainStorageAudio;
format = audioStreamsAdapter.getItem(selectedAudioIndex).getFormat();
size = getWrappedAudioStreams().getSizeInBytes(selectedAudioIndex);
if (format == MediaFormat.WEBMA_OPUS) {
mimeTmp = "audio/ogg";
filenameTmp += "opus";
@ -821,6 +807,7 @@ public class DownloadDialog extends DialogFragment
selectedMediaType = getString(R.string.last_download_type_video_key);
mainStorage = mainStorageVideo;
format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat();
size = wrappedVideoStreams.getSizeInBytes(selectedVideoIndex);
if (format != null) {
mimeTmp = format.mimeType;
filenameTmp += format.getSuffix();
@ -830,6 +817,7 @@ public class DownloadDialog extends DialogFragment
selectedMediaType = getString(R.string.last_download_type_subtitle_key);
mainStorage = mainStorageVideo; // subtitle & video files go together
format = subtitleStreamsAdapter.getItem(selectedSubtitleIndex).getFormat();
size = wrappedSubtitleStreams.getSizeInBytes(selectedSubtitleIndex);
if (format != null) {
mimeTmp = format.mimeType;
}
@ -885,6 +873,21 @@ public class DownloadDialog extends DialogFragment
return;
}
// Check for free storage space
final long freeSpace = mainStorage.getFreeStorageSpace();
if (freeSpace <= size) {
Toast.makeText(context, getString(R.
string.error_insufficient_storage), Toast.LENGTH_LONG).show();
// move the user to storage setting tab
final Intent storageSettingsIntent = new Intent(Settings.
ACTION_INTERNAL_STORAGE_SETTINGS);
if (storageSettingsIntent.resolveActivity(context.getPackageManager())
!= null) {
startActivity(storageSettingsIntent);
}
return;
}
// check for existing file with the same name
checkSelectedDownload(mainStorage, mainStorage.findFile(filenameTmp), filenameTmp,
mimeTmp);
@ -1149,7 +1152,8 @@ public class DownloadDialog extends DialogFragment
}
DownloadManagerService.startMission(context, urls, storage, kind, threads,
currentInfo.getUrl(), psName, psArgs, nearLength, new ArrayList<>(recoveryInfo), segments);
currentInfo.getUrl(), psName, psArgs, nearLength,
new ArrayList<>(recoveryInfo), segments);
Toast.makeText(context, getString(R.string.download_has_started),
Toast.LENGTH_SHORT).show();

View file

@ -6,6 +6,7 @@ package org.schabi.newpipe.error;
public enum UserAction {
USER_REPORT("user report"),
UI_ERROR("ui error"),
DATABASE_IMPORT_EXPORT("database import or export"),
SUBSCRIPTION_CHANGE("subscription change"),
SUBSCRIPTION_UPDATE("subscription update"),
SUBSCRIPTION_GET("get subscription"),

View file

@ -220,7 +220,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
public void commitPlaylistTabs() {
pagerAdapter.getLocalPlaylistFragments()
.stream()
.forEach(LocalPlaylistFragment::commitChanges);
.forEach(LocalPlaylistFragment::saveImmediate);
}
private void updateTabLayoutPosition() {
@ -282,7 +282,7 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
* Keep reference to LocalPlaylistFragments, because their data can be modified by the user
* during runtime and changes are not committed immediately. However, in some cases,
* the changes need to be committed immediately by calling
* {@link LocalPlaylistFragment#commitChanges()}.
* {@link LocalPlaylistFragment#saveImmediate()}.
* The fragments are removed when {@link LocalPlaylistFragment#onDestroy()} is called.
*/
private final List<LocalPlaylistFragment> localPlaylistFragments = new ArrayList<>();

View file

@ -64,7 +64,7 @@ public abstract class BaseDescriptionFragment extends BaseFragment {
/**
* Get the description to display.
* @return description object
* @return description object, if available
*/
@Nullable
protected abstract Description getDescription();
@ -73,7 +73,7 @@ public abstract class BaseDescriptionFragment extends BaseFragment {
* Get the streaming service. Used for generating description links.
* @return streaming service
*/
@Nullable
@NonNull
protected abstract StreamingService getService();
/**
@ -93,7 +93,7 @@ public abstract class BaseDescriptionFragment extends BaseFragment {
* Get the list of tags to display below the description.
* @return tag list
*/
@Nullable
@NonNull
public abstract List<String> getTags();
/**
@ -158,7 +158,7 @@ public abstract class BaseDescriptionFragment extends BaseFragment {
final LinearLayout layout,
final boolean linkifyContent,
@StringRes final int type,
@Nullable final String content) {
@NonNull final String content) {
if (isBlank(content)) {
return;
}
@ -221,16 +221,12 @@ public abstract class BaseDescriptionFragment extends BaseFragment {
urls.append(imageSizeToText(image.getWidth()));
} else {
switch (image.getEstimatedResolutionLevel()) {
case LOW:
urls.append(getString(R.string.image_quality_low));
break;
default: // unreachable, Image.ResolutionLevel.UNKNOWN is already filtered out
case MEDIUM:
urls.append(getString(R.string.image_quality_medium));
break;
case HIGH:
urls.append(getString(R.string.image_quality_high));
break;
case LOW -> urls.append(getString(R.string.image_quality_low));
case MEDIUM -> urls.append(getString(R.string.image_quality_medium));
case HIGH -> urls.append(getString(R.string.image_quality_high));
default -> {
// unreachable, Image.ResolutionLevel.UNKNOWN is already filtered out
}
}
}
@ -255,7 +251,7 @@ public abstract class BaseDescriptionFragment extends BaseFragment {
private void addTagsMetadataItem(final LayoutInflater inflater, final LinearLayout layout) {
final List<String> tags = getTags();
if (tags != null && !tags.isEmpty()) {
if (!tags.isEmpty()) {
final var itemBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false);
tags.stream().sorted(String.CASE_INSENSITIVE_ORDER).forEach(tag -> {

View file

@ -7,6 +7,7 @@ import android.view.LayoutInflater;
import android.view.View;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
@ -23,56 +24,43 @@ import icepick.State;
public class DescriptionFragment extends BaseDescriptionFragment {
@State
StreamInfo streamInfo = null;
public DescriptionFragment() {
}
StreamInfo streamInfo;
public DescriptionFragment(final StreamInfo streamInfo) {
this.streamInfo = streamInfo;
}
@Nullable
@Override
protected Description getDescription() {
if (streamInfo == null) {
return null;
}
return streamInfo.getDescription();
public DescriptionFragment() {
// keep empty constructor for IcePick when resuming fragment from memory
}
@Nullable
@Override
protected Description getDescription() {
return streamInfo.getDescription();
}
@NonNull
@Override
protected StreamingService getService() {
if (streamInfo == null) {
return null;
}
return streamInfo.getService();
}
@Override
protected int getServiceId() {
if (streamInfo == null) {
return -1;
}
return streamInfo.getServiceId();
}
@Nullable
@NonNull
@Override
protected String getStreamUrl() {
if (streamInfo == null) {
return null;
}
return streamInfo.getUrl();
}
@Nullable
@NonNull
@Override
public List<String> getTags() {
if (streamInfo == null) {
return null;
}
return streamInfo.getTags();
}

View file

@ -72,7 +72,6 @@ import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.ReCaptchaActivity;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.Image;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
@ -109,15 +108,16 @@ import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.ReturnYouTubeDislikeUtils;
import org.schabi.newpipe.util.external_communication.KoreUtils;
import org.schabi.newpipe.util.InfoCache;
import org.schabi.newpipe.util.ListHelper;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PermissionHelper;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.StreamTypeUtil;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.util.PlayButtonHelper;
import org.schabi.newpipe.util.image.PicassoHelper;
import java.util.ArrayList;
import java.util.Iterator;
@ -483,7 +483,7 @@ public final class VideoDetailFragment
// commit previous pending changes to database
if (fragment instanceof LocalPlaylistFragment) {
((LocalPlaylistFragment) fragment).commitChanges();
((LocalPlaylistFragment) fragment).saveImmediate();
} else if (fragment instanceof MainFragment) {
((MainFragment) fragment).commitPlaylistTabs();
}
@ -1446,7 +1446,7 @@ public final class VideoDetailFragment
super.showLoading();
//if data is already cached, transition from VISIBLE -> INVISIBLE -> VISIBLE is not required
if (!ExtractorHelper.isCached(serviceId, url, InfoItem.InfoType.STREAM)) {
if (!ExtractorHelper.isCached(serviceId, url, InfoCache.Type.STREAM)) {
binding.detailContentRootHiding.setVisibility(View.INVISIBLE);
}

View file

@ -2,12 +2,12 @@ package org.schabi.newpipe.fragments.list.channel;
import static org.schabi.newpipe.extractor.stream.StreamExtractor.UNKNOWN_SUBSCRIBER_COUNT;
import android.content.Context;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.schabi.newpipe.R;
@ -26,14 +26,12 @@ public class ChannelAboutFragment extends BaseDescriptionFragment {
@State
protected ChannelInfo channelInfo;
public static ChannelAboutFragment getInstance(final ChannelInfo channelInfo) {
final ChannelAboutFragment fragment = new ChannelAboutFragment();
fragment.channelInfo = channelInfo;
return fragment;
ChannelAboutFragment(@NonNull final ChannelInfo channelInfo) {
this.channelInfo = channelInfo;
}
public ChannelAboutFragment() {
super();
// keep empty constructor for IcePick when resuming fragment from memory
}
@Override
@ -45,26 +43,17 @@ public class ChannelAboutFragment extends BaseDescriptionFragment {
@Nullable
@Override
protected Description getDescription() {
if (channelInfo == null) {
return null;
}
return new Description(channelInfo.getDescription(), Description.PLAIN_TEXT);
}
@Nullable
@NonNull
@Override
protected StreamingService getService() {
if (channelInfo == null) {
return null;
}
return channelInfo.getService();
}
@Override
protected int getServiceId() {
if (channelInfo == null) {
return -1;
}
return channelInfo.getServiceId();
}
@ -74,12 +63,9 @@ public class ChannelAboutFragment extends BaseDescriptionFragment {
return null;
}
@Nullable
@NonNull
@Override
public List<String> getTags() {
if (channelInfo == null) {
return null;
}
return channelInfo.getTags();
}
@ -93,10 +79,11 @@ public class ChannelAboutFragment extends BaseDescriptionFragment {
return;
}
final Context context = getContext();
if (channelInfo.getSubscriberCount() != UNKNOWN_SUBSCRIBER_COUNT) {
addMetadataItem(inflater, layout, false, R.string.metadata_subscribers,
Localization.localizeNumber(context, channelInfo.getSubscriberCount()));
Localization.localizeNumber(
requireContext(),
channelInfo.getSubscriberCount()));
}
addImagesMetadataItem(inflater, layout, R.string.metadata_avatars,

View file

@ -474,7 +474,7 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
if (ChannelTabHelper.showChannelTab(
context, preferences, R.string.show_channel_tabs_about)) {
tabAdapter.addFragment(
ChannelAboutFragment.getInstance(currentInfo),
new ChannelAboutFragment(currentInfo),
context.getString(R.string.channel_tab_about));
}
}

View file

@ -30,6 +30,7 @@ import org.schabi.newpipe.util.text.TextLinkifier;
import java.util.Queue;
import java.util.function.Supplier;
import icepick.State;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
@ -38,7 +39,8 @@ public final class CommentRepliesFragment
public static final String TAG = CommentRepliesFragment.class.getSimpleName();
private CommentsInfoItem commentsInfoItem; // the comment to show replies of
@State
CommentsInfoItem commentsInfoItem; // the comment to show replies of
private final CompositeDisposable disposables = new CompositeDisposable();

View file

@ -1,7 +1,9 @@
package org.schabi.newpipe.fragments.list.playlist;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
import android.content.Context;
import android.os.Bundle;
@ -37,6 +39,7 @@ import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.extractor.services.youtube.YoutubeParsingHelper;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
@ -48,9 +51,10 @@ import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.PlayButtonHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.text.TextEllipsizer;
import java.util.ArrayList;
import java.util.List;
@ -85,6 +89,9 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
private MenuItem playlistBookmarkButton;
private long streamCount;
private long playlistOverallDurationSeconds;
public static PlaylistFragment getInstance(final int serviceId, final String url,
final String name) {
final PlaylistFragment instance = new PlaylistFragment();
@ -273,6 +280,12 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
animate(headerBinding.uploaderLayout, false, 200);
}
@Override
public void handleNextItems(final ListExtractor.InfoItemsPage result) {
super.handleNextItems(result);
setStreamCountAndOverallDuration(result.getItems(), !result.hasNextPage());
}
@Override
public void handleResult(@NonNull final PlaylistInfo result) {
super.handleResult(result);
@ -318,8 +331,31 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
.into(headerBinding.uploaderAvatarView);
}
headerBinding.playlistStreamCount.setText(Localization
.localizeStreamCount(getContext(), result.getStreamCount()));
streamCount = result.getStreamCount();
setStreamCountAndOverallDuration(result.getRelatedItems(), !result.hasNextPage());
final Description description = result.getDescription();
if (description != null && description != Description.EMPTY_DESCRIPTION
&& !isBlank(description.getContent())) {
final TextEllipsizer ellipsizer = new TextEllipsizer(
headerBinding.playlistDescription, 5, getServiceById(result.getServiceId()));
ellipsizer.setStateChangeListener(isEllipsized ->
headerBinding.playlistDescriptionReadMore.setText(
Boolean.TRUE.equals(isEllipsized) ? R.string.show_more : R.string.show_less
));
ellipsizer.setOnContentChanged(canBeEllipsized -> {
headerBinding.playlistDescriptionReadMore.setVisibility(
Boolean.TRUE.equals(canBeEllipsized) ? View.VISIBLE : View.GONE);
if (Boolean.TRUE.equals(canBeEllipsized)) {
ellipsizer.ellipsize();
}
});
ellipsizer.setContent(description);
headerBinding.playlistDescriptionReadMore.setOnClickListener(v -> ellipsizer.toggle());
} else {
headerBinding.playlistDescription.setVisibility(View.GONE);
headerBinding.playlistDescriptionReadMore.setVisibility(View.GONE);
}
if (!result.getErrors().isEmpty()) {
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.REQUESTED_PLAYLIST,
@ -459,4 +495,20 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
playlistBookmarkButton.setIcon(drawable);
playlistBookmarkButton.setTitle(titleRes);
}
private void setStreamCountAndOverallDuration(final List<StreamInfoItem> list,
final boolean isDurationComplete) {
if (activity != null && headerBinding != null) {
playlistOverallDurationSeconds += list.stream()
.mapToLong(x -> x.getDuration())
.sum();
headerBinding.playlistStreamCount.setText(
Localization.concatenateStrings(
Localization.localizeStreamCount(activity, streamCount),
Localization.getDurationString(playlistOverallDurationSeconds,
isDurationComplete, true))
);
}
}
}

View file

@ -1,6 +1,7 @@
package org.schabi.newpipe.fragments.list.search;
import static androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
import static java.util.Arrays.asList;
@ -389,7 +390,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
@Override
public void onSaveInstanceState(@NonNull final Bundle bundle) {
searchString = searchEditText != null
? searchEditText.getText().toString()
? getSearchEditString().trim()
: searchString;
super.onSaveInstanceState(bundle);
}
@ -400,11 +401,11 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
@Override
public void reloadContent() {
if (!TextUtils.isEmpty(searchString)
|| (searchEditText != null && !TextUtils.isEmpty(searchEditText.getText()))) {
if (!TextUtils.isEmpty(searchString) || (searchEditText != null
&& !isSearchEditBlank())) {
search(!TextUtils.isEmpty(searchString)
? searchString
: searchEditText.getText().toString(), this.contentFilter, "");
: getSearchEditString(), this.contentFilter, "");
} else {
if (searchEditText != null) {
searchEditText.setText("");
@ -498,7 +499,8 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
}
searchEditText.setText(searchString);
if (TextUtils.isEmpty(searchString) || TextUtils.isEmpty(searchEditText.getText())) {
if (TextUtils.isEmpty(searchString)
|| isSearchEditBlank()) {
searchToolbarContainer.setTranslationX(100);
searchToolbarContainer.setAlpha(0.0f);
searchToolbarContainer.setVisibility(View.VISIBLE);
@ -522,7 +524,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
if (DEBUG) {
Log.d(TAG, "onClick() called with: v = [" + v + "]");
}
if (TextUtils.isEmpty(searchEditText.getText())) {
if (isSearchEditBlank()) {
NavigationHelper.gotoMainFragment(getFM());
return;
}
@ -603,7 +605,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
s.removeSpan(span);
}
final String newText = searchEditText.getText().toString();
final String newText = getSearchEditString().trim();
suggestionPublisher.onNext(newText);
}
};
@ -619,7 +621,8 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
} else if (event != null
&& (event.getKeyCode() == KeyEvent.KEYCODE_ENTER
|| event.getAction() == EditorInfo.IME_ACTION_SEARCH)) {
search(searchEditText.getText().toString(), new String[0], "");
searchEditText.setText(getSearchEditString().trim());
search(getSearchEditString(), new String[0], "");
return true;
}
return false;
@ -694,7 +697,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
howManyDeleted -> suggestionPublisher
.onNext(searchEditText.getText().toString()),
.onNext(getSearchEditString()),
throwable -> showSnackBarError(new ErrorInfo(throwable,
UserAction.DELETE_FROM_HISTORY,
"Deleting item failed")));
@ -723,9 +726,9 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
.getRelatedSearches(query, similarQueryLimit, 25)
.toObservable()
.map(searchHistoryEntries ->
searchHistoryEntries.stream()
.map(entry -> new SuggestionItem(true, entry))
.collect(Collectors.toList()));
searchHistoryEntries.stream()
.map(entry -> new SuggestionItem(true, entry))
.collect(Collectors.toList()));
}
private Observable<List<SuggestionItem>> getRemoteSuggestionsObservable(final String query) {
@ -792,12 +795,12 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
} else if (listNotification.isOnError()
&& listNotification.getError() != null
&& !ExceptionUtils.isInterruptedCaused(
listNotification.getError())) {
listNotification.getError())) {
showSnackBarError(new ErrorInfo(listNotification.getError(),
UserAction.GET_SUGGESTIONS, searchString, serviceId));
}
}, throwable -> showSnackBarError(new ErrorInfo(
throwable, UserAction.GET_SUGGESTIONS, searchString, serviceId)));
throwable, UserAction.GET_SUGGESTIONS, searchString, serviceId)));
}
@Override
@ -805,7 +808,13 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
// no-op
}
private void search(final String theSearchString,
/**
* Perform a search.
* @param theSearchString the trimmed search string
* @param theContentFilter the content filter to use. FIXME: unused param
* @param theSortFilter FIXME: unused param
*/
private void search(@NonNull final String theSearchString,
final String[] theContentFilter,
final String theSortFilter) {
if (DEBUG) {
@ -815,25 +824,26 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
return;
}
// Check if theSearchString is a URL which can be opened by NewPipe directly
// and open it if possible.
try {
final StreamingService streamingService = NewPipe.getServiceByUrl(theSearchString);
if (streamingService != null) {
showLoading();
disposables.add(Observable
.fromCallable(() -> NavigationHelper.getIntentByLink(activity,
streamingService, theSearchString))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(intent -> {
getFM().popBackStackImmediate();
activity.startActivity(intent);
}, throwable -> showTextError(getString(R.string.unsupported_url))));
return;
}
showLoading();
disposables.add(Observable
.fromCallable(() -> NavigationHelper.getIntentByLink(activity,
streamingService, theSearchString))
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(intent -> {
getFM().popBackStackImmediate();
activity.startActivity(intent);
}, throwable -> showTextError(getString(R.string.unsupported_url))));
return;
} catch (final Exception ignored) {
// Exception occurred, it's not a url
}
// prepare search
lastSearchedString = this.searchString;
this.searchString = theSearchString;
infoListAdapter.clearStreamItemList();
@ -842,13 +852,17 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
searchBinding.searchMetaInfoSeparator, disposables);
hideKeyboardSearch();
// store search query if search history is enabled
disposables.add(historyRecordManager.onSearched(serviceId, theSearchString)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
ignored -> { },
ignored -> {
},
throwable -> showSnackBarError(new ErrorInfo(throwable, UserAction.SEARCHED,
theSearchString, serviceId))
));
// load search results
suggestionPublisher.onNext(theSearchString);
startLoading(false);
}
@ -938,6 +952,14 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
sortFilter = theSortFilter;
}
private String getSearchEditString() {
return searchEditText.getText().toString();
}
private boolean isSearchEditBlank() {
return isBlank(getSearchEditString());
}
/*//////////////////////////////////////////////////////////////////////////
// Suggestion Results
//////////////////////////////////////////////////////////////////////////*/
@ -979,6 +1001,9 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
}
searchSuggestion = result.getSearchSuggestion();
if (searchSuggestion != null) {
searchSuggestion = searchSuggestion.trim();
}
isCorrectedSearch = result.isCorrectedSearch();
// List<MetaInfo> cannot be bundled without creating some containers
@ -1080,7 +1105,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
howManyDeleted -> suggestionPublisher
.onNext(searchEditText.getText().toString()),
.onNext(getSearchEditString()),
throwable -> showSnackBarError(new ErrorInfo(throwable,
UserAction.DELETE_FROM_HISTORY, "Deleting item failed")));
disposables.add(onDelete);

View file

@ -1,10 +1,7 @@
package org.schabi.newpipe.info_list.holder;
import static android.text.TextUtils.isEmpty;
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
import android.graphics.Paint;
import android.text.Layout;
import android.text.method.LinkMovementMethod;
import android.text.style.URLSpan;
import android.view.View;
@ -15,42 +12,28 @@ import android.widget.RelativeLayout;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.text.HtmlCompat;
import androidx.fragment.app.FragmentActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
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.DeviceUtils;
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.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.text.CommentTextOnTouchListener;
import org.schabi.newpipe.util.text.TextLinkifier;
import java.util.function.Consumer;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import org.schabi.newpipe.util.text.TextEllipsizer;
public class CommentInfoItemHolder extends InfoItemHolder {
private static final String ELLIPSIS = "";
private static final int COMMENT_DEFAULT_LINES = 2;
private static final int COMMENT_EXPANDED_LINES = 1000;
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;
@ -61,13 +44,8 @@ public class CommentInfoItemHolder extends InfoItemHolder {
private final ImageView itemPinnedView;
private final Button repliesButton;
private final CompositeDisposable disposables = new CompositeDisposable();
@Nullable
private Description commentText;
@Nullable
private StreamingService streamService;
@Nullable
private String streamUrl;
@NonNull
private final TextEllipsizer textEllipsizer;
public CommentInfoItemHolder(final InfoItemBuilder infoItemBuilder,
final ViewGroup parent) {
@ -88,9 +66,14 @@ public class CommentInfoItemHolder extends InfoItemHolder {
commentVerticalPadding = (int) infoItemBuilder.getContext()
.getResources().getDimension(R.dimen.comments_vertical_padding);
paintAtContentSize = new Paint();
paintAtContentSize.setTextSize(itemContentView.getTextSize());
ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS);
textEllipsizer = new TextEllipsizer(itemContentView, COMMENT_DEFAULT_LINES, null);
textEllipsizer.setStateChangeListener(isEllipsized -> {
if (Boolean.TRUE.equals(isEllipsized)) {
denyLinkFocus();
} else {
determineMovementMethod();
}
});
}
@Override
@ -139,16 +122,16 @@ public class CommentInfoItemHolder extends InfoItemHolder {
// setup comment content and click listeners to expand/ellipsize it
streamService = getServiceById(item.getServiceId());
streamUrl = item.getUrl();
commentText = item.getCommentText();
ellipsize();
textEllipsizer.setStreamingService(getServiceById(item.getServiceId()));
textEllipsizer.setStreamUrl(item.getUrl());
textEllipsizer.setContent(item.getCommentText());
textEllipsizer.ellipsize();
//noinspection ClickableViewAccessibility
itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE);
itemView.setOnClickListener(view -> {
toggleEllipsize();
textEllipsizer.toggle();
if (itemBuilder.getOnCommentsSelectedListener() != null) {
itemBuilder.getOnCommentsSelectedListener().selected(item);
}
@ -202,76 +185,4 @@ public class CommentInfoItemHolder extends InfoItemHolder {
denyLinkFocus();
}
}
private void ellipsize() {
itemContentView.setMaxLines(COMMENT_EXPANDED_LINES);
linkifyCommentContentView(v -> {
boolean hasEllipsis = false;
final CharSequence charSeqText = itemContentView.getText();
if (charSeqText != null && 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 = charSeqText.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;
}
itemContentView.setMaxLines(COMMENT_DEFAULT_LINES);
if (hasEllipsis) {
denyLinkFocus();
} else {
determineMovementMethod();
}
});
}
private void toggleEllipsize() {
final CharSequence text = itemContentView.getText();
if (!isEmpty(text) && 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);
linkifyCommentContentView(v -> determineMovementMethod());
}
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

@ -14,6 +14,7 @@ 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.LocalBookmarkPlaylistItemHolder;
import org.schabi.newpipe.local.holder.LocalItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistCardItemHolder;
import org.schabi.newpipe.local.holder.LocalPlaylistGridItemHolder;
@ -24,6 +25,7 @@ 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.RemoteBookmarkPlaylistItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistCardItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder;
import org.schabi.newpipe.local.holder.RemotePlaylistItemHolder;
@ -73,10 +75,12 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
private static final int LOCAL_PLAYLIST_HOLDER_TYPE = 0x2000;
private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2001;
private static final int LOCAL_PLAYLIST_CARD_HOLDER_TYPE = 0x2002;
private static final int LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE = 0x2003;
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 static final int REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE = 0x3003;
private final LocalItemBuilder localItemBuilder;
private final ArrayList<LocalItem> localItems;
@ -87,6 +91,7 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
private View header = null;
private View footer = null;
private ItemViewMode itemViewMode = ItemViewMode.LIST;
private boolean useItemHandle = false;
public LocalItemListAdapter(final Context context) {
recordManager = new HistoryRecordManager(context);
@ -180,6 +185,10 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
this.itemViewMode = itemViewMode;
}
public void setUseItemHandle(final boolean useItemHandle) {
this.useItemHandle = useItemHandle;
}
public void setHeader(final View header) {
final boolean changed = header != this.header;
this.header = header;
@ -257,7 +266,9 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
final LocalItem item = localItems.get(position);
switch (item.getLocalItemType()) {
case PLAYLIST_LOCAL_ITEM:
if (itemViewMode == ItemViewMode.CARD) {
if (useItemHandle) {
return LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.CARD) {
return LOCAL_PLAYLIST_CARD_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.GRID) {
return LOCAL_PLAYLIST_GRID_HOLDER_TYPE;
@ -265,7 +276,9 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
return LOCAL_PLAYLIST_HOLDER_TYPE;
}
case PLAYLIST_REMOTE_ITEM:
if (itemViewMode == ItemViewMode.CARD) {
if (useItemHandle) {
return REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.CARD) {
return REMOTE_PLAYLIST_CARD_HOLDER_TYPE;
} else if (itemViewMode == ItemViewMode.GRID) {
return REMOTE_PLAYLIST_GRID_HOLDER_TYPE;
@ -314,12 +327,16 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
return new LocalPlaylistGridItemHolder(localItemBuilder, parent);
case LOCAL_PLAYLIST_CARD_HOLDER_TYPE:
return new LocalPlaylistCardItemHolder(localItemBuilder, parent);
case LOCAL_BOOKMARK_PLAYLIST_HOLDER_TYPE:
return new LocalBookmarkPlaylistItemHolder(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 REMOTE_BOOKMARK_PLAYLIST_HOLDER_TYPE:
return new RemoteBookmarkPlaylistItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_HOLDER_TYPE:
return new LocalPlaylistStreamItemHolder(localItemBuilder, parent);
case STREAM_PLAYLIST_GRID_HOLDER_TYPE:

View file

@ -1,10 +1,13 @@
package org.schabi.newpipe.local.bookmark;
import static org.schabi.newpipe.local.bookmark.MergedPlaylistManager.getMergedOrderedPlaylists;
import android.content.DialogInterface;
import android.os.Bundle;
import android.os.Parcelable;
import android.text.InputType;
import android.util.Log;
import android.util.Pair;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
@ -13,6 +16,8 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.FragmentManager;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
@ -27,29 +32,45 @@ import org.schabi.newpipe.databinding.DialogEditTextBinding;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder;
import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder;
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
import org.schabi.newpipe.util.debounce.DebounceSavable;
import org.schabi.newpipe.util.debounce.DebounceSaver;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import icepick.State;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistLocalItem>, Void> {
public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistLocalItem>, Void>
implements DebounceSavable {
private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12;
@State
protected Parcelable itemsListState;
Parcelable itemsListState;
private Subscription databaseSubscription;
private CompositeDisposable disposables = new CompositeDisposable();
private LocalPlaylistManager localPlaylistManager;
private RemotePlaylistManager remotePlaylistManager;
private ItemTouchHelper itemTouchHelper;
/* Have the bookmarked playlists been fully loaded from db */
private AtomicBoolean isLoadingComplete;
/* Gives enough time to avoid interrupting user sorting operations */
@Nullable
private DebounceSaver debounceSaver;
private List<Pair<Long, LocalItem.LocalItemType>> deletedItems;
///////////////////////////////////////////////////////////////////////////
// Fragment LifeCycle - Creation
@ -65,6 +86,11 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
localPlaylistManager = new LocalPlaylistManager(database);
remotePlaylistManager = new RemotePlaylistManager(database);
disposables = new CompositeDisposable();
isLoadingComplete = new AtomicBoolean();
debounceSaver = new DebounceSaver(3000, this);
deletedItems = new ArrayList<>();
}
@Nullable
@ -91,10 +117,20 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
// Fragment LifeCycle - Views
///////////////////////////////////////////////////////////////////////////
@Override
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
itemListAdapter.setUseItemHandle(true);
}
@Override
protected void initListeners() {
super.initListeners();
itemTouchHelper = new ItemTouchHelper(getItemTouchCallback());
itemTouchHelper.attachToRecyclerView(itemsList);
itemListAdapter.setSelectedListener(new OnClickGesture<>() {
@Override
public void selected(final LocalItem selectedItem) {
@ -102,7 +138,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
if (selectedItem instanceof PlaylistMetadataEntry) {
final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem);
NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.uid,
NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.getUid(),
entry.name);
} else if (selectedItem instanceof PlaylistRemoteEntity) {
@ -123,6 +159,14 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
showRemoteDeleteDialog((PlaylistRemoteEntity) selectedItem);
}
}
@Override
public void drag(final LocalItem selectedItem,
final RecyclerView.ViewHolder viewHolder) {
if (itemTouchHelper != null) {
itemTouchHelper.startDrag(viewHolder);
}
}
});
}
@ -134,8 +178,13 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
public void startLoading(final boolean forceLoad) {
super.startLoading(forceLoad);
Flowable.combineLatest(localPlaylistManager.getPlaylists(),
remotePlaylistManager.getPlaylists(), PlaylistLocalItem::merge)
if (debounceSaver != null) {
disposables.add(debounceSaver.getDebouncedSaver());
debounceSaver.setNoChangesToSave();
}
isLoadingComplete.set(false);
getMergedOrderedPlaylists(localPlaylistManager, remotePlaylistManager)
.onBackpressureLatest()
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getPlaylistsSubscriber());
@ -149,6 +198,9 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
public void onPause() {
super.onPause();
itemsListState = itemsList.getLayoutManager().onSaveInstanceState();
// Save on exit
saveImmediate();
}
@Override
@ -163,19 +215,27 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
}
databaseSubscription = null;
itemTouchHelper = null;
}
@Override
public void onDestroy() {
super.onDestroy();
if (debounceSaver != null) {
debounceSaver.getDebouncedSaveSignal().onComplete();
}
if (disposables != null) {
disposables.dispose();
}
debounceSaver = null;
disposables = null;
localPlaylistManager = null;
remotePlaylistManager = null;
itemsListState = null;
isLoadingComplete = null;
deletedItems = null;
}
///////////////////////////////////////////////////////////////////////////
@ -183,10 +243,12 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
///////////////////////////////////////////////////////////////////////////
private Subscriber<List<PlaylistLocalItem>> getPlaylistsSubscriber() {
return new Subscriber<List<PlaylistLocalItem>>() {
return new Subscriber<>() {
@Override
public void onSubscribe(final Subscription s) {
showLoading();
isLoadingComplete.set(false);
if (databaseSubscription != null) {
databaseSubscription.cancel();
}
@ -196,7 +258,10 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
@Override
public void onNext(final List<PlaylistLocalItem> subscriptions) {
handleResult(subscriptions);
if (debounceSaver == null || !debounceSaver.getIsModified()) {
handleResult(subscriptions);
isLoadingComplete.set(true);
}
if (databaseSubscription != null) {
databaseSubscription.request(1);
}
@ -209,7 +274,8 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
}
@Override
public void onComplete() { }
public void onComplete() {
}
};
}
@ -244,12 +310,183 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
}
}
/*//////////////////////////////////////////////////////////////////////////
// Playlist Metadata Manipulation
//////////////////////////////////////////////////////////////////////////*/
private void changeLocalPlaylistName(final long id, final String name) {
if (localPlaylistManager == null) {
return;
}
if (DEBUG) {
Log.d(TAG, "Updating playlist id=[" + id + "] "
+ "with new name=[" + name + "] items");
}
final Disposable disposable = localPlaylistManager.renamePlaylist(id, name)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(longs -> { /*Do nothing on success*/ }, throwable -> showError(
new ErrorInfo(throwable,
UserAction.REQUESTED_BOOKMARK,
"Changing playlist name")));
disposables.add(disposable);
}
private void deleteItem(final PlaylistLocalItem item) {
if (itemListAdapter == null) {
return;
}
itemListAdapter.removeItem(item);
if (item instanceof PlaylistMetadataEntry) {
deletedItems.add(new Pair<>(item.getUid(),
LocalItem.LocalItemType.PLAYLIST_LOCAL_ITEM));
} else if (item instanceof PlaylistRemoteEntity) {
deletedItems.add(new Pair<>(item.getUid(),
LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM));
}
if (debounceSaver != null) {
debounceSaver.setHasChangesToSave();
saveImmediate();
}
}
@Override
public void saveImmediate() {
if (itemListAdapter == null) {
return;
}
// List must be loaded and modified in order to save
if (isLoadingComplete == null || debounceSaver == null
|| !isLoadingComplete.get() || !debounceSaver.getIsModified()) {
return;
}
final List<LocalItem> items = itemListAdapter.getItemsList();
final List<PlaylistMetadataEntry> localItemsUpdate = new ArrayList<>();
final List<Long> localItemsDeleteUid = new ArrayList<>();
final List<PlaylistRemoteEntity> remoteItemsUpdate = new ArrayList<>();
final List<Long> remoteItemsDeleteUid = new ArrayList<>();
// Calculate display index
for (int i = 0; i < items.size(); i++) {
final LocalItem item = items.get(i);
if (item instanceof PlaylistMetadataEntry
&& ((PlaylistMetadataEntry) item).getDisplayIndex() != i) {
((PlaylistMetadataEntry) item).setDisplayIndex(i);
localItemsUpdate.add((PlaylistMetadataEntry) item);
} else if (item instanceof PlaylistRemoteEntity
&& ((PlaylistRemoteEntity) item).getDisplayIndex() != i) {
((PlaylistRemoteEntity) item).setDisplayIndex(i);
remoteItemsUpdate.add((PlaylistRemoteEntity) item);
}
}
// Find deleted items
for (final Pair<Long, LocalItem.LocalItemType> item : deletedItems) {
if (item.second.equals(LocalItem.LocalItemType.PLAYLIST_LOCAL_ITEM)) {
localItemsDeleteUid.add(item.first);
} else if (item.second.equals(LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM)) {
remoteItemsDeleteUid.add(item.first);
}
}
deletedItems.clear();
// 1. Update local playlists
// 2. Update remote playlists
// 3. Set NoChangesToSave
disposables.add(localPlaylistManager.updatePlaylists(localItemsUpdate, localItemsDeleteUid)
.mergeWith(remotePlaylistManager.updatePlaylists(
remoteItemsUpdate, remoteItemsDeleteUid))
.observeOn(AndroidSchedulers.mainThread())
.subscribe(() -> {
if (debounceSaver != null) {
debounceSaver.setNoChangesToSave();
}
},
throwable -> showError(new ErrorInfo(throwable,
UserAction.REQUESTED_BOOKMARK, "Saving playlist"))
));
}
private ItemTouchHelper.SimpleCallback getItemTouchCallback() {
// if adding grid layout, also include ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT
// with an `if (shouldUseGridLayout()) ...`
return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN,
ItemTouchHelper.ACTION_STATE_IDLE) {
@Override
public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView,
final int viewSize,
final int viewSizeOutOfBounds,
final int totalSize,
final long msSinceStartScroll) {
final int standardSpeed = super.interpolateOutOfBoundsScroll(recyclerView,
viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll);
final int minimumAbsVelocity = Math.max(MINIMUM_INITIAL_DRAG_VELOCITY,
Math.abs(standardSpeed));
return minimumAbsVelocity * (int) Math.signum(viewSizeOutOfBounds);
}
@Override
public boolean onMove(@NonNull final RecyclerView recyclerView,
@NonNull final RecyclerView.ViewHolder source,
@NonNull final RecyclerView.ViewHolder target) {
// Allow swap LocalBookmarkPlaylistItemHolder and RemoteBookmarkPlaylistItemHolder.
if (itemListAdapter == null
|| source.getItemViewType() != target.getItemViewType()
&& !(
(
(source instanceof LocalBookmarkPlaylistItemHolder)
|| (source instanceof RemoteBookmarkPlaylistItemHolder)
)
&& (
(target instanceof LocalBookmarkPlaylistItemHolder)
|| (target instanceof RemoteBookmarkPlaylistItemHolder)
))
) {
return false;
}
final int sourceIndex = source.getBindingAdapterPosition();
final int targetIndex = target.getBindingAdapterPosition();
final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex);
if (isSwapped && debounceSaver != null) {
debounceSaver.setHasChangesToSave();
}
return isSwapped;
}
@Override
public boolean isLongPressDragEnabled() {
return false;
}
@Override
public boolean isItemViewSwipeEnabled() {
return false;
}
@Override
public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder,
final int swipeDir) {
// Do nothing.
}
};
}
///////////////////////////////////////////////////////////////////////////
// Utils
///////////////////////////////////////////////////////////////////////////
private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) {
showDeleteDialog(item.getName(), remotePlaylistManager.deletePlaylist(item.getUid()));
showDeleteDialog(item.getName(), item);
}
private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
@ -257,7 +494,7 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
final String delete = getString(R.string.delete);
final String unsetThumbnail = getString(R.string.unset_playlist_thumbnail);
final boolean isThumbnailPermanent = localPlaylistManager
.getIsPlaylistThumbnailPermanent(selectedItem.uid);
.getIsPlaylistThumbnailPermanent(selectedItem.getUid());
final ArrayList<String> items = new ArrayList<>();
items.add(rename);
@ -270,13 +507,12 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
if (items.get(index).equals(rename)) {
showRenameDialog(selectedItem);
} else if (items.get(index).equals(delete)) {
showDeleteDialog(selectedItem.name,
localPlaylistManager.deletePlaylist(selectedItem.uid));
showDeleteDialog(selectedItem.name, selectedItem);
} else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) {
final long thumbnailStreamId = localPlaylistManager
.getAutomaticPlaylistThumbnailStreamId(selectedItem.uid);
.getAutomaticPlaylistThumbnailStreamId(selectedItem.getUid());
localPlaylistManager
.changePlaylistThumbnail(selectedItem.uid, thumbnailStreamId, false)
.changePlaylistThumbnail(selectedItem.getUid(), thumbnailStreamId, false)
.observeOn(AndroidSchedulers.mainThread())
.subscribe();
}
@ -298,13 +534,13 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
.setView(dialogBinding.getRoot())
.setPositiveButton(R.string.rename_playlist, (dialog, which) ->
changeLocalPlaylistName(
selectedItem.uid,
selectedItem.getUid(),
dialogBinding.dialogEditText.getText().toString()))
.setNegativeButton(R.string.cancel, null)
.show();
}
private void showDeleteDialog(final String name, final Single<Integer> deleteReactor) {
private void showDeleteDialog(final String name, final PlaylistLocalItem item) {
if (activity == null || disposables == null) {
return;
}
@ -313,35 +549,8 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
.setTitle(name)
.setMessage(R.string.delete_playlist_prompt)
.setCancelable(true)
.setPositiveButton(R.string.delete, (dialog, i) ->
disposables.add(deleteReactor
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignored -> { /*Do nothing on success*/ }, throwable ->
showError(new ErrorInfo(throwable,
UserAction.REQUESTED_BOOKMARK,
"Deleting playlist")))))
.setPositiveButton(R.string.delete, (dialog, i) -> deleteItem(item))
.setNegativeButton(R.string.cancel, null)
.show();
}
private void changeLocalPlaylistName(final long id, final String name) {
if (localPlaylistManager == null) {
return;
}
if (DEBUG) {
Log.d(TAG, "Updating playlist id=[" + id + "] "
+ "with new name=[" + name + "] items");
}
localPlaylistManager.renamePlaylist(id, name);
final Disposable disposable = localPlaylistManager.renamePlaylist(id, name)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(longs -> { /*Do nothing on success*/ }, throwable -> showError(
new ErrorInfo(throwable,
UserAction.REQUESTED_BOOKMARK,
"Changing playlist name")));
disposables.add(disposable);
}
}

View file

@ -0,0 +1,95 @@
package org.schabi.newpipe.local.bookmark;
import org.schabi.newpipe.database.playlist.PlaylistLocalItem;
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import io.reactivex.rxjava3.core.Flowable;
/**
* Takes care of remote and local playlists at once, hence "merged".
*/
public final class MergedPlaylistManager {
private MergedPlaylistManager() {
}
public static Flowable<List<PlaylistLocalItem>> getMergedOrderedPlaylists(
final LocalPlaylistManager localPlaylistManager,
final RemotePlaylistManager remotePlaylistManager) {
return Flowable.combineLatest(
localPlaylistManager.getPlaylists(),
remotePlaylistManager.getPlaylists(),
MergedPlaylistManager::merge
);
}
/**
* Merge localPlaylists and remotePlaylists by the display index.
* If two items have the same display index, sort them in {@code CASE_INSENSITIVE_ORDER}.
*
* @param localPlaylists local playlists, already sorted by display index
* @param remotePlaylists remote playlists, already sorted by display index
* @return merged playlists
*/
public static List<PlaylistLocalItem> merge(
final List<PlaylistMetadataEntry> localPlaylists,
final List<PlaylistRemoteEntity> remotePlaylists) {
// This algorithm is similar to the merge operation in merge sort.
final List<PlaylistLocalItem> result = new ArrayList<>(
localPlaylists.size() + remotePlaylists.size());
final List<PlaylistLocalItem> itemsWithSameIndex = new ArrayList<>();
int i = 0;
int j = 0;
while (i < localPlaylists.size()) {
while (j < remotePlaylists.size()) {
if (remotePlaylists.get(j).getDisplayIndex()
<= localPlaylists.get(i).getDisplayIndex()) {
addItem(result, remotePlaylists.get(j), itemsWithSameIndex);
j++;
} else {
break;
}
}
addItem(result, localPlaylists.get(i), itemsWithSameIndex);
i++;
}
while (j < remotePlaylists.size()) {
addItem(result, remotePlaylists.get(j), itemsWithSameIndex);
j++;
}
addItemsWithSameIndex(result, itemsWithSameIndex);
return result;
}
private static void addItem(final List<PlaylistLocalItem> result,
final PlaylistLocalItem item,
final List<PlaylistLocalItem> itemsWithSameIndex) {
if (!itemsWithSameIndex.isEmpty()
&& itemsWithSameIndex.get(0).getDisplayIndex() != item.getDisplayIndex()) {
// The new item has a different display index, add previous items with same
// index to the result.
addItemsWithSameIndex(result, itemsWithSameIndex);
itemsWithSameIndex.clear();
}
itemsWithSameIndex.add(item);
}
private static void addItemsWithSameIndex(final List<PlaylistLocalItem> result,
final List<PlaylistLocalItem> itemsWithSameIndex) {
Collections.sort(itemsWithSameIndex,
Comparator.comparing(PlaylistLocalItem::getOrderingName,
Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)));
result.addAll(itemsWithSameIndex);
}
}

View file

@ -155,14 +155,14 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
final Toast successToast = Toast.makeText(getContext(), toastText, Toast.LENGTH_SHORT);
playlistDisposables.add(manager.appendToPlaylist(playlist.uid, streams)
playlistDisposables.add(manager.appendToPlaylist(playlist.getUid(), streams)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignored -> {
successToast.show();
if (playlist.thumbnailUrl.equals(PlaylistEntity.DEFAULT_THUMBNAIL)) {
playlistDisposables.add(manager
.changePlaylistThumbnail(playlist.uid, streams.get(0).getUid(),
.changePlaylistThumbnail(playlist.getUid(), streams.get(0).getUid(),
false)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignore -> successToast.show()));

View file

@ -18,7 +18,7 @@ data class FeedUpdateInfo(
@NotificationMode
val notificationMode: Int,
val name: String,
val avatarUrl: String,
val avatarUrl: String?,
val url: String,
val serviceId: Int,
// description and subscriberCount are null if the constructor info is from the fast feed method

View file

@ -0,0 +1,54 @@
package org.schabi.newpipe.local.holder;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import java.time.format.DateTimeFormatter;
public class LocalBookmarkPlaylistItemHolder extends LocalPlaylistItemHolder {
private final View itemHandleView;
public LocalBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
this(infoItemBuilder, R.layout.list_playlist_bookmark_item, parent);
}
LocalBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId,
final ViewGroup parent) {
super(infoItemBuilder, layoutId, parent);
itemHandleView = itemView.findViewById(R.id.itemHandle);
}
@Override
public void updateFromItem(final LocalItem localItem,
final HistoryRecordManager historyRecordManager,
final DateTimeFormatter dateTimeFormatter) {
if (!(localItem instanceof PlaylistMetadataEntry)) {
return;
}
final PlaylistMetadataEntry item = (PlaylistMetadataEntry) localItem;
itemHandleView.setOnTouchListener(getOnTouchListener(item));
super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter);
}
private View.OnTouchListener getOnTouchListener(final PlaylistMetadataEntry item) {
return (view, motionEvent) -> {
view.performClick();
if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null
&& motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) {
itemBuilder.getOnItemSelectedListener().drag(item,
LocalBookmarkPlaylistItemHolder.this);
}
return false;
};
}
}

View file

@ -0,0 +1,54 @@
package org.schabi.newpipe.local.holder;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.LocalItem;
import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import java.time.format.DateTimeFormatter;
public class RemoteBookmarkPlaylistItemHolder extends RemotePlaylistItemHolder {
private final View itemHandleView;
public RemoteBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
this(infoItemBuilder, R.layout.list_playlist_bookmark_item, parent);
}
RemoteBookmarkPlaylistItemHolder(final LocalItemBuilder infoItemBuilder, final int layoutId,
final ViewGroup parent) {
super(infoItemBuilder, layoutId, parent);
itemHandleView = itemView.findViewById(R.id.itemHandle);
}
@Override
public void updateFromItem(final LocalItem localItem,
final HistoryRecordManager historyRecordManager,
final DateTimeFormatter dateTimeFormatter) {
if (!(localItem instanceof PlaylistRemoteEntity)) {
return;
}
final PlaylistRemoteEntity item = (PlaylistRemoteEntity) localItem;
itemHandleView.setOnTouchListener(getOnTouchListener(item));
super.updateFromItem(localItem, historyRecordManager, dateTimeFormatter);
}
private View.OnTouchListener getOnTouchListener(final PlaylistRemoteEntity item) {
return (view, motionEvent) -> {
view.performClick();
if (itemBuilder != null && itemBuilder.getOnItemSelectedListener() != null
&& motionEvent.getActionMasked() == MotionEvent.ACTION_DOWN) {
itemBuilder.getOnItemSelectedListener().drag(item,
RemoteBookmarkPlaylistItemHolder.this);
}
return false;
};
}
}

View file

@ -14,6 +14,7 @@ import org.schabi.newpipe.util.ServiceHelper;
import java.time.format.DateTimeFormatter;
public class RemotePlaylistItemHolder extends PlaylistItemHolder {
public RemotePlaylistItemHolder(final LocalItemBuilder infoItemBuilder,
final ViewGroup parent) {
super(infoItemBuilder, parent);

View file

@ -49,6 +49,8 @@ import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.debounce.DebounceSavable;
import org.schabi.newpipe.util.debounce.DebounceSaver;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
@ -58,7 +60,6 @@ import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
@ -68,12 +69,10 @@ import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers;
import io.reactivex.rxjava3.subjects.PublishSubject;
public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistStreamEntry>, Void>
implements PlaylistControlViewHolder {
/** Save the list 10 seconds after the last change occurred. */
private static final long SAVE_DEBOUNCE_MILLIS = 10000;
implements PlaylistControlViewHolder, DebounceSavable {
private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12;
@State
protected Long playlistId;
@ -90,13 +89,12 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
private LocalPlaylistManager playlistManager;
private Subscription databaseSubscription;
private PublishSubject<Long> debouncedSaveSignal;
private CompositeDisposable disposables;
/** Whether the playlist has been fully loaded from db. */
private AtomicBoolean isLoadingComplete;
/** Whether the playlist has been modified (e.g. items reordered or deleted) */
private AtomicBoolean isModified;
/** Used to debounce saving playlist edits to disk. */
private DebounceSaver debounceSaver;
/** Flag to prevent simultaneous rewrites of the playlist. */
private boolean isRewritingPlaylist = false;
@ -121,12 +119,11 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
playlistManager = new LocalPlaylistManager(NewPipeDatabase.getInstance(requireContext()));
debouncedSaveSignal = PublishSubject.create();
disposables = new CompositeDisposable();
isLoadingComplete = new AtomicBoolean();
isModified = new AtomicBoolean();
debounceSaver = new DebounceSaver(this);
}
@Override
@ -166,17 +163,6 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
return headerBinding;
}
/**
* <p>Commit changes immediately if the playlist has been modified.</p>
* Delete operations and other modifications will be committed to ensure that the database
* is up to date, e.g. when the user adds the just deleted stream from another fragment.
*/
public void commitChanges() {
if (isModified != null && isModified.get()) {
saveImmediate();
}
}
@Override
protected void initListeners() {
super.initListeners();
@ -243,10 +229,13 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
if (disposables != null) {
disposables.clear();
}
disposables.add(getDebouncedSaver());
if (debounceSaver != null) {
disposables.add(debounceSaver.getDebouncedSaver());
debounceSaver.setNoChangesToSave();
}
isLoadingComplete.set(false);
isModified.set(false);
playlistManager.getPlaylistStreams(playlistId)
.onBackpressureLatest()
@ -304,8 +293,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
@Override
public void onDestroy() {
super.onDestroy();
if (debouncedSaveSignal != null) {
debouncedSaveSignal.onComplete();
if (debounceSaver != null) {
debounceSaver.getDebouncedSaveSignal().onComplete();
}
if (disposables != null) {
disposables.dispose();
@ -314,12 +303,11 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
tabsPagerAdapter.getLocalPlaylistFragments().remove(this);
}
debouncedSaveSignal = null;
debounceSaver = null;
playlistManager = null;
disposables = null;
isLoadingComplete = null;
isModified = null;
}
///////////////////////////////////////////////////////////////////////////
@ -343,7 +331,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
@Override
public void onNext(final List<PlaylistStreamEntry> streams) {
// Skip handling the result after it has been modified
if (isModified == null || !isModified.get()) {
if (debounceSaver == null || !debounceSaver.getIsModified()) {
handleResult(streams);
isLoadingComplete.set(true);
}
@ -495,14 +483,14 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
itemListAdapter.clearStreamItemList();
itemListAdapter.addItems(itemsToKeep);
saveChanges();
debounceSaver.setHasChangesToSave();
if (thumbnailVideoRemoved) {
updateThumbnailUrl();
}
final long videoCount = itemListAdapter.getItemsList().size();
setVideoCount(videoCount);
setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
if (videoCount == 0) {
showEmptyState();
}
@ -532,7 +520,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
itemsList.getLayoutManager().onRestoreInstanceState(itemsListState);
itemsListState = null;
}
setVideoCount(itemListAdapter.getItemsList().size());
setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this);
@ -665,8 +653,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
.subscribe(itemsToKeep -> {
itemListAdapter.clearStreamItemList();
itemListAdapter.addItems(itemsToKeep);
setVideoCount(itemListAdapter.getItemsList().size());
saveChanges();
setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
debounceSaver.setHasChangesToSave();
hideLoading();
isRewritingPlaylist = false;
@ -684,42 +672,24 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
updateThumbnailUrl();
}
setVideoCount(itemListAdapter.getItemsList().size());
saveChanges();
setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
debounceSaver.setHasChangesToSave();
}
private void saveChanges() {
if (isModified == null || debouncedSaveSignal == null) {
return;
}
isModified.set(true);
debouncedSaveSignal.onNext(System.currentTimeMillis());
}
private Disposable getDebouncedSaver() {
if (debouncedSaveSignal == null) {
return Disposable.empty();
}
return debouncedSaveSignal
.debounce(SAVE_DEBOUNCE_MILLIS, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignored -> saveImmediate(), throwable ->
showError(new ErrorInfo(throwable, UserAction.SOMETHING_ELSE,
"Debounced saver")));
}
private void saveImmediate() {
/**
* <p>Commit changes immediately if the playlist has been modified.</p>
* Delete operations and other modifications will be committed to ensure that the database
* is up to date, e.g. when the user adds the just deleted stream from another fragment.
*/
@Override
public void saveImmediate() {
if (playlistManager == null || itemListAdapter == null) {
return;
}
// List must be loaded and modified in order to save
if (isLoadingComplete == null || isModified == null
|| !isLoadingComplete.get() || !isModified.get()) {
Log.w(TAG, "Attempting to save playlist when local playlist "
+ "is not loaded or not modified: playlist id=[" + playlistId + "]");
if (isLoadingComplete == null || debounceSaver == null
|| !isLoadingComplete.get() || !debounceSaver.getIsModified()) {
return;
}
@ -740,8 +710,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
() -> {
if (isModified != null) {
isModified.set(false);
if (debounceSaver != null) {
debounceSaver.setNoChangesToSave();
}
},
throwable -> showError(new ErrorInfo(throwable,
@ -784,7 +754,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
final int targetIndex = target.getBindingAdapterPosition();
final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex);
if (isSwapped) {
saveChanges();
debounceSaver.setHasChangesToSave();
}
return isSwapped;
}
@ -855,10 +825,21 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
this.name = !TextUtils.isEmpty(title) ? title : "";
}
private void setVideoCount(final long count) {
private void setStreamCountAndOverallDuration(final ArrayList<LocalItem> itemsList) {
if (activity != null && headerBinding != null) {
headerBinding.playlistStreamCount.setText(Localization
.localizeStreamCount(activity, count));
final long streamCount = itemsList.size();
final long playlistOverallDurationSeconds = itemsList.stream()
.filter(PlaylistStreamEntry.class::isInstance)
.map(PlaylistStreamEntry.class::cast)
.map(PlaylistStreamEntry::getStreamEntity)
.mapToLong(StreamEntity::getDuration)
.sum();
headerBinding.playlistStreamCount.setText(
Localization.concatenateStrings(
Localization.localizeStreamCount(activity, streamCount),
Localization.getDurationString(playlistOverallDurationSeconds,
true, true))
);
}
}

View file

@ -19,7 +19,6 @@ import java.util.List;
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Maybe;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class LocalPlaylistManager {
@ -43,10 +42,13 @@ public class LocalPlaylistManager {
return Maybe.empty();
}
// Save to the database directly.
// Make sure the new playlist is always on the top of bookmark.
// The index will be reassigned to non-negative number in BookmarkFragment.
return Maybe.fromCallable(() -> database.runInTransaction(() -> {
final List<Long> streamIds = streamTable.upsertAll(streams);
final PlaylistEntity newPlaylist = new PlaylistEntity(name, false,
streamIds.get(0));
streamIds.get(0), -1);
return insertJoinEntities(playlistTable.insert(newPlaylist),
streamIds, 0);
@ -89,8 +91,20 @@ public class LocalPlaylistManager {
})).subscribeOn(Schedulers.io());
}
public Flowable<List<PlaylistMetadataEntry>> getPlaylists() {
return playlistStreamTable.getPlaylistMetadata().subscribeOn(Schedulers.io());
public Completable updatePlaylists(final List<PlaylistMetadataEntry> updateItems,
final List<Long> deletedItems) {
final List<PlaylistEntity> items = new ArrayList<>(updateItems.size());
for (final PlaylistMetadataEntry item : updateItems) {
items.add(new PlaylistEntity(item));
}
return Completable.fromRunnable(() -> database.runInTransaction(() -> {
for (final Long uid : deletedItems) {
playlistTable.deletePlaylist(uid);
}
for (final PlaylistEntity item : items) {
playlistTable.upsertPlaylist(item);
}
})).subscribeOn(Schedulers.io());
}
public Flowable<List<PlaylistStreamEntry>> getDistinctPlaylistStreams(final long playlistId) {
@ -110,13 +124,12 @@ public class LocalPlaylistManager {
.subscribeOn(Schedulers.io());
}
public Flowable<List<PlaylistStreamEntry>> getPlaylistStreams(final long playlistId) {
return playlistStreamTable.getOrderedStreamsOf(playlistId).subscribeOn(Schedulers.io());
public Flowable<List<PlaylistMetadataEntry>> getPlaylists() {
return playlistStreamTable.getPlaylistMetadata().subscribeOn(Schedulers.io());
}
public Single<Integer> deletePlaylist(final long playlistId) {
return Single.fromCallable(() -> playlistTable.deletePlaylist(playlistId))
.subscribeOn(Schedulers.io());
public Flowable<List<PlaylistStreamEntry>> getPlaylistStreams(final long playlistId) {
return playlistStreamTable.getOrderedStreamsOf(playlistId).subscribeOn(Schedulers.io());
}
public Maybe<Integer> renamePlaylist(final long playlistId, final String name) {

View file

@ -7,20 +7,23 @@ import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import java.util.List;
import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers;
public class RemotePlaylistManager {
private final AppDatabase database;
private final PlaylistRemoteDAO playlistRemoteTable;
public RemotePlaylistManager(final AppDatabase db) {
database = db;
playlistRemoteTable = db.playlistRemoteDAO();
}
public Flowable<List<PlaylistRemoteEntity>> getPlaylists() {
return playlistRemoteTable.getAll().subscribeOn(Schedulers.io());
return playlistRemoteTable.getPlaylists().subscribeOn(Schedulers.io());
}
public Flowable<List<PlaylistRemoteEntity>> getPlaylist(final PlaylistInfo info) {
@ -33,6 +36,18 @@ public class RemotePlaylistManager {
.subscribeOn(Schedulers.io());
}
public Completable updatePlaylists(final List<PlaylistRemoteEntity> updateItems,
final List<Long> deletedItems) {
return Completable.fromRunnable(() -> database.runInTransaction(() -> {
for (final Long uid: deletedItems) {
playlistRemoteTable.deletePlaylist(uid);
}
for (final PlaylistRemoteEntity item: updateItems) {
playlistRemoteTable.upsert(item);
}
})).subscribeOn(Schedulers.io());
}
public Single<Long> onBookmark(final PlaylistInfo playlistInfo) {
return Single.fromCallable(() -> {
final PlaylistRemoteEntity playlist = new PlaylistRemoteEntity(playlistInfo);

View file

@ -100,7 +100,9 @@ class SubscriptionManager(context: Context) {
val subscriptionEntity = subscriptionTable.getSubscription(info.uid)
subscriptionEntity.name = info.name
subscriptionEntity.avatarUrl = info.avatarUrl
// some services do not provide an avatar URL
info.avatarUrl?.let { subscriptionEntity.avatarUrl = it }
// these two fields are null if the feed info was fetched using the fast feed method
info.description?.let { subscriptionEntity.description = it }

View file

@ -1,10 +1,12 @@
package org.schabi.newpipe.player.mediasession;
import static org.schabi.newpipe.MainActivity.DEBUG;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_RECREATE_NOTIFICATION;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.os.Build;
import android.support.v4.media.MediaMetadataCompat;
import android.support.v4.media.session.MediaSessionCompat;
import android.util.Log;
@ -14,15 +16,23 @@ import androidx.annotation.Nullable;
import androidx.media.session.MediaButtonReceiver;
import com.google.android.exoplayer2.ForwardingPlayer;
import com.google.android.exoplayer2.Player.RepeatMode;
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.notification.NotificationActionData;
import org.schabi.newpipe.player.notification.NotificationConstants;
import org.schabi.newpipe.player.ui.PlayerUi;
import org.schabi.newpipe.player.ui.VideoPlayerUi;
import org.schabi.newpipe.util.StreamTypeUtil;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class MediaSessionPlayerUi extends PlayerUi
implements SharedPreferences.OnSharedPreferenceChangeListener {
@ -34,6 +44,10 @@ public class MediaSessionPlayerUi extends PlayerUi
private final String ignoreHardwareMediaButtonsKey;
private boolean shouldIgnoreHardwareMediaButtons = false;
// used to check whether any notification action changed, before sending costly updates
private List<NotificationActionData> prevNotificationActions = List.of();
public MediaSessionPlayerUi(@NonNull final Player player) {
super(player);
ignoreHardwareMediaButtonsKey =
@ -63,6 +77,10 @@ public class MediaSessionPlayerUi extends PlayerUi
sessionConnector.setMetadataDeduplicationEnabled(true);
sessionConnector.setMediaMetadataProvider(exoPlayer -> buildMediaMetadata());
// force updating media session actions by resetting the previous ones
prevNotificationActions = List.of();
updateMediaSessionActions();
}
@Override
@ -80,6 +98,7 @@ public class MediaSessionPlayerUi extends PlayerUi
mediaSession.release();
mediaSession = null;
}
prevNotificationActions = List.of();
}
@Override
@ -163,4 +182,109 @@ public class MediaSessionPlayerUi extends PlayerUi
return builder.build();
}
private void updateMediaSessionActions() {
// On Android 13+ (or Android T or API 33+) the actions in the player notification can't be
// controlled directly anymore, but are instead derived from custom media session actions.
// However the system allows customizing only two of these actions, since the other three
// are fixed to play-pause-buffering, previous, next.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
// Although setting media session actions on older android versions doesn't seem to
// cause any trouble, it also doesn't seem to do anything, so we don't do anything to
// save battery. Check out NotificationUtil.updateActions() to see what happens on
// older android versions.
return;
}
// only use the fourth and fifth actions (the settings page also shows only the last 2 on
// Android 13+)
final List<NotificationActionData> newNotificationActions = IntStream.of(3, 4)
.map(i -> player.getPrefs().getInt(
player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]),
NotificationConstants.SLOT_DEFAULTS[i]))
.mapToObj(action -> NotificationActionData
.fromNotificationActionEnum(player, action))
.filter(Objects::nonNull)
.collect(Collectors.toList());
// avoid costly notification actions update, if nothing changed from last time
if (!newNotificationActions.equals(prevNotificationActions)) {
prevNotificationActions = newNotificationActions;
sessionConnector.setCustomActionProviders(
newNotificationActions.stream()
.map(data -> new SessionConnectorActionProvider(data, context))
.toArray(SessionConnectorActionProvider[]::new));
}
}
@Override
public void onBlocked() {
super.onBlocked();
updateMediaSessionActions();
}
@Override
public void onPlaying() {
super.onPlaying();
updateMediaSessionActions();
}
@Override
public void onBuffering() {
super.onBuffering();
updateMediaSessionActions();
}
@Override
public void onPaused() {
super.onPaused();
updateMediaSessionActions();
}
@Override
public void onPausedSeek() {
super.onPausedSeek();
updateMediaSessionActions();
}
@Override
public void onCompleted() {
super.onCompleted();
updateMediaSessionActions();
}
@Override
public void onRepeatModeChanged(@RepeatMode final int repeatMode) {
super.onRepeatModeChanged(repeatMode);
updateMediaSessionActions();
}
@Override
public void onShuffleModeEnabledChanged(final boolean shuffleModeEnabled) {
super.onShuffleModeEnabledChanged(shuffleModeEnabled);
updateMediaSessionActions();
}
@Override
public void onBroadcastReceived(final Intent intent) {
super.onBroadcastReceived(intent);
if (ACTION_RECREATE_NOTIFICATION.equals(intent.getAction())) {
// the notification actions changed
updateMediaSessionActions();
}
}
@Override
public void onMetadataChanged(@NonNull final StreamInfo info) {
super.onMetadataChanged(info);
updateMediaSessionActions();
}
@Override
public void onPlayQueueEdited() {
super.onPlayQueueEdited();
updateMediaSessionActions();
}
}

View file

@ -0,0 +1,47 @@
package org.schabi.newpipe.player.mediasession;
import android.content.Context;
import android.content.Intent;
import android.os.Bundle;
import android.support.v4.media.session.PlaybackStateCompat;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
import org.schabi.newpipe.player.notification.NotificationActionData;
import java.lang.ref.WeakReference;
public class SessionConnectorActionProvider implements MediaSessionConnector.CustomActionProvider {
private final NotificationActionData data;
@NonNull
private final WeakReference<Context> context;
public SessionConnectorActionProvider(final NotificationActionData notificationActionData,
@NonNull final Context context) {
this.data = notificationActionData;
this.context = new WeakReference<>(context);
}
@Override
public void onCustomAction(@NonNull final Player player,
@NonNull final String action,
@Nullable final Bundle extras) {
final Context actualContext = context.get();
if (actualContext != null) {
actualContext.sendBroadcast(new Intent(action));
}
}
@Nullable
@Override
public PlaybackStateCompat.CustomAction getCustomAction(@NonNull final Player player) {
return new PlaybackStateCompat.CustomAction.Builder(
data.action(), data.name(), data.icon()
).build();
}
}

View file

@ -0,0 +1,187 @@
package org.schabi.newpipe.player.notification;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_FORWARD;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_REWIND;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_NEXT;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PREVIOUS;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_REPEAT;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE;
import android.annotation.SuppressLint;
import android.content.Context;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.schabi.newpipe.R;
import org.schabi.newpipe.player.Player;
import java.util.Objects;
public final class NotificationActionData {
@NonNull
private final String action;
@NonNull
private final String name;
@DrawableRes
private final int icon;
public NotificationActionData(@NonNull final String action, @NonNull final String name,
@DrawableRes final int icon) {
this.action = action;
this.name = name;
this.icon = icon;
}
@NonNull
public String action() {
return action;
}
@NonNull
public String name() {
return name;
}
@DrawableRes
public int icon() {
return icon;
}
@SuppressLint("PrivateResource") // we currently use Exoplayer's internal strings and icons
@Nullable
public static NotificationActionData fromNotificationActionEnum(
@NonNull final Player player,
@NotificationConstants.Action final int selectedAction
) {
final int baseActionIcon = NotificationConstants.ACTION_ICONS[selectedAction];
final Context ctx = player.getContext();
switch (selectedAction) {
case NotificationConstants.PREVIOUS:
return new NotificationActionData(ACTION_PLAY_PREVIOUS,
ctx.getString(R.string.exo_controls_previous_description), baseActionIcon);
case NotificationConstants.NEXT:
return new NotificationActionData(ACTION_PLAY_NEXT,
ctx.getString(R.string.exo_controls_next_description), baseActionIcon);
case NotificationConstants.REWIND:
return new NotificationActionData(ACTION_FAST_REWIND,
ctx.getString(R.string.exo_controls_rewind_description), baseActionIcon);
case NotificationConstants.FORWARD:
return new NotificationActionData(ACTION_FAST_FORWARD,
ctx.getString(R.string.exo_controls_fastforward_description),
baseActionIcon);
case NotificationConstants.SMART_REWIND_PREVIOUS:
if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
return new NotificationActionData(ACTION_PLAY_PREVIOUS,
ctx.getString(R.string.exo_controls_previous_description),
R.drawable.exo_notification_previous);
} else {
return new NotificationActionData(ACTION_FAST_REWIND,
ctx.getString(R.string.exo_controls_rewind_description),
R.drawable.exo_controls_rewind);
}
case NotificationConstants.SMART_FORWARD_NEXT:
if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
return new NotificationActionData(ACTION_PLAY_NEXT,
ctx.getString(R.string.exo_controls_next_description),
R.drawable.exo_notification_next);
} else {
return new NotificationActionData(ACTION_FAST_FORWARD,
ctx.getString(R.string.exo_controls_fastforward_description),
R.drawable.exo_controls_fastforward);
}
case NotificationConstants.PLAY_PAUSE_BUFFERING:
if (player.getCurrentState() == Player.STATE_PREFLIGHT
|| player.getCurrentState() == Player.STATE_BLOCKED
|| player.getCurrentState() == Player.STATE_BUFFERING) {
return new NotificationActionData(ACTION_PLAY_PAUSE,
ctx.getString(R.string.notification_action_buffering),
R.drawable.ic_hourglass_top);
}
// fallthrough
case NotificationConstants.PLAY_PAUSE:
if (player.getCurrentState() == Player.STATE_COMPLETED) {
return new NotificationActionData(ACTION_PLAY_PAUSE,
ctx.getString(R.string.exo_controls_pause_description),
R.drawable.ic_replay);
} else if (player.isPlaying()
|| player.getCurrentState() == Player.STATE_PREFLIGHT
|| player.getCurrentState() == Player.STATE_BLOCKED
|| player.getCurrentState() == Player.STATE_BUFFERING) {
return new NotificationActionData(ACTION_PLAY_PAUSE,
ctx.getString(R.string.exo_controls_pause_description),
R.drawable.exo_notification_pause);
} else {
return new NotificationActionData(ACTION_PLAY_PAUSE,
ctx.getString(R.string.exo_controls_play_description),
R.drawable.exo_notification_play);
}
case NotificationConstants.REPEAT:
if (player.getRepeatMode() == REPEAT_MODE_ALL) {
return new NotificationActionData(ACTION_REPEAT,
ctx.getString(R.string.exo_controls_repeat_all_description),
R.drawable.exo_media_action_repeat_all);
} else if (player.getRepeatMode() == REPEAT_MODE_ONE) {
return new NotificationActionData(ACTION_REPEAT,
ctx.getString(R.string.exo_controls_repeat_one_description),
R.drawable.exo_media_action_repeat_one);
} else /* player.getRepeatMode() == REPEAT_MODE_OFF */ {
return new NotificationActionData(ACTION_REPEAT,
ctx.getString(R.string.exo_controls_repeat_off_description),
R.drawable.exo_media_action_repeat_off);
}
case NotificationConstants.SHUFFLE:
if (player.getPlayQueue() != null && player.getPlayQueue().isShuffled()) {
return new NotificationActionData(ACTION_SHUFFLE,
ctx.getString(R.string.exo_controls_shuffle_on_description),
R.drawable.exo_controls_shuffle_on);
} else {
return new NotificationActionData(ACTION_SHUFFLE,
ctx.getString(R.string.exo_controls_shuffle_off_description),
R.drawable.exo_controls_shuffle_off);
}
case NotificationConstants.CLOSE:
return new NotificationActionData(ACTION_CLOSE, ctx.getString(R.string.close),
R.drawable.ic_close);
case NotificationConstants.NOTHING:
default:
// do nothing
return null;
}
}
@Override
public boolean equals(@Nullable final Object obj) {
return (obj instanceof NotificationActionData other)
&& this.action.equals(other.action)
&& this.name.equals(other.name)
&& this.icon == other.icon;
}
@Override
public int hashCode() {
return Objects.hash(action, name, icon);
}
}

View file

@ -13,7 +13,7 @@ import org.schabi.newpipe.util.Localization;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.SortedSet;
import java.util.TreeSet;
@ -65,10 +65,16 @@ public final class NotificationConstants {
public static final int CLOSE = 11;
@Retention(RetentionPolicy.SOURCE)
@IntDef({NOTHING, PREVIOUS, NEXT, REWIND, FORWARD, SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT,
PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT, SHUFFLE, CLOSE})
@IntDef({NOTHING, PREVIOUS, NEXT, REWIND, FORWARD,
SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT,
SHUFFLE, CLOSE})
public @interface Action { }
@Action
public static final int[] ALL_ACTIONS = {NOTHING, PREVIOUS, NEXT, REWIND, FORWARD,
SMART_REWIND_PREVIOUS, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING, REPEAT,
SHUFFLE, CLOSE};
@DrawableRes
public static final int[] ACTION_ICONS = {
0,
@ -95,16 +101,6 @@ public final class NotificationConstants {
CLOSE,
};
@Action
public static final int[][] SLOT_ALLOWED_ACTIONS = {
new int[] {PREVIOUS, REWIND, SMART_REWIND_PREVIOUS},
new int[] {REWIND, PLAY_PAUSE, PLAY_PAUSE_BUFFERING},
new int[] {NEXT, FORWARD, SMART_FORWARD_NEXT, PLAY_PAUSE, PLAY_PAUSE_BUFFERING},
new int[] {NOTHING, PREVIOUS, NEXT, REWIND, FORWARD, SMART_REWIND_PREVIOUS,
SMART_FORWARD_NEXT, REPEAT, SHUFFLE, CLOSE},
new int[] {NOTHING, NEXT, FORWARD, SMART_FORWARD_NEXT, REPEAT, SHUFFLE, CLOSE},
};
public static final int[] SLOT_PREF_KEYS = {
R.string.notification_slot_0_key,
R.string.notification_slot_1_key,
@ -165,14 +161,11 @@ public final class NotificationConstants {
/**
* @param context the context to use
* @param sharedPreferences the shared preferences to query values from
* @param slotCount remove indices >= than this value (set to {@code 5} to do nothing, or make
* it lower if there are slots with empty actions)
* @return a sorted list of the indices of the slots to use as compact slots
*/
public static List<Integer> getCompactSlotsFromPreferences(
public static Collection<Integer> getCompactSlotsFromPreferences(
@NonNull final Context context,
final SharedPreferences sharedPreferences,
final int slotCount) {
final SharedPreferences sharedPreferences) {
final SortedSet<Integer> compactSlots = new TreeSet<>();
for (int i = 0; i < 3; i++) {
final int compactSlot = sharedPreferences.getInt(
@ -180,14 +173,14 @@ public final class NotificationConstants {
if (compactSlot == Integer.MAX_VALUE) {
// settings not yet populated, return default values
return new ArrayList<>(SLOT_COMPACT_DEFAULTS);
return SLOT_COMPACT_DEFAULTS;
}
// a negative value (-1) is set when the user does not want a particular compact slot
if (compactSlot >= 0 && compactSlot < slotCount) {
if (compactSlot >= 0) {
// compact slot is < 0 if there are less than 3 checked checkboxes
compactSlots.add(compactSlot);
}
}
return new ArrayList<>(compactSlots);
return compactSlots;
}
}

View file

@ -1,16 +1,19 @@
package org.schabi.newpipe.player.notification;
import static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
import static androidx.media.app.NotificationCompat.MediaStyle;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE;
import android.annotation.SuppressLint;
import android.app.PendingIntent;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.graphics.Bitmap;
import android.os.Build;
import android.util.Log;
import androidx.annotation.DrawableRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.app.PendingIntentCompat;
@ -23,23 +26,12 @@ import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
import org.schabi.newpipe.util.NavigationHelper;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import static android.app.PendingIntent.FLAG_UPDATE_CURRENT;
import static androidx.media.app.NotificationCompat.MediaStyle;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_FORWARD;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_FAST_REWIND;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_NEXT;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PAUSE;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_PLAY_PREVIOUS;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_REPEAT;
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_SHUFFLE;
/**
* This is a utility class for player notifications.
*/
@ -100,29 +92,21 @@ public final class NotificationUtil {
final NotificationCompat.Builder builder =
new NotificationCompat.Builder(player.getContext(),
player.getContext().getString(R.string.notification_channel_id));
final MediaStyle mediaStyle = new MediaStyle();
initializeNotificationSlots();
// count the number of real slots, to make sure compact slots indices are not out of bound
int nonNothingSlotCount = 5;
if (notificationSlots[3] == NotificationConstants.NOTHING) {
--nonNothingSlotCount;
// setup media style (compact notification slots and media session)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
// notification actions are ignored on Android 13+, and are replaced by code in
// MediaSessionPlayerUi
final int[] compactSlots = initializeNotificationSlots();
mediaStyle.setShowActionsInCompactView(compactSlots);
}
if (notificationSlots[4] == NotificationConstants.NOTHING) {
--nonNothingSlotCount;
}
// build the compact slot indices array (need code to convert from Integer... because Java)
final List<Integer> compactSlotList = NotificationConstants.getCompactSlotsFromPreferences(
player.getContext(), player.getPrefs(), nonNothingSlotCount);
final int[] compactSlots = compactSlotList.stream().mapToInt(Integer::intValue).toArray();
final MediaStyle mediaStyle = new MediaStyle().setShowActionsInCompactView(compactSlots);
player.UIs()
.get(MediaSessionPlayerUi.class)
.flatMap(MediaSessionPlayerUi::getSessionToken)
.ifPresent(mediaStyle::setMediaSession);
// setup notification builder
builder.setStyle(mediaStyle)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
@ -157,7 +141,11 @@ public final class NotificationUtil {
notificationBuilder.setContentText(player.getUploaderName());
notificationBuilder.setTicker(player.getVideoTitle());
updateActions(notificationBuilder);
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
// notification actions are ignored on Android 13+, and are replaced by code in
// MediaSessionPlayerUi
updateActions(notificationBuilder);
}
}
@ -209,12 +197,35 @@ public final class NotificationUtil {
// ACTIONS
/////////////////////////////////////////////////////
private void initializeNotificationSlots() {
/**
* The compact slots array from settings contains indices from 0 to 4, each referring to one of
* the five actions configurable by the user. However, if the user sets an action to "Nothing",
* then all of the actions coming after will have a "settings index" different than the index
* of the corresponding action when sent to the system.
*
* @return the indices of compact slots referred to the list of non-nothing actions that will be
* sent to the system
*/
private int[] initializeNotificationSlots() {
final Collection<Integer> settingsCompactSlots = NotificationConstants
.getCompactSlotsFromPreferences(player.getContext(), player.getPrefs());
final List<Integer> adjustedCompactSlots = new ArrayList<>();
int nonNothingIndex = 0;
for (int i = 0; i < 5; ++i) {
notificationSlots[i] = player.getPrefs().getInt(
player.getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]),
NotificationConstants.SLOT_DEFAULTS[i]);
if (notificationSlots[i] != NotificationConstants.NOTHING) {
if (settingsCompactSlots.contains(i)) {
adjustedCompactSlots.add(nonNothingIndex);
}
nonNothingIndex += 1;
}
}
return adjustedCompactSlots.stream().mapToInt(Integer::intValue).toArray();
}
@SuppressLint("RestrictedApi")
@ -227,115 +238,15 @@ public final class NotificationUtil {
private void addAction(final NotificationCompat.Builder builder,
@NotificationConstants.Action final int slot) {
final NotificationCompat.Action action = getAction(slot);
if (action != null) {
builder.addAction(action);
@Nullable final NotificationActionData data =
NotificationActionData.fromNotificationActionEnum(player, slot);
if (data == null) {
return;
}
}
@Nullable
private NotificationCompat.Action getAction(
@NotificationConstants.Action final int selectedAction) {
final int baseActionIcon = NotificationConstants.ACTION_ICONS[selectedAction];
switch (selectedAction) {
case NotificationConstants.PREVIOUS:
return getAction(baseActionIcon,
R.string.exo_controls_previous_description, ACTION_PLAY_PREVIOUS);
case NotificationConstants.NEXT:
return getAction(baseActionIcon,
R.string.exo_controls_next_description, ACTION_PLAY_NEXT);
case NotificationConstants.REWIND:
return getAction(baseActionIcon,
R.string.exo_controls_rewind_description, ACTION_FAST_REWIND);
case NotificationConstants.FORWARD:
return getAction(baseActionIcon,
R.string.exo_controls_fastforward_description, ACTION_FAST_FORWARD);
case NotificationConstants.SMART_REWIND_PREVIOUS:
if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
return getAction(R.drawable.exo_notification_previous,
R.string.exo_controls_previous_description, ACTION_PLAY_PREVIOUS);
} else {
return getAction(R.drawable.exo_controls_rewind,
R.string.exo_controls_rewind_description, ACTION_FAST_REWIND);
}
case NotificationConstants.SMART_FORWARD_NEXT:
if (player.getPlayQueue() != null && player.getPlayQueue().size() > 1) {
return getAction(R.drawable.exo_notification_next,
R.string.exo_controls_next_description, ACTION_PLAY_NEXT);
} else {
return getAction(R.drawable.exo_controls_fastforward,
R.string.exo_controls_fastforward_description, ACTION_FAST_FORWARD);
}
case NotificationConstants.PLAY_PAUSE_BUFFERING:
if (player.getCurrentState() == Player.STATE_PREFLIGHT
|| player.getCurrentState() == Player.STATE_BLOCKED
|| player.getCurrentState() == Player.STATE_BUFFERING) {
// null intent -> show hourglass icon that does nothing when clicked
return new NotificationCompat.Action(R.drawable.ic_hourglass_top,
player.getContext().getString(R.string.notification_action_buffering),
null);
}
// fallthrough
case NotificationConstants.PLAY_PAUSE:
if (player.getCurrentState() == Player.STATE_COMPLETED) {
return getAction(R.drawable.ic_replay,
R.string.exo_controls_pause_description, ACTION_PLAY_PAUSE);
} else if (player.isPlaying()
|| player.getCurrentState() == Player.STATE_PREFLIGHT
|| player.getCurrentState() == Player.STATE_BLOCKED
|| player.getCurrentState() == Player.STATE_BUFFERING) {
return getAction(R.drawable.exo_notification_pause,
R.string.exo_controls_pause_description, ACTION_PLAY_PAUSE);
} else {
return getAction(R.drawable.exo_notification_play,
R.string.exo_controls_play_description, ACTION_PLAY_PAUSE);
}
case NotificationConstants.REPEAT:
if (player.getRepeatMode() == REPEAT_MODE_ALL) {
return getAction(R.drawable.exo_media_action_repeat_all,
R.string.exo_controls_repeat_all_description, ACTION_REPEAT);
} else if (player.getRepeatMode() == REPEAT_MODE_ONE) {
return getAction(R.drawable.exo_media_action_repeat_one,
R.string.exo_controls_repeat_one_description, ACTION_REPEAT);
} else /* player.getRepeatMode() == REPEAT_MODE_OFF */ {
return getAction(R.drawable.exo_media_action_repeat_off,
R.string.exo_controls_repeat_off_description, ACTION_REPEAT);
}
case NotificationConstants.SHUFFLE:
if (player.getPlayQueue() != null && player.getPlayQueue().isShuffled()) {
return getAction(R.drawable.exo_controls_shuffle_on,
R.string.exo_controls_shuffle_on_description, ACTION_SHUFFLE);
} else {
return getAction(R.drawable.exo_controls_shuffle_off,
R.string.exo_controls_shuffle_off_description, ACTION_SHUFFLE);
}
case NotificationConstants.CLOSE:
return getAction(R.drawable.ic_close,
R.string.close, ACTION_CLOSE);
case NotificationConstants.NOTHING:
default:
// do nothing
return null;
}
}
private NotificationCompat.Action getAction(@DrawableRes final int drawable,
@StringRes final int title,
final String intentAction) {
return new NotificationCompat.Action(drawable, player.getContext().getString(title),
PendingIntentCompat.getBroadcast(player.getContext(), NOTIFICATION_ID,
new Intent(intentAction), FLAG_UPDATE_CURRENT, false));
final PendingIntent intent = PendingIntentCompat.getBroadcast(player.getContext(),
NOTIFICATION_ID, new Intent(data.action()), FLAG_UPDATE_CURRENT, false);
builder.addAction(new NotificationCompat.Action(data.icon(), data.name(), intent));
}
private Intent getIntentForNotification() {

View file

@ -0,0 +1,301 @@
package org.schabi.newpipe.settings;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.app.Activity;
import android.app.AlertDialog;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Bundle;
import android.widget.Toast;
import androidx.activity.result.ActivityResult;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.content.ContextCompat;
import androidx.preference.Preference;
import androidx.preference.PreferenceManager;
import com.grack.nanojson.JsonParserException;
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.settings.export.BackupFileLocator;
import org.schabi.newpipe.settings.export.ImportExportManager;
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard;
import org.schabi.newpipe.streams.io.StoredFileHelper;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.ZipHelper;
import java.io.File;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.Objects;
public class BackupRestoreSettingsFragment extends BasePreferenceFragment {
private static final String ZIP_MIME_TYPE = "application/zip";
private final SimpleDateFormat exportDateFormat =
new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US);
private ImportExportManager manager;
private String importExportDataPathKey;
private final ActivityResultLauncher<Intent> requestImportPathLauncher =
registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
this::requestImportPathResult);
private final ActivityResultLauncher<Intent> requestExportPathLauncher =
registerForActivityResult(new ActivityResultContracts.StartActivityForResult(),
this::requestExportPathResult);
@Override
public void onCreatePreferences(@Nullable final Bundle savedInstanceState,
@Nullable final String rootKey) {
final File homeDir = ContextCompat.getDataDir(requireContext());
Objects.requireNonNull(homeDir);
manager = new ImportExportManager(new BackupFileLocator(homeDir));
importExportDataPathKey = getString(R.string.import_export_data_path);
addPreferencesFromResourceRegistry();
final Preference importDataPreference = requirePreference(R.string.import_data);
importDataPreference.setOnPreferenceClickListener((Preference p) -> {
NoFileManagerSafeGuard.launchSafe(
requestImportPathLauncher,
StoredFileHelper.getPicker(requireContext(),
ZIP_MIME_TYPE, getImportExportDataUri()),
TAG,
getContext()
);
return true;
});
final Preference exportDataPreference = requirePreference(R.string.export_data);
exportDataPreference.setOnPreferenceClickListener((final Preference p) -> {
NoFileManagerSafeGuard.launchSafe(
requestExportPathLauncher,
StoredFileHelper.getNewPicker(requireContext(),
"NewPipeData-" + exportDateFormat.format(new Date()) + ".zip",
ZIP_MIME_TYPE, getImportExportDataUri()),
TAG,
getContext()
);
return true;
});
final Preference resetSettings = findPreference(getString(R.string.reset_settings));
// Resets all settings by deleting shared preference and restarting the app
// A dialogue will pop up to confirm if user intends to reset all settings
assert resetSettings != null;
resetSettings.setOnPreferenceClickListener(preference -> {
// Show Alert Dialogue
final AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
builder.setMessage(R.string.reset_all_settings);
builder.setCancelable(true);
builder.setPositiveButton(R.string.ok, (dialogInterface, i) -> {
// Deletes all shared preferences xml files.
final SharedPreferences sharedPreferences =
PreferenceManager.getDefaultSharedPreferences(requireContext());
sharedPreferences.edit().clear().apply();
// Restarts the app
if (getActivity() == null) {
return;
}
NavigationHelper.restartApp(getActivity());
});
builder.setNegativeButton(R.string.cancel, (dialogInterface, i) -> {
});
final AlertDialog alertDialog = builder.create();
alertDialog.show();
return true;
});
}
private void requestExportPathResult(final ActivityResult result) {
assureCorrectAppLanguage(requireContext());
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
// will be saved only on success
final Uri lastExportDataUri = result.getData().getData();
final StoredFileHelper file = new StoredFileHelper(
requireContext(), result.getData().getData(), ZIP_MIME_TYPE);
exportDatabase(file, lastExportDataUri);
}
}
private void requestImportPathResult(final ActivityResult result) {
assureCorrectAppLanguage(requireContext());
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
// will be saved only on success
final Uri lastImportDataUri = result.getData().getData();
final StoredFileHelper file = new StoredFileHelper(
requireContext(), result.getData().getData(), ZIP_MIME_TYPE);
new androidx.appcompat.app.AlertDialog.Builder(requireActivity())
.setMessage(R.string.override_current_data)
.setPositiveButton(R.string.ok, (d, id) ->
importDatabase(file, lastImportDataUri))
.setNegativeButton(R.string.cancel, (d, id) ->
d.cancel())
.show();
}
}
private void exportDatabase(final StoredFileHelper file, final Uri exportDataUri) {
try {
//checkpoint before export
NewPipeDatabase.checkpoint();
final SharedPreferences preferences = PreferenceManager
.getDefaultSharedPreferences(requireContext());
manager.exportDatabase(preferences, file);
saveLastImportExportDataUri(exportDataUri); // save export path only on success
Toast.makeText(requireContext(), R.string.export_complete_toast, Toast.LENGTH_SHORT)
.show();
} catch (final Exception e) {
showErrorSnackbar(e, "Exporting database and settings");
}
}
private void importDatabase(final StoredFileHelper file, final Uri importDataUri) {
// check if file is supported
if (!ZipHelper.isValidZipFile(file)) {
Toast.makeText(requireContext(), R.string.no_valid_zip_file, Toast.LENGTH_SHORT)
.show();
return;
}
try {
if (!manager.ensureDbDirectoryExists()) {
throw new IOException("Could not create databases dir");
}
// replace the current database
if (!manager.extractDb(file)) {
Toast.makeText(requireContext(), R.string.could_not_import_all_files,
Toast.LENGTH_LONG)
.show();
}
// if settings file exist, ask if it should be imported.
final boolean hasJsonPrefs = manager.exportHasJsonPrefs(file);
if (hasJsonPrefs || manager.exportHasSerializedPrefs(file)) {
new androidx.appcompat.app.AlertDialog.Builder(requireContext())
.setTitle(R.string.import_settings)
.setMessage(hasJsonPrefs ? null : requireContext()
.getString(R.string.import_settings_vulnerable_format))
.setOnDismissListener(dialog -> finishImport(importDataUri))
.setNegativeButton(R.string.cancel, (dialog, which) -> {
dialog.dismiss();
finishImport(importDataUri);
})
.setPositiveButton(R.string.ok, (dialog, which) -> {
dialog.dismiss();
final Context context = requireContext();
final SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(context);
try {
if (hasJsonPrefs) {
manager.loadJsonPrefs(file, prefs);
} else {
manager.loadSerializedPrefs(file, prefs);
}
} catch (IOException | ClassNotFoundException | JsonParserException e) {
createErrorNotification(e, "Importing preferences");
return;
}
cleanImport(context, prefs);
finishImport(importDataUri);
})
.show();
} else {
finishImport(importDataUri);
}
} catch (final Exception e) {
showErrorSnackbar(e, "Importing database and settings");
}
}
/**
* Remove settings that are not supposed to be imported on different devices
* and reset them to default values.
* @param context the context used for the import
* @param prefs the preferences used while running the import
*/
private void cleanImport(@NonNull final Context context,
@NonNull final SharedPreferences prefs) {
// Check if media tunnelling needs to be disabled automatically,
// if it was disabled automatically in the imported preferences.
final String tunnelingKey = context.getString(R.string.disable_media_tunneling_key);
final String automaticTunnelingKey =
context.getString(R.string.disabled_media_tunneling_automatically_key);
// R.string.disable_media_tunneling_key should always be true
// if R.string.disabled_media_tunneling_automatically_key equals 1,
// but we double check here just to be sure and to avoid regressions
// caused by possible later modification of the media tunneling functionality.
// R.string.disabled_media_tunneling_automatically_key == 0:
// automatic value overridden by user in settings
// R.string.disabled_media_tunneling_automatically_key == -1: not set
final boolean wasMediaTunnelingDisabledAutomatically =
prefs.getInt(automaticTunnelingKey, -1) == 1
&& prefs.getBoolean(tunnelingKey, false);
if (wasMediaTunnelingDisabledAutomatically) {
prefs.edit()
.putInt(automaticTunnelingKey, -1)
.putBoolean(tunnelingKey, false)
.apply();
NewPipeSettings.setMediaTunneling(context);
}
}
/**
* Save import path and restart app.
*
* @param importDataUri The import path to save
*/
private void finishImport(final Uri importDataUri) {
// save import path only on success
saveLastImportExportDataUri(importDataUri);
// restart app to properly load db
NavigationHelper.restartApp(requireActivity());
}
private Uri getImportExportDataUri() {
final String path = defaultPreferences.getString(importExportDataPathKey, null);
return isBlank(path) ? null : Uri.parse(path);
}
private void saveLastImportExportDataUri(final Uri importExportDataUri) {
final SharedPreferences.Editor editor = defaultPreferences.edit()
.putString(importExportDataPathKey, importExportDataUri.toString());
editor.apply();
}
private void showErrorSnackbar(final Throwable e, final String request) {
ErrorUtil.showSnackbar(this, new ErrorInfo(e, UserAction.DATABASE_IMPORT_EXPORT, request));
}
private void createErrorNotification(final Throwable e, final String request) {
ErrorUtil.createNotification(
requireContext(),
new ErrorInfo(e, UserAction.DATABASE_IMPORT_EXPORT, request)
);
}
}

View file

@ -1,106 +1,36 @@
package org.schabi.newpipe.settings;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;
import android.widget.Toast;
import androidx.activity.result.ActivityResult;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
import androidx.core.content.ContextCompat;
import androidx.preference.Preference;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.DownloaderImpl;
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.localization.ContentCountry;
import org.schabi.newpipe.extractor.localization.Localization;
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard;
import org.schabi.newpipe.streams.io.StoredFileHelper;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.ZipHelper;
import org.schabi.newpipe.util.image.PreferredImageQuality;
import java.io.File;
import java.io.IOException;
import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;
import java.util.Objects;
public class ContentSettingsFragment extends BasePreferenceFragment {
private static final String ZIP_MIME_TYPE = "application/zip";
private final SimpleDateFormat exportDateFormat =
new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US);
private ContentSettingsManager manager;
private String importExportDataPathKey;
private String youtubeRestrictedModeEnabledKey;
private Localization initialSelectedLocalization;
private ContentCountry initialSelectedContentCountry;
private String initialLanguage;
private final ActivityResultLauncher<Intent> requestImportPathLauncher =
registerForActivityResult(new StartActivityForResult(), this::requestImportPathResult);
private final ActivityResultLauncher<Intent> requestExportPathLauncher =
registerForActivityResult(new StartActivityForResult(), this::requestExportPathResult);
@Override
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
final File homeDir = ContextCompat.getDataDir(requireContext());
Objects.requireNonNull(homeDir);
manager = new ContentSettingsManager(new NewPipeFileLocator(homeDir));
manager.deleteSettingsFile();
importExportDataPathKey = getString(R.string.import_export_data_path);
youtubeRestrictedModeEnabledKey = getString(R.string.youtube_restricted_mode_enabled);
addPreferencesFromResourceRegistry();
final Preference importDataPreference = requirePreference(R.string.import_data);
importDataPreference.setOnPreferenceClickListener((Preference p) -> {
NoFileManagerSafeGuard.launchSafe(
requestImportPathLauncher,
StoredFileHelper.getPicker(requireContext(),
ZIP_MIME_TYPE, getImportExportDataUri()),
TAG,
getContext()
);
return true;
});
final Preference exportDataPreference = requirePreference(R.string.export_data);
exportDataPreference.setOnPreferenceClickListener((final Preference p) -> {
NoFileManagerSafeGuard.launchSafe(
requestExportPathLauncher,
StoredFileHelper.getNewPicker(requireContext(),
"NewPipeData-" + exportDateFormat.format(new Date()) + ".zip",
ZIP_MIME_TYPE, getImportExportDataUri()),
TAG,
getContext()
);
return true;
});
initialSelectedLocalization = org.schabi.newpipe.util.Localization
.getPreferredLocalization(requireContext());
initialSelectedContentCountry = org.schabi.newpipe.util.Localization
@ -158,151 +88,4 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
NewPipe.setupLocalization(selectedLocalization, selectedContentCountry);
}
}
private void requestExportPathResult(final ActivityResult result) {
assureCorrectAppLanguage(getContext());
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
// will be saved only on success
final Uri lastExportDataUri = result.getData().getData();
final StoredFileHelper file =
new StoredFileHelper(getContext(), result.getData().getData(), ZIP_MIME_TYPE);
exportDatabase(file, lastExportDataUri);
}
}
private void requestImportPathResult(final ActivityResult result) {
assureCorrectAppLanguage(getContext());
if (result.getResultCode() == Activity.RESULT_OK && result.getData() != null) {
// will be saved only on success
final Uri lastImportDataUri = result.getData().getData();
final StoredFileHelper file =
new StoredFileHelper(getContext(), result.getData().getData(), ZIP_MIME_TYPE);
new AlertDialog.Builder(requireActivity())
.setMessage(R.string.override_current_data)
.setPositiveButton(R.string.ok, (d, id) ->
importDatabase(file, lastImportDataUri))
.setNegativeButton(R.string.cancel, (d, id) ->
d.cancel())
.show();
}
}
private void exportDatabase(final StoredFileHelper file, final Uri exportDataUri) {
try {
//checkpoint before export
NewPipeDatabase.checkpoint();
final SharedPreferences preferences = PreferenceManager
.getDefaultSharedPreferences(requireContext());
manager.exportDatabase(preferences, file);
saveLastImportExportDataUri(exportDataUri); // save export path only on success
Toast.makeText(getContext(), R.string.export_complete_toast, Toast.LENGTH_SHORT).show();
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(this, "Exporting database", e);
}
}
private void importDatabase(final StoredFileHelper file, final Uri importDataUri) {
// check if file is supported
if (!ZipHelper.isValidZipFile(file)) {
Toast.makeText(getContext(), R.string.no_valid_zip_file, Toast.LENGTH_SHORT)
.show();
return;
}
try {
if (!manager.ensureDbDirectoryExists()) {
throw new IOException("Could not create databases dir");
}
if (!manager.extractDb(file)) {
Toast.makeText(getContext(), R.string.could_not_import_all_files, Toast.LENGTH_LONG)
.show();
}
// if settings file exist, ask if it should be imported.
if (manager.extractSettings(file)) {
new AlertDialog.Builder(requireContext())
.setTitle(R.string.import_settings)
.setNegativeButton(R.string.cancel, (dialog, which) -> {
dialog.dismiss();
finishImport(importDataUri);
})
.setPositiveButton(R.string.ok, (dialog, which) -> {
dialog.dismiss();
final Context context = requireContext();
final SharedPreferences prefs = PreferenceManager
.getDefaultSharedPreferences(context);
manager.loadSharedPreferences(prefs);
cleanImport(context, prefs);
finishImport(importDataUri);
})
.show();
} else {
finishImport(importDataUri);
}
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(this, "Importing database", e);
}
}
/**
* Remove settings that are not supposed to be imported on different devices
* and reset them to default values.
* @param context the context used for the import
* @param prefs the preferences used while running the import
*/
private void cleanImport(@NonNull final Context context,
@NonNull final SharedPreferences prefs) {
// Check if media tunnelling needs to be disabled automatically,
// if it was disabled automatically in the imported preferences.
final String tunnelingKey = context.getString(R.string.disable_media_tunneling_key);
final String automaticTunnelingKey =
context.getString(R.string.disabled_media_tunneling_automatically_key);
// R.string.disable_media_tunneling_key should always be true
// if R.string.disabled_media_tunneling_automatically_key equals 1,
// but we double check here just to be sure and to avoid regressions
// caused by possible later modification of the media tunneling functionality.
// R.string.disabled_media_tunneling_automatically_key == 0:
// automatic value overridden by user in settings
// R.string.disabled_media_tunneling_automatically_key == -1: not set
final boolean wasMediaTunnelingDisabledAutomatically =
prefs.getInt(automaticTunnelingKey, -1) == 1
&& prefs.getBoolean(tunnelingKey, false);
if (wasMediaTunnelingDisabledAutomatically) {
prefs.edit()
.putInt(automaticTunnelingKey, -1)
.putBoolean(tunnelingKey, false)
.apply();
NewPipeSettings.setMediaTunneling(context);
}
}
/**
* Save import path and restart system.
*
* @param importDataUri The import path to save
*/
private void finishImport(final Uri importDataUri) {
// save import path only on success
saveLastImportExportDataUri(importDataUri);
// restart app to properly load db
NavigationHelper.restartApp(requireActivity());
}
private Uri getImportExportDataUri() {
final String path = defaultPreferences.getString(importExportDataPathKey, null);
return isBlank(path) ? null : Uri.parse(path);
}
private void saveLastImportExportDataUri(final Uri importExportDataUri) {
final SharedPreferences.Editor editor = defaultPreferences.edit()
.putString(importExportDataPathKey, importExportDataUri.toString());
editor.apply();
}
}

View file

@ -1,120 +0,0 @@
package org.schabi.newpipe.settings
import android.content.SharedPreferences
import android.util.Log
import org.schabi.newpipe.MainActivity.DEBUG
import org.schabi.newpipe.streams.io.SharpOutputStream
import org.schabi.newpipe.streams.io.StoredFileHelper
import org.schabi.newpipe.util.ZipHelper
import java.io.IOException
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
import java.util.zip.ZipOutputStream
class ContentSettingsManager(private val fileLocator: NewPipeFileLocator) {
companion object {
const val TAG = "ContentSetManager"
}
/**
* Exports given [SharedPreferences] to the file in given outputPath.
* It also creates the file.
*/
@Throws(Exception::class)
fun exportDatabase(preferences: SharedPreferences, file: StoredFileHelper) {
file.create()
ZipOutputStream(SharpOutputStream(file.stream).buffered())
.use { outZip ->
ZipHelper.addFileToZip(outZip, fileLocator.db.path, "newpipe.db")
try {
ObjectOutputStream(fileLocator.settings.outputStream()).use { output ->
output.writeObject(preferences.all)
output.flush()
}
} catch (e: IOException) {
if (DEBUG) {
Log.e(TAG, "Unable to exportDatabase", e)
}
}
ZipHelper.addFileToZip(outZip, fileLocator.settings.path, "newpipe.settings")
}
}
fun deleteSettingsFile() {
fileLocator.settings.delete()
}
/**
* Tries to create database directory if it does not exist.
*
* @return Whether the directory exists afterwards.
*/
fun ensureDbDirectoryExists(): Boolean {
return fileLocator.dbDir.exists() || fileLocator.dbDir.mkdir()
}
fun extractDb(file: StoredFileHelper): Boolean {
val success = ZipHelper.extractFileFromZip(file, fileLocator.db.path, "newpipe.db")
if (success) {
fileLocator.dbJournal.delete()
fileLocator.dbWal.delete()
fileLocator.dbShm.delete()
}
return success
}
fun extractSettings(file: StoredFileHelper): Boolean {
return ZipHelper.extractFileFromZip(file, fileLocator.settings.path, "newpipe.settings")
}
/**
* Remove all shared preferences from the app and load the preferences supplied to the manager.
*/
fun loadSharedPreferences(preferences: SharedPreferences) {
try {
val preferenceEditor = preferences.edit()
ObjectInputStream(fileLocator.settings.inputStream()).use { input ->
preferenceEditor.clear()
@Suppress("UNCHECKED_CAST")
val entries = input.readObject() as Map<String, *>
for ((key, value) in entries) {
when (value) {
is Boolean -> {
preferenceEditor.putBoolean(key, value)
}
is Float -> {
preferenceEditor.putFloat(key, value)
}
is Int -> {
preferenceEditor.putInt(key, value)
}
is Long -> {
preferenceEditor.putLong(key, value)
}
is String -> {
preferenceEditor.putString(key, value)
}
is Set<*> -> {
// There are currently only Sets with type String possible
@Suppress("UNCHECKED_CAST")
preferenceEditor.putStringSet(key, value as Set<String>?)
}
}
}
preferenceEditor.commit()
}
} catch (e: IOException) {
if (DEBUG) {
Log.e(TAG, "Unable to loadSharedPreferences", e)
}
} catch (e: ClassNotFoundException) {
if (DEBUG) {
Log.e(TAG, "Unable to loadSharedPreferences", e)
}
}
}
}

View file

@ -23,7 +23,7 @@ public class MainSettingsFragment extends BasePreferenceFragment {
setHasOptionsMenu(true); // Otherwise onCreateOptionsMenu is not called
// Check if the app is updatable
if (!ReleaseVersionUtil.isReleaseApk()) {
if (!ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
getPreferenceScreen().removePreference(
findPreference(getString(R.string.update_pref_screen_key)));

View file

@ -1,21 +0,0 @@
package org.schabi.newpipe.settings
import java.io.File
/**
* Locates specific files of NewPipe based on the home directory of the app.
*/
class NewPipeFileLocator(private val homeDir: File) {
val dbDir by lazy { File(homeDir, "/databases") }
val db by lazy { File(homeDir, "/databases/newpipe.db") }
val dbJournal by lazy { File(homeDir, "/databases/newpipe.db-journal") }
val dbShm by lazy { File(homeDir, "/databases/newpipe.db-shm") }
val dbWal by lazy { File(homeDir, "/databases/newpipe.db-wal") }
val settings by lazy { File(homeDir, "/databases/newpipe.settings") }
}

View file

@ -11,6 +11,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.StringRes;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
import org.schabi.newpipe.util.DeviceUtils;
@ -44,14 +45,8 @@ public final class NewPipeSettings {
private NewPipeSettings() { }
public static void initSettings(final Context context) {
// check if the last used preference version is set
// to determine whether this is the first app run
final int lastUsedPrefVersion = PreferenceManager.getDefaultSharedPreferences(context)
.getInt(context.getString(R.string.last_used_preferences_version), -1);
final boolean isFirstRun = lastUsedPrefVersion == -1;
// first run migrations, then setDefaultValues, since the latter requires the correct types
SettingMigrations.runMigrationsIfNeeded(context, isFirstRun);
SettingMigrations.runMigrationsIfNeeded(context);
// readAgain is true so that if new settings are added their default value is set
PreferenceManager.setDefaultValues(context, R.xml.main_settings, true);
@ -64,11 +59,12 @@ public final class NewPipeSettings {
PreferenceManager.setDefaultValues(context, R.xml.update_settings, true);
PreferenceManager.setDefaultValues(context, R.xml.debug_settings, true);
PreferenceManager.setDefaultValues(context, R.xml.sponsor_block_category_settings, true);
PreferenceManager.setDefaultValues(context, R.xml.backup_restore_settings, true);
saveDefaultVideoDownloadDirectory(context);
saveDefaultAudioDownloadDirectory(context);
disableMediaTunnelingIfNecessary(context, isFirstRun);
disableMediaTunnelingIfNecessary(context);
}
static void saveDefaultVideoDownloadDirectory(final Context context) {
@ -146,8 +142,7 @@ public final class NewPipeSettings {
R.string.show_remote_search_suggestions_key);
}
private static void disableMediaTunnelingIfNecessary(@NonNull final Context context,
final boolean isFirstRun) {
private static void disableMediaTunnelingIfNecessary(@NonNull final Context context) {
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
final String disabledTunnelingKey = context.getString(R.string.disable_media_tunneling_key);
final String disabledTunnelingAutomaticallyKey =
@ -162,7 +157,7 @@ public final class NewPipeSettings {
prefs.getInt(disabledTunnelingAutomaticallyKey, -1) == 0
&& !prefs.getBoolean(disabledTunnelingKey, false);
if (Boolean.TRUE.equals(isFirstRun)
if (App.getApp().isFirstRun()
|| (wasDeviceBlacklistUpdated && !wasMediaTunnelingEnabledByUser)) {
setMediaTunneling(context);
}

View file

@ -1,5 +1,7 @@
package org.schabi.newpipe.settings;
import static org.schabi.newpipe.local.bookmark.MergedPlaylistManager.getMergedOrderedPlaylists;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
@ -31,7 +33,6 @@ import java.util.List;
import java.util.Vector;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Flowable;
import io.reactivex.rxjava3.disposables.Disposable;
public class SelectPlaylistFragment extends DialogFragment {
@ -90,8 +91,7 @@ public class SelectPlaylistFragment extends DialogFragment {
final LocalPlaylistManager localPlaylistManager = new LocalPlaylistManager(database);
final RemotePlaylistManager remotePlaylistManager = new RemotePlaylistManager(database);
disposable = Flowable.combineLatest(localPlaylistManager.getPlaylists(),
remotePlaylistManager.getPlaylists(), PlaylistLocalItem::merge)
disposable = getMergedOrderedPlaylists(localPlaylistManager, remotePlaylistManager)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(this::displayPlaylists, this::onError);
}
@ -118,7 +118,7 @@ public class SelectPlaylistFragment extends DialogFragment {
if (selectedItem instanceof PlaylistMetadataEntry) {
final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem);
onSelectedListener.onLocalPlaylistSelected(entry.uid, entry.name);
onSelectedListener.onLocalPlaylistSelected(entry.getUid(), entry.name);
} else if (selectedItem instanceof PlaylistRemoteEntity) {
final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem);

View file

@ -7,6 +7,7 @@ import android.util.Log;
import androidx.annotation.NonNull;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil;
@ -163,15 +164,14 @@ public final class SettingMigrations {
private static final int VERSION = 6;
public static void runMigrationsIfNeeded(@NonNull final Context context,
final boolean isFirstRun) {
public static void runMigrationsIfNeeded(@NonNull final Context context) {
// setup migrations and check if there is something to do
sp = PreferenceManager.getDefaultSharedPreferences(context);
final String lastPrefVersionKey = context.getString(R.string.last_used_preferences_version);
final int lastPrefVersion = sp.getInt(lastPrefVersionKey, 0);
// no migration to run, already up to date
if (isFirstRun) {
if (App.getApp().isFirstRun()) {
sp.edit().putInt(lastPrefVersionKey, VERSION).apply();
return;
} else if (lastPrefVersion == VERSION) {

View file

@ -266,7 +266,7 @@ public class SettingsActivity extends AppCompatActivity implements
*/
private void ensureSearchRepresentsApplicationState() {
// Check if the update settings are available
if (!ReleaseVersionUtil.isReleaseApk()) {
if (!ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
SettingsResourceRegistry.getInstance()
.getEntryByPreferencesResId(R.xml.update_settings)
.setSearchable(false);

View file

@ -44,6 +44,7 @@ public final class SettingsResourceRegistry {
add(SponsorBlockSettingsFragment.class, R.xml.sponsor_block_settings);
add(SponsorBlockCategoriesSettingsFragment.class, R.xml.sponsor_block_category_settings);
add(ExtraSettingsFragment.class, R.xml.extra_settings);
add(BackupRestoreSettingsFragment.class, R.xml.backup_restore_settings);
}
private SettingRegistryEntry add(

View file

@ -1,9 +1,12 @@
package org.schabi.newpipe.settings;
import android.app.AlertDialog;
import android.content.Context;
import android.os.Bundle;
import android.widget.Toast;
import androidx.preference.Preference;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.NewVersionWorker;
import org.schabi.newpipe.R;
@ -36,4 +39,38 @@ public class UpdateSettingsFragment extends BasePreferenceFragment {
findPreference(getString(R.string.manual_update_key))
.setOnPreferenceClickListener(manualUpdateClick);
}
public static void askForConsentToUpdateChecks(final Context context) {
new AlertDialog.Builder(context)
.setTitle(context.getString(R.string.check_for_updates))
.setMessage(context.getString(R.string.auto_update_check_description))
.setPositiveButton(context.getString(R.string.yes), (d, w) -> {
d.dismiss();
setAutoUpdateCheckEnabled(context, true);
})
.setNegativeButton(R.string.no, (d, w) -> {
d.dismiss();
// set explicitly to false, since the default is true on previous versions
setAutoUpdateCheckEnabled(context, false);
})
.show();
}
private static void setAutoUpdateCheckEnabled(final Context context, final boolean enabled) {
PreferenceManager.getDefaultSharedPreferences(context)
.edit()
.putBoolean(context.getString(R.string.update_app_key), enabled)
.putBoolean(context.getString(R.string.update_check_consent_key), true)
.apply();
}
/**
* Whether the user was asked for consent to automatically check for app updates.
* @param context
* @return true if the user was asked for consent, false otherwise
*/
public static boolean wasUserAskedForConsent(final Context context) {
return PreferenceManager.getDefaultSharedPreferences(context)
.getBoolean(context.getString(R.string.update_check_consent_key), false);
}
}

View file

@ -5,35 +5,22 @@ import static org.schabi.newpipe.player.notification.NotificationConstants.ACTIO
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.res.ColorStateList;
import android.os.Build;
import android.util.AttributeSet;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.ImageView;
import android.widget.RadioButton;
import android.widget.RadioGroup;
import android.widget.TextView;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.widget.TextViewCompat;
import androidx.preference.Preference;
import androidx.preference.PreferenceViewHolder;
import org.schabi.newpipe.App;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding;
import org.schabi.newpipe.player.notification.NotificationConstants;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.views.FocusOverlayView;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.IntStream;
@ -45,8 +32,9 @@ public class NotificationActionsPreference extends Preference {
}
@Nullable private NotificationSlot[] notificationSlots = null;
@Nullable private List<Integer> compactSlots = null;
private NotificationSlot[] notificationSlots;
private List<Integer> compactSlots;
////////////////////////////////////////////////////////////////////////////
// Lifecycle
@ -56,6 +44,11 @@ public class NotificationActionsPreference extends Preference {
public void onBindViewHolder(@NonNull final PreferenceViewHolder holder) {
super.onBindViewHolder(holder);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
((TextView) holder.itemView.findViewById(R.id.summary))
.setText(R.string.notification_actions_summary_android13);
}
holder.itemView.setClickable(false);
setupActions(holder.itemView);
}
@ -75,13 +68,29 @@ public class NotificationActionsPreference extends Preference {
////////////////////////////////////////////////////////////////////////////
private void setupActions(@NonNull final View view) {
compactSlots = NotificationConstants.getCompactSlotsFromPreferences(getContext(),
getSharedPreferences(), 5);
compactSlots = new ArrayList<>(NotificationConstants.getCompactSlotsFromPreferences(
getContext(), getSharedPreferences()));
notificationSlots = IntStream.range(0, 5)
.mapToObj(i -> new NotificationSlot(i, view))
.mapToObj(i -> new NotificationSlot(getContext(), getSharedPreferences(), i, view,
compactSlots.contains(i), this::onToggleCompactSlot))
.toArray(NotificationSlot[]::new);
}
private void onToggleCompactSlot(final int i, final CheckBox checkBox) {
if (checkBox.isChecked()) {
compactSlots.remove((Integer) i);
} else if (compactSlots.size() < 3) {
compactSlots.add(i);
} else {
Toast.makeText(getContext(),
R.string.notification_actions_at_most_three,
Toast.LENGTH_SHORT).show();
return;
}
checkBox.toggle();
}
////////////////////////////////////////////////////////////////////////////
// Saving
@ -99,143 +108,10 @@ public class NotificationActionsPreference extends Preference {
for (int i = 0; i < 5; i++) {
editor.putInt(getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]),
notificationSlots[i].selectedAction);
notificationSlots[i].getSelectedAction());
}
editor.apply();
}
}
////////////////////////////////////////////////////////////////////////////
// Notification action
////////////////////////////////////////////////////////////////////////////
private static final int[] SLOT_ITEMS = {
R.id.notificationAction0,
R.id.notificationAction1,
R.id.notificationAction2,
R.id.notificationAction3,
R.id.notificationAction4,
};
private static final int[] SLOT_TITLES = {
R.string.notification_action_0_title,
R.string.notification_action_1_title,
R.string.notification_action_2_title,
R.string.notification_action_3_title,
R.string.notification_action_4_title,
};
private class NotificationSlot {
final int i;
@NotificationConstants.Action int selectedAction;
ImageView icon;
TextView summary;
NotificationSlot(final int actionIndex, final View parentView) {
this.i = actionIndex;
final View view = parentView.findViewById(SLOT_ITEMS[i]);
setupSelectedAction(view);
setupTitle(view);
setupCheckbox(view);
}
void setupTitle(final View view) {
((TextView) view.findViewById(R.id.notificationActionTitle))
.setText(SLOT_TITLES[i]);
view.findViewById(R.id.notificationActionClickableArea).setOnClickListener(
v -> openActionChooserDialog());
}
void setupCheckbox(final View view) {
final CheckBox compactSlotCheckBox = view.findViewById(R.id.notificationActionCheckBox);
compactSlotCheckBox.setChecked(compactSlots.contains(i));
view.findViewById(R.id.notificationActionCheckBoxClickableArea).setOnClickListener(
v -> {
if (compactSlotCheckBox.isChecked()) {
compactSlots.remove((Integer) i);
} else if (compactSlots.size() < 3) {
compactSlots.add(i);
} else {
Toast.makeText(getContext(),
R.string.notification_actions_at_most_three,
Toast.LENGTH_SHORT).show();
return;
}
compactSlotCheckBox.toggle();
});
}
void setupSelectedAction(final View view) {
icon = view.findViewById(R.id.notificationActionIcon);
summary = view.findViewById(R.id.notificationActionSummary);
selectedAction = getSharedPreferences().getInt(
getContext().getString(NotificationConstants.SLOT_PREF_KEYS[i]),
NotificationConstants.SLOT_DEFAULTS[i]);
updateInfo();
}
void updateInfo() {
if (NotificationConstants.ACTION_ICONS[selectedAction] == 0) {
icon.setImageDrawable(null);
} else {
icon.setImageDrawable(AppCompatResources.getDrawable(getContext(),
NotificationConstants.ACTION_ICONS[selectedAction]));
}
summary.setText(NotificationConstants.getActionName(getContext(), selectedAction));
}
void openActionChooserDialog() {
final LayoutInflater inflater = LayoutInflater.from(getContext());
final SingleChoiceDialogViewBinding binding =
SingleChoiceDialogViewBinding.inflate(inflater);
final AlertDialog alertDialog = new AlertDialog.Builder(getContext())
.setTitle(SLOT_TITLES[i])
.setView(binding.getRoot())
.setCancelable(true)
.create();
final View.OnClickListener radioButtonsClickListener = v -> {
selectedAction = NotificationConstants.SLOT_ALLOWED_ACTIONS[i][v.getId()];
updateInfo();
alertDialog.dismiss();
};
for (int id = 0; id < NotificationConstants.SLOT_ALLOWED_ACTIONS[i].length; ++id) {
final int action = NotificationConstants.SLOT_ALLOWED_ACTIONS[i][id];
final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater)
.getRoot();
// if present set action icon with correct color
final int iconId = NotificationConstants.ACTION_ICONS[action];
if (iconId != 0) {
radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, iconId, 0);
final var color = ColorStateList.valueOf(ThemeHelper
.resolveColorFromAttr(getContext(), android.R.attr.textColorPrimary));
TextViewCompat.setCompoundDrawableTintList(radioButton, color);
}
radioButton.setText(NotificationConstants.getActionName(getContext(), action));
radioButton.setChecked(action == selectedAction);
radioButton.setId(id);
radioButton.setLayoutParams(new RadioGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
radioButton.setOnClickListener(radioButtonsClickListener);
binding.list.addView(radioButton);
}
alertDialog.show();
if (DeviceUtils.isTv(getContext())) {
FocusOverlayView.setupFocusObserver(alertDialog);
}
}
}
}

View file

@ -0,0 +1,172 @@
package org.schabi.newpipe.settings.custom;
import android.content.Context;
import android.content.SharedPreferences;
import android.content.res.ColorStateList;
import android.os.Build;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.CheckBox;
import android.widget.ImageView;
import android.widget.RadioButton;
import android.widget.RadioGroup;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.core.widget.TextViewCompat;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding;
import org.schabi.newpipe.player.notification.NotificationConstants;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.views.FocusOverlayView;
import java.util.Objects;
import java.util.function.BiConsumer;
class NotificationSlot {
private static final int[] SLOT_ITEMS = {
R.id.notificationAction0,
R.id.notificationAction1,
R.id.notificationAction2,
R.id.notificationAction3,
R.id.notificationAction4,
};
private static final int[] SLOT_TITLES = {
R.string.notification_action_0_title,
R.string.notification_action_1_title,
R.string.notification_action_2_title,
R.string.notification_action_3_title,
R.string.notification_action_4_title,
};
private final int i;
private @NotificationConstants.Action int selectedAction;
private final Context context;
private final BiConsumer<Integer, CheckBox> onToggleCompactSlot;
private ImageView icon;
private TextView summary;
NotificationSlot(final Context context,
final SharedPreferences prefs,
final int actionIndex,
final View parentView,
final boolean isCompactSlotChecked,
final BiConsumer<Integer, CheckBox> onToggleCompactSlot) {
this.context = context;
this.i = actionIndex;
this.onToggleCompactSlot = onToggleCompactSlot;
selectedAction = Objects.requireNonNull(prefs).getInt(
context.getString(NotificationConstants.SLOT_PREF_KEYS[i]),
NotificationConstants.SLOT_DEFAULTS[i]);
final View view = parentView.findViewById(SLOT_ITEMS[i]);
// only show the last two notification slots on Android 13+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU || i >= 3) {
setupSelectedAction(view);
setupTitle(view);
setupCheckbox(view, isCompactSlotChecked);
} else {
view.setVisibility(View.GONE);
}
}
void setupTitle(final View view) {
((TextView) view.findViewById(R.id.notificationActionTitle))
.setText(SLOT_TITLES[i]);
view.findViewById(R.id.notificationActionClickableArea).setOnClickListener(
v -> openActionChooserDialog());
}
void setupCheckbox(final View view, final boolean isCompactSlotChecked) {
final CheckBox compactSlotCheckBox = view.findViewById(R.id.notificationActionCheckBox);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
// there are no compact slots to customize on Android 13+
compactSlotCheckBox.setVisibility(View.GONE);
view.findViewById(R.id.notificationActionCheckBoxClickableArea)
.setVisibility(View.GONE);
return;
}
compactSlotCheckBox.setChecked(isCompactSlotChecked);
view.findViewById(R.id.notificationActionCheckBoxClickableArea).setOnClickListener(
v -> onToggleCompactSlot.accept(i, compactSlotCheckBox));
}
void setupSelectedAction(final View view) {
icon = view.findViewById(R.id.notificationActionIcon);
summary = view.findViewById(R.id.notificationActionSummary);
updateInfo();
}
void updateInfo() {
if (NotificationConstants.ACTION_ICONS[selectedAction] == 0) {
icon.setImageDrawable(null);
} else {
icon.setImageDrawable(AppCompatResources.getDrawable(context,
NotificationConstants.ACTION_ICONS[selectedAction]));
}
summary.setText(NotificationConstants.getActionName(context, selectedAction));
}
void openActionChooserDialog() {
final LayoutInflater inflater = LayoutInflater.from(context);
final SingleChoiceDialogViewBinding binding =
SingleChoiceDialogViewBinding.inflate(inflater);
final AlertDialog alertDialog = new AlertDialog.Builder(context)
.setTitle(SLOT_TITLES[i])
.setView(binding.getRoot())
.setCancelable(true)
.create();
final View.OnClickListener radioButtonsClickListener = v -> {
selectedAction = NotificationConstants.ALL_ACTIONS[v.getId()];
updateInfo();
alertDialog.dismiss();
};
for (int id = 0; id < NotificationConstants.ALL_ACTIONS.length; ++id) {
final int action = NotificationConstants.ALL_ACTIONS[id];
final RadioButton radioButton = ListRadioIconItemBinding.inflate(inflater)
.getRoot();
// if present set action icon with correct color
final int iconId = NotificationConstants.ACTION_ICONS[action];
if (iconId != 0) {
radioButton.setCompoundDrawablesRelativeWithIntrinsicBounds(0, 0, iconId, 0);
final var color = ColorStateList.valueOf(ThemeHelper
.resolveColorFromAttr(context, android.R.attr.textColorPrimary));
TextViewCompat.setCompoundDrawableTintList(radioButton, color);
}
radioButton.setText(NotificationConstants.getActionName(context, action));
radioButton.setChecked(action == selectedAction);
radioButton.setId(id);
radioButton.setLayoutParams(new RadioGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT));
radioButton.setOnClickListener(radioButtonsClickListener);
binding.list.addView(radioButton);
}
alertDialog.show();
if (DeviceUtils.isTv(context)) {
FocusOverlayView.setupFocusObserver(alertDialog);
}
}
@NotificationConstants.Action
public int getSelectedAction() {
return selectedAction;
}
}

View file

@ -0,0 +1,28 @@
package org.schabi.newpipe.settings.export
import java.io.File
/**
* Locates specific files of NewPipe based on the home directory of the app.
*/
class BackupFileLocator(private val homeDir: File) {
companion object {
const val FILE_NAME_DB = "newpipe.db"
@Deprecated(
"Serializing preferences with Java's ObjectOutputStream is vulnerable to injections",
replaceWith = ReplaceWith("FILE_NAME_JSON_PREFS")
)
const val FILE_NAME_SERIALIZED_PREFS = "newpipe.settings"
const val FILE_NAME_JSON_PREFS = "preferences.json"
}
val dbDir by lazy { File(homeDir, "/databases") }
val db by lazy { File(dbDir, FILE_NAME_DB) }
val dbJournal by lazy { File(dbDir, "$FILE_NAME_DB-journal") }
val dbShm by lazy { File(dbDir, "$FILE_NAME_DB-shm") }
val dbWal by lazy { File(dbDir, "$FILE_NAME_DB-wal") }
}

View file

@ -0,0 +1,180 @@
package org.schabi.newpipe.settings.export
import android.content.SharedPreferences
import com.grack.nanojson.JsonArray
import com.grack.nanojson.JsonParser
import com.grack.nanojson.JsonParserException
import com.grack.nanojson.JsonWriter
import org.schabi.newpipe.streams.io.SharpOutputStream
import org.schabi.newpipe.streams.io.StoredFileHelper
import org.schabi.newpipe.util.ZipHelper
import java.io.FileNotFoundException
import java.io.IOException
import java.io.ObjectOutputStream
import java.util.zip.ZipOutputStream
class ImportExportManager(private val fileLocator: BackupFileLocator) {
companion object {
const val TAG = "ImportExportManager"
}
/**
* Exports given [SharedPreferences] to the file in given outputPath.
* It also creates the file.
*/
@Throws(Exception::class)
fun exportDatabase(preferences: SharedPreferences, file: StoredFileHelper) {
file.create()
ZipOutputStream(SharpOutputStream(file.stream).buffered()).use { outZip ->
// add the database
ZipHelper.addFileToZip(
outZip,
BackupFileLocator.FILE_NAME_DB,
fileLocator.db.path,
)
// add the legacy vulnerable serialized preferences (will be removed in the future)
ZipHelper.addFileToZip(
outZip,
BackupFileLocator.FILE_NAME_SERIALIZED_PREFS
) { byteOutput ->
ObjectOutputStream(byteOutput).use { output ->
output.writeObject(preferences.all)
output.flush()
}
}
// add the JSON preferences
ZipHelper.addFileToZip(
outZip,
BackupFileLocator.FILE_NAME_JSON_PREFS
) { byteOutput ->
JsonWriter
.indent("")
.on(byteOutput)
.`object`(preferences.all)
.done()
}
}
}
/**
* Tries to create database directory if it does not exist.
*
* @return Whether the directory exists afterwards.
*/
fun ensureDbDirectoryExists(): Boolean {
return fileLocator.dbDir.exists() || fileLocator.dbDir.mkdir()
}
/**
* Extracts the database from the given file to the app's database directory.
* The current app's database will be overwritten.
* @param file the .zip file to extract the database from
* @return true if the database was successfully extracted, false otherwise
*/
fun extractDb(file: StoredFileHelper): Boolean {
val success = ZipHelper.extractFileFromZip(
file,
BackupFileLocator.FILE_NAME_DB,
fileLocator.db.path,
)
if (success) {
fileLocator.dbJournal.delete()
fileLocator.dbWal.delete()
fileLocator.dbShm.delete()
}
return success
}
@Deprecated(
"Serializing preferences with Java's ObjectOutputStream is vulnerable to injections",
replaceWith = ReplaceWith("exportHasJsonPrefs")
)
fun exportHasSerializedPrefs(zipFile: StoredFileHelper): Boolean {
return ZipHelper.zipContainsFile(zipFile, BackupFileLocator.FILE_NAME_SERIALIZED_PREFS)
}
fun exportHasJsonPrefs(zipFile: StoredFileHelper): Boolean {
return ZipHelper.zipContainsFile(zipFile, BackupFileLocator.FILE_NAME_JSON_PREFS)
}
/**
* Remove all shared preferences from the app and load the preferences supplied to the manager.
*/
@Deprecated(
"Serializing preferences with Java's ObjectOutputStream is vulnerable to injections",
replaceWith = ReplaceWith("loadJsonPrefs")
)
@Throws(IOException::class, ClassNotFoundException::class)
fun loadSerializedPrefs(zipFile: StoredFileHelper, preferences: SharedPreferences) {
ZipHelper.extractFileFromZip(zipFile, BackupFileLocator.FILE_NAME_SERIALIZED_PREFS) {
PreferencesObjectInputStream(it).use { input ->
@Suppress("UNCHECKED_CAST")
val entries = input.readObject() as Map<String, *>
val editor = preferences.edit()
editor.clear()
for ((key, value) in entries) {
when (value) {
is Boolean -> editor.putBoolean(key, value)
is Float -> editor.putFloat(key, value)
is Int -> editor.putInt(key, value)
is Long -> editor.putLong(key, value)
is String -> editor.putString(key, value)
is Set<*> -> {
// There are currently only Sets with type String possible
@Suppress("UNCHECKED_CAST")
editor.putStringSet(key, value as Set<String>?)
}
}
}
if (!editor.commit()) {
throw IOException("Unable to commit loadSerializedPrefs")
}
}
}.let { fileExists ->
if (!fileExists) {
throw FileNotFoundException(BackupFileLocator.FILE_NAME_SERIALIZED_PREFS)
}
}
}
/**
* Remove all shared preferences from the app and load the preferences supplied to the manager.
*/
@Throws(IOException::class, JsonParserException::class)
fun loadJsonPrefs(zipFile: StoredFileHelper, preferences: SharedPreferences) {
ZipHelper.extractFileFromZip(zipFile, BackupFileLocator.FILE_NAME_JSON_PREFS) {
val jsonObject = JsonParser.`object`().from(it)
val editor = preferences.edit()
editor.clear()
for ((key, value) in jsonObject) {
when (value) {
is Boolean -> editor.putBoolean(key, value)
is Float -> editor.putFloat(key, value)
is Int -> editor.putInt(key, value)
is Long -> editor.putLong(key, value)
is String -> editor.putString(key, value)
is JsonArray -> {
editor.putStringSet(key, value.mapNotNull { e -> e as? String }.toSet())
}
}
}
if (!editor.commit()) {
throw IOException("Unable to commit loadJsonPrefs")
}
}.let { fileExists ->
if (!fileExists) {
throw FileNotFoundException(BackupFileLocator.FILE_NAME_JSON_PREFS)
}
}
}
}

View file

@ -0,0 +1,58 @@
package org.schabi.newpipe.settings.export;
import java.io.IOException;
import java.io.InputStream;
import java.io.ObjectInputStream;
import java.io.ObjectStreamClass;
import java.util.Set;
/**
* An {@link ObjectInputStream} that only allows preferences-related types to be deserialized, to
* prevent injections. The only allowed types are: all primitive types, all boxed primitive types,
* null, strings. HashMap, HashSet and arrays of previously defined types are also allowed. Sources:
* <a href="https://wiki.sei.cmu.edu/confluence/display/java/SER00-J.+Enable+serialization+compatibility+during+class+evolution">
* cmu.edu
* </a>,
* <a href="https://cheatsheetseries.owasp.org/cheatsheets/Deserialization_Cheat_Sheet.html#harden-your-own-javaioobjectinputstream">
* OWASP cheatsheet
* </a>,
* <a href="https://commons.apache.org/proper/commons-io/apidocs/src-html/org/apache/commons/io/serialization/ValidatingObjectInputStream.html#line-118">
* Apache's {@code ValidatingObjectInputStream}
* </a>
*/
public class PreferencesObjectInputStream extends ObjectInputStream {
/**
* Primitive types, strings and other built-in types do not pass through resolveClass() but
* instead have a custom encoding; see
* <a href="https://docs.oracle.com/javase/6/docs/platform/serialization/spec/protocol.html#10152">
* official docs</a>.
*/
private static final Set<String> CLASS_WHITELIST = Set.of(
"java.lang.Boolean",
"java.lang.Byte",
"java.lang.Character",
"java.lang.Short",
"java.lang.Integer",
"java.lang.Long",
"java.lang.Float",
"java.lang.Double",
"java.lang.Void",
"java.util.HashMap",
"java.util.HashSet"
);
public PreferencesObjectInputStream(final InputStream in) throws IOException {
super(in);
}
@Override
protected Class<?> resolveClass(final ObjectStreamClass desc)
throws ClassNotFoundException, IOException {
if (CLASS_WHITELIST.contains(desc.getName())) {
return super.resolveClass(desc);
} else {
throw new ClassNotFoundException("Class not allowed: " + desc.getName());
}
}
}

View file

@ -1,11 +1,18 @@
package org.schabi.newpipe.streams.io;
import static android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME;
import static android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.ParcelFileDescriptor;
import android.provider.DocumentsContract;
import android.system.Os;
import android.system.StructStatVfs;
import android.util.Log;
import androidx.annotation.NonNull;
@ -15,6 +22,7 @@ import androidx.documentfile.provider.DocumentFile;
import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.util.FilePickerActivityHelper;
import java.io.FileDescriptor;
import java.io.IOException;
import java.net.URI;
import java.nio.file.Files;
@ -26,10 +34,6 @@ import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static android.provider.DocumentsContract.Document.COLUMN_DISPLAY_NAME;
import static android.provider.DocumentsContract.Root.COLUMN_DOCUMENT_ID;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
public class StoredDirectoryHelper {
private static final String TAG = StoredDirectoryHelper.class.getSimpleName();
public static final int PERMISSION_FLAGS = Intent.FLAG_GRANT_READ_URI_PERMISSION
@ -38,6 +42,10 @@ public class StoredDirectoryHelper {
private Path ioTree;
private DocumentFile docTree;
/**
* Context is `null` for non-SAF files, i.e. files that use `ioTree`.
*/
@Nullable
private Context context;
private final String tag;
@ -168,6 +176,46 @@ public class StoredDirectoryHelper {
return docTree == null;
}
/**
* Get free memory of the storage partition this file belongs to (root of the directory).
* See <a href="https://stackoverflow.com/q/31171838">StackOverflow</a> and
* <a href="https://pubs.opengroup.org/onlinepubs/9699919799/functions/fstatvfs.html">
* {@code statvfs()} and {@code fstatvfs()} docs</a>
*
* @return amount of free memory in the volume of current directory (bytes), or {@link
* Long#MAX_VALUE} if an error occurred
*/
public long getFreeStorageSpace() {
try {
final StructStatVfs stat;
if (ioTree != null) {
// non-SAF file, use statvfs with the path directly (also, `context` would be null
// for non-SAF files, so we wouldn't be able to call `getContentResolver` anyway)
stat = Os.statvfs(ioTree.toString());
} else {
// SAF file, we can't get a path directly, so obtain a file descriptor first
// and then use fstatvfs with the file descriptor
try (ParcelFileDescriptor parcelFileDescriptor =
context.getContentResolver().openFileDescriptor(getUri(), "r")) {
if (parcelFileDescriptor == null) {
return Long.MAX_VALUE;
}
final FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
stat = Os.fstatvfs(fileDescriptor);
}
}
// this is the same formula used inside the FsStat class
return stat.f_bavail * stat.f_frsize;
} catch (final Throwable e) {
// ignore any error
Log.e(TAG, "Could not get free storage space", e);
return Long.MAX_VALUE;
}
}
/**
* Only using Java I/O. Creates the directory named by this abstract pathname, including any
* necessary but nonexistent parent directories.

View file

@ -27,6 +27,7 @@ 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 androidx.preference.PreferenceManager;
@ -113,14 +114,14 @@ public final class ExtractorHelper {
public static Single<StreamInfo> getStreamInfo(final int serviceId, final String url,
final boolean forceLoad) {
checkServiceId(serviceId);
return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.STREAM,
return checkCache(forceLoad, serviceId, url, InfoCache.Type.STREAM,
Single.fromCallable(() -> StreamInfo.getInfo(NewPipe.getService(serviceId), url)));
}
public static Single<ChannelInfo> getChannelInfo(final int serviceId, final String url,
final boolean forceLoad) {
checkServiceId(serviceId);
return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.CHANNEL,
return checkCache(forceLoad, serviceId, url, InfoCache.Type.CHANNEL,
Single.fromCallable(() ->
ChannelInfo.getInfo(NewPipe.getService(serviceId), url)));
}
@ -130,7 +131,7 @@ public final class ExtractorHelper {
final boolean forceLoad) {
checkServiceId(serviceId);
return checkCache(forceLoad, serviceId,
listLinkHandler.getUrl(), InfoItem.InfoType.CHANNEL,
listLinkHandler.getUrl(), InfoCache.Type.CHANNEL_TAB,
Single.fromCallable(() ->
ChannelTabInfo.getInfo(NewPipe.getService(serviceId), listLinkHandler)));
}
@ -145,10 +146,11 @@ public final class ExtractorHelper {
listLinkHandler, nextPage));
}
public static Single<CommentsInfo> getCommentsInfo(final int serviceId, final String url,
public static Single<CommentsInfo> getCommentsInfo(final int serviceId,
final String url,
final boolean forceLoad) {
checkServiceId(serviceId);
return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.COMMENT,
return checkCache(forceLoad, serviceId, url, InfoCache.Type.COMMENTS,
Single.fromCallable(() ->
CommentsInfo.getInfo(NewPipe.getService(serviceId), url)));
}
@ -175,7 +177,7 @@ public final class ExtractorHelper {
final String url,
final boolean forceLoad) {
checkServiceId(serviceId);
return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.PLAYLIST,
return checkCache(forceLoad, serviceId, url, InfoCache.Type.PLAYLIST,
Single.fromCallable(() ->
PlaylistInfo.getInfo(NewPipe.getService(serviceId), url)));
}
@ -188,9 +190,10 @@ public final class ExtractorHelper {
PlaylistInfo.getMoreItems(NewPipe.getService(serviceId), url, nextPage));
}
public static Single<KioskInfo> getKioskInfo(final int serviceId, final String url,
public static Single<KioskInfo> getKioskInfo(final int serviceId,
final String url,
final boolean forceLoad) {
return checkCache(forceLoad, serviceId, url, InfoItem.InfoType.PLAYLIST,
return checkCache(forceLoad, serviceId, url, InfoCache.Type.KIOSK,
Single.fromCallable(() -> KioskInfo.getInfo(NewPipe.getService(serviceId), url)));
}
@ -202,7 +205,7 @@ public final class ExtractorHelper {
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
// Cache
//////////////////////////////////////////////////////////////////////////*/
/**
@ -214,24 +217,25 @@ public final class ExtractorHelper {
* @param forceLoad whether to force loading from the network instead of from the cache
* @param serviceId the service to load from
* @param url the URL to load
* @param infoType the {@link InfoItem.InfoType} of the item
* @param cacheType the {@link InfoCache.Type} of the item
* @param loadFromNetwork the {@link Single} to load the item from the network
* @return a {@link Single} that loads the item
*/
private static <I extends Info> Single<I> checkCache(final boolean forceLoad,
final int serviceId, final String url,
final InfoItem.InfoType infoType,
final Single<I> loadFromNetwork) {
final int serviceId,
@NonNull final String url,
@NonNull final InfoCache.Type cacheType,
@NonNull final Single<I> loadFromNetwork) {
checkServiceId(serviceId);
final Single<I> actualLoadFromNetwork = loadFromNetwork
.doOnSuccess(info -> CACHE.putInfo(serviceId, url, info, infoType));
.doOnSuccess(info -> CACHE.putInfo(serviceId, url, info, cacheType));
final Single<I> load;
if (forceLoad) {
CACHE.removeInfo(serviceId, url, infoType);
CACHE.removeInfo(serviceId, url, cacheType);
load = actualLoadFromNetwork;
} else {
load = Maybe.concat(ExtractorHelper.loadFromCache(serviceId, url, infoType),
load = Maybe.concat(ExtractorHelper.loadFromCache(serviceId, url, cacheType),
actualLoadFromNetwork.toMaybe())
.firstElement() // Take the first valid
.toSingle();
@ -246,15 +250,17 @@ public final class ExtractorHelper {
* @param <I> the item type's class that extends {@link Info}
* @param serviceId the service to load from
* @param url the URL to load
* @param infoType the {@link InfoItem.InfoType} of the item
* @param cacheType the {@link InfoCache.Type} of the item
* @return a {@link Single} that loads the item
*/
private static <I extends Info> Maybe<I> loadFromCache(final int serviceId, final String url,
final InfoItem.InfoType infoType) {
private static <I extends Info> Maybe<I> loadFromCache(
final int serviceId,
@NonNull final String url,
@NonNull final InfoCache.Type cacheType) {
checkServiceId(serviceId);
return Maybe.defer(() -> {
//noinspection unchecked
final I info = (I) CACHE.getFromKey(serviceId, url, infoType);
final I info = (I) CACHE.getFromKey(serviceId, url, cacheType);
if (MainActivity.DEBUG) {
Log.d(TAG, "loadFromCache() called, info > " + info);
}
@ -268,11 +274,17 @@ public final class ExtractorHelper {
});
}
public static boolean isCached(final int serviceId, final String url,
final InfoItem.InfoType infoType) {
return null != loadFromCache(serviceId, url, infoType).blockingGet();
public static boolean isCached(final int serviceId,
@NonNull final String url,
@NonNull final InfoCache.Type cacheType) {
return null != loadFromCache(serviceId, url, cacheType).blockingGet();
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
/**
* Formats the text contained in the meta info list as HTML and puts it into the text view,
* while also making the separator visible. If the list is null or empty, or the user chose not

View file

@ -27,7 +27,6 @@ import androidx.collection.LruCache;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.extractor.Info;
import org.schabi.newpipe.extractor.InfoItem;
import java.util.Map;
@ -48,14 +47,27 @@ public final class InfoCache {
// no instance
}
/**
* Identifies the type of {@link Info} to put into the cache.
*/
public enum Type {
STREAM,
CHANNEL,
CHANNEL_TAB,
COMMENTS,
PLAYLIST,
KIOSK,
}
public static InfoCache getInstance() {
return INSTANCE;
}
@NonNull
private static String keyOf(final int serviceId, @NonNull final String url,
@NonNull final InfoItem.InfoType infoType) {
return serviceId + url + infoType.toString();
private static String keyOf(final int serviceId,
@NonNull final String url,
@NonNull final Type cacheType) {
return serviceId + ":" + cacheType.ordinal() + ":" + url;
}
private static void removeStaleCache() {
@ -83,19 +95,22 @@ public final class InfoCache {
}
@Nullable
public Info getFromKey(final int serviceId, @NonNull final String url,
@NonNull final InfoItem.InfoType infoType) {
public Info getFromKey(final int serviceId,
@NonNull final String url,
@NonNull final Type cacheType) {
if (DEBUG) {
Log.d(TAG, "getFromKey() called with: "
+ "serviceId = [" + serviceId + "], url = [" + url + "]");
}
synchronized (LRU_CACHE) {
return getInfo(keyOf(serviceId, url, infoType));
return getInfo(keyOf(serviceId, url, cacheType));
}
}
public void putInfo(final int serviceId, @NonNull final String url, @NonNull final Info info,
@NonNull final InfoItem.InfoType infoType) {
public void putInfo(final int serviceId,
@NonNull final String url,
@NonNull final Info info,
@NonNull final Type cacheType) {
if (DEBUG) {
Log.d(TAG, "putInfo() called with: info = [" + info + "]");
}
@ -103,18 +118,19 @@ public final class InfoCache {
final long expirationMillis = ServiceHelper.getCacheExpirationMillis(info.getServiceId());
synchronized (LRU_CACHE) {
final CacheData data = new CacheData(info, expirationMillis);
LRU_CACHE.put(keyOf(serviceId, url, infoType), data);
LRU_CACHE.put(keyOf(serviceId, url, cacheType), data);
}
}
public void removeInfo(final int serviceId, @NonNull final String url,
@NonNull final InfoItem.InfoType infoType) {
public void removeInfo(final int serviceId,
@NonNull final String url,
@NonNull final Type cacheType) {
if (DEBUG) {
Log.d(TAG, "removeInfo() called with: "
+ "serviceId = [" + serviceId + "], url = [" + url + "]");
}
synchronized (LRU_CACHE) {
LRU_CACHE.remove(keyOf(serviceId, url, infoType));
LRU_CACHE.remove(keyOf(serviceId, url, cacheType));
}
}

View file

@ -643,6 +643,7 @@ public final class ListHelper {
context.getString(R.string.best_resolution_key), defaultFormat, videoStreams);
}
@Nullable
private static MediaFormat getDefaultFormat(@NonNull final Context context,
@StringRes final int defaultFormatKey,
@StringRes final int defaultFormatValueKey) {
@ -651,18 +652,14 @@ public final class ListHelper {
final String defaultFormat = context.getString(defaultFormatValueKey);
final String defaultFormatString = preferences.getString(
context.getString(defaultFormatKey), defaultFormat);
context.getString(defaultFormatKey),
defaultFormat
);
MediaFormat defaultMediaFormat = getMediaFormatFromKey(context, defaultFormatString);
if (defaultMediaFormat == null) {
preferences.edit().putString(context.getString(defaultFormatKey), defaultFormat)
.apply();
defaultMediaFormat = getMediaFormatFromKey(context, defaultFormat);
}
return defaultMediaFormat;
return getMediaFormatFromKey(context, defaultFormatString);
}
@Nullable
private static MediaFormat getMediaFormatFromKey(@NonNull final Context context,
@NonNull final String formatKey) {
MediaFormat format = null;
@ -877,6 +874,7 @@ public final class ListHelper {
return Comparator.comparing(AudioStream::getAudioLocale, Comparator.nullsLast(
Comparator.comparing(locale -> locale.getDisplayName(appLoc))))
.thenComparing(AudioStream::getAudioTrackType);
.thenComparing(AudioStream::getAudioTrackType, Comparator.nullsLast(
Comparator.naturalOrder()));
}
}

View file

@ -238,7 +238,27 @@ public final class Localization {
}
}
/**
* Get a readable text for a duration in the format {@code days:hours:minutes:seconds}.
* Prepended zeros are removed.
* @param duration the duration in seconds
* @return a formatted duration String or {@code 0:00} if the duration is zero.
*/
public static String getDurationString(final long duration) {
return getDurationString(duration, true, false);
}
/**
* Get a readable text for a duration in the format {@code days:hours:minutes:seconds+}.
* Prepended zeros are removed. If the given duration is incomplete, a plus is appended to the
* duration string.
* @param duration the duration in seconds
* @param isDurationComplete whether the given duration is complete or whether info is missing
* @param showDurationPrefix whether the duration-prefix shall be shown
* @return a formatted duration String or {@code 0:00} if the duration is zero.
*/
public static String getDurationString(final long duration, final boolean isDurationComplete,
final boolean showDurationPrefix) {
final String output;
final long days = duration / (24 * 60 * 60L); /* greater than a day */
@ -256,7 +276,9 @@ public final class Localization {
} else {
output = String.format(Locale.US, "%d:%02d", minutes, seconds);
}
return output;
final String durationPrefix = showDurationPrefix ? "" : "";
final String durationPostfix = isDurationComplete ? "" : "+";
return durationPrefix + output + durationPostfix;
}
/**

View file

@ -1,97 +1,39 @@
package org.schabi.newpipe.util
import android.content.pm.PackageManager
import android.content.pm.Signature
import androidx.core.content.pm.PackageInfoCompat
import org.schabi.newpipe.App
import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.ErrorUtil.Companion.createNotification
import org.schabi.newpipe.error.UserAction
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.security.cert.CertificateEncodingException
import java.security.cert.CertificateException
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.time.Instant
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
object ReleaseVersionUtil {
// Public key of the certificate that is used in NewPipe release versions
private const val RELEASE_CERT_PUBLIC_KEY_SHA1 =
"7F:46:0D:D0:6A:2D:A0:6B:57:B5:2C:ED:73:06:B7:87:43:90:66:A9"
private const val RELEASE_CERT_PUBLIC_KEY_SHA256 =
"cb84069bd68116bafae5ee4ee5b08a567aa6d898404e7cb12f9e756df5cf5cab"
@JvmStatic
fun isReleaseApk(): Boolean {
return certificateSHA1Fingerprint == RELEASE_CERT_PUBLIC_KEY_SHA1
}
/**
* Method to get the APK's SHA1 key. See https://stackoverflow.com/questions/9293019/#22506133.
*
* @return String with the APK's SHA1 fingerprint in hexadecimal
*/
private val certificateSHA1Fingerprint: String
get() {
val app = App.getApp()
val signatures: List<Signature> = try {
PackageInfoCompat.getSignatures(app.packageManager, app.packageName)
} catch (e: PackageManager.NameNotFoundException) {
showRequestError(app, e, "Could not find package info")
return ""
}
if (signatures.isEmpty()) {
return ""
}
val x509cert = try {
val cf = CertificateFactory.getInstance("X509")
cf.generateCertificate(signatures[0].toByteArray().inputStream()) as X509Certificate
} catch (e: CertificateException) {
showRequestError(app, e, "Certificate error")
return ""
}
return try {
val md = MessageDigest.getInstance("SHA1")
val publicKey = md.digest(x509cert.encoded)
byte2HexFormatted(publicKey)
} catch (e: NoSuchAlgorithmException) {
showRequestError(app, e, "Could not retrieve SHA1 key")
""
} catch (e: CertificateEncodingException) {
showRequestError(app, e, "Could not retrieve SHA1 key")
""
}
}
private fun byte2HexFormatted(arr: ByteArray): String {
val str = StringBuilder(arr.size * 2)
for (i in arr.indices) {
var h = Integer.toHexString(arr[i].toInt())
val l = h.length
if (l == 1) {
h = "0$h"
}
if (l > 2) {
h = h.substring(l - 2, l)
}
str.append(h.uppercase())
if (i < arr.size - 1) {
str.append(':')
}
}
return str.toString()
}
private fun showRequestError(app: App, e: Exception, request: String) {
createNotification(
app, ErrorInfo(e, UserAction.CHECK_FOR_NEW_APP_VERSION, request)
@OptIn(ExperimentalStdlibApi::class)
val isReleaseApk by lazy {
@Suppress("NewApi")
val certificates = mapOf(
RELEASE_CERT_PUBLIC_KEY_SHA256.hexToByteArray() to PackageManager.CERT_INPUT_SHA256
)
val app = App.getApp()
try {
PackageInfoCompat.hasSignatures(app.packageManager, app.packageName, certificates, false)
} catch (e: PackageManager.NameNotFoundException) {
createNotification(
app, ErrorInfo(e, UserAction.CHECK_FOR_NEW_APP_VERSION, "Could not find package info")
)
false
}
}
fun isLastUpdateCheckExpired(expiry: Long): Boolean {
return Instant.ofEpochSecond(expiry).isBefore(Instant.now())
return Instant.ofEpochSecond(expiry) < Instant.now()
}
/**

View file

@ -11,7 +11,6 @@ import org.schabi.newpipe.extractor.stream.Stream;
import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper;
import java.util.Comparator;
import java.util.List;
public class SecondaryStreamHelper<T extends Stream> {
@ -43,42 +42,27 @@ public class SecondaryStreamHelper<T extends Stream> {
@NonNull final List<AudioStream> audioStreams,
@NonNull final VideoStream videoStream) {
final MediaFormat mediaFormat = videoStream.getFormat();
if (mediaFormat == null) {
if (mediaFormat == MediaFormat.WEBM) {
return audioStreams
.stream()
.filter(audioStream -> audioStream.getFormat() == MediaFormat.WEBMA
|| audioStream.getFormat() == MediaFormat.WEBMA_OPUS)
.max(ListHelper.getAudioFormatComparator(MediaFormat.WEBMA,
ListHelper.isLimitingDataUsage(context)))
.orElse(null);
} else if (mediaFormat == MediaFormat.MPEG_4) {
return audioStreams
.stream()
.filter(audioStream -> audioStream.getFormat() == MediaFormat.M4A)
.max(ListHelper.getAudioFormatComparator(MediaFormat.M4A,
ListHelper.isLimitingDataUsage(context)))
.orElse(null);
} else {
return null;
}
switch (mediaFormat) {
case WEBM:
case MPEG_4: // Is MPEG-4 DASH?
break;
default:
return null;
}
final boolean m4v = mediaFormat == MediaFormat.MPEG_4;
final boolean isLimitingDataUsage = ListHelper.isLimitingDataUsage(context);
Comparator<AudioStream> comparator = ListHelper.getAudioFormatComparator(
m4v ? MediaFormat.M4A : MediaFormat.WEBMA, isLimitingDataUsage);
int preferredAudioStreamIndex = ListHelper.getAudioIndexByHighestRank(
audioStreams, comparator);
if (preferredAudioStreamIndex == -1) {
if (m4v) {
return null;
}
comparator = ListHelper.getAudioFormatComparator(
MediaFormat.WEBMA_OPUS, isLimitingDataUsage);
preferredAudioStreamIndex = ListHelper.getAudioIndexByHighestRank(
audioStreams, comparator);
if (preferredAudioStreamIndex == -1) {
return null;
}
}
return audioStreams.get(preferredAudioStreamIndex);
}
public T getStream() {

View file

@ -1,18 +1,21 @@
package org.schabi.newpipe.util;
import org.schabi.newpipe.streams.io.SharpInputStream;
import org.schabi.newpipe.streams.io.StoredFileHelper;
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import org.schabi.newpipe.streams.io.StoredFileHelper;
/**
* Created by Christian Schabesberger on 28.01.18.
* Copyright 2018 Christian Schabesberger <chris.schabesberger@mailbox.org>
@ -34,73 +37,154 @@ import org.schabi.newpipe.streams.io.StoredFileHelper;
*/
public final class ZipHelper {
private ZipHelper() { }
private static final int BUFFER_SIZE = 2048;
@FunctionalInterface
public interface InputStreamConsumer {
void acceptStream(InputStream inputStream) throws IOException;
}
@FunctionalInterface
public interface OutputStreamConsumer {
void acceptStream(OutputStream outputStream) throws IOException;
}
private ZipHelper() { }
/**
* This function helps to create zip files.
* Caution this will override the original file.
* This function helps to create zip files. Caution this will overwrite the original file.
*
* @param outZip The ZipOutputStream where the data should be stored in
* @param file The path of the file that should be added to zip.
* @param name The path of the file inside the zip.
* @throws Exception
* @param outZip the ZipOutputStream where the data should be stored in
* @param nameInZip the path of the file inside the zip
* @param fileOnDisk the path of the file on the disk that should be added to zip
*/
public static void addFileToZip(final ZipOutputStream outZip, final String file,
final String name) throws Exception {
public static void addFileToZip(final ZipOutputStream outZip,
final String nameInZip,
final String fileOnDisk) throws IOException {
try (FileInputStream fi = new FileInputStream(fileOnDisk)) {
addFileToZip(outZip, nameInZip, fi);
}
}
/**
* This function helps to create zip files. Caution this will overwrite the original file.
*
* @param outZip the ZipOutputStream where the data should be stored in
* @param nameInZip the path of the file inside the zip
* @param streamConsumer will be called with an output stream that will go to the output file
*/
public static void addFileToZip(final ZipOutputStream outZip,
final String nameInZip,
final OutputStreamConsumer streamConsumer) throws IOException {
final byte[] bytes;
try (ByteArrayOutputStream byteOutput = new ByteArrayOutputStream()) {
streamConsumer.acceptStream(byteOutput);
bytes = byteOutput.toByteArray();
}
try (ByteArrayInputStream byteInput = new ByteArrayInputStream(bytes)) {
ZipHelper.addFileToZip(outZip, nameInZip, byteInput);
}
}
/**
* This function helps to create zip files. Caution this will overwrite the original file.
*
* @param outZip the ZipOutputStream where the data should be stored in
* @param nameInZip the path of the file inside the zip
* @param inputStream the content to put inside the file
*/
public static void addFileToZip(final ZipOutputStream outZip,
final String nameInZip,
final InputStream inputStream) throws IOException {
final byte[] data = new byte[BUFFER_SIZE];
try (FileInputStream fi = new FileInputStream(file);
BufferedInputStream inputStream = new BufferedInputStream(fi, BUFFER_SIZE)) {
final ZipEntry entry = new ZipEntry(name);
try (BufferedInputStream bufferedInputStream =
new BufferedInputStream(inputStream, BUFFER_SIZE)) {
final ZipEntry entry = new ZipEntry(nameInZip);
outZip.putNextEntry(entry);
int count;
while ((count = inputStream.read(data, 0, BUFFER_SIZE)) != -1) {
while ((count = bufferedInputStream.read(data, 0, BUFFER_SIZE)) != -1) {
outZip.write(data, 0, count);
}
}
}
/**
* This will extract data from ZipInputStream.
* Caution this will override the original file.
* This will extract data from ZipInputStream. Caution this will overwrite the original file.
*
* @param zipFile The zip file
* @param file The path of the file on the disk where the data should be extracted to.
* @param name The path of the file inside the zip.
* @param zipFile the zip file to extract from
* @param nameInZip the path of the file inside the zip
* @param fileOnDisk the path of the file on the disk where the data should be extracted to
* @return will return true if the file was found within the zip file
* @throws Exception
*/
public static boolean extractFileFromZip(final StoredFileHelper zipFile, final String file,
final String name) throws Exception {
public static boolean extractFileFromZip(final StoredFileHelper zipFile,
final String nameInZip,
final String fileOnDisk) throws IOException {
return extractFileFromZip(zipFile, nameInZip, input -> {
// delete old file first
final File oldFile = new File(fileOnDisk);
if (oldFile.exists()) {
if (!oldFile.delete()) {
throw new IOException("Could not delete " + fileOnDisk);
}
}
final byte[] data = new byte[BUFFER_SIZE];
try (FileOutputStream outFile = new FileOutputStream(fileOnDisk)) {
int count;
while ((count = input.read(data)) != -1) {
outFile.write(data, 0, count);
}
}
});
}
/**
* This will extract data from ZipInputStream.
*
* @param zipFile the zip file to extract from
* @param nameInZip the path of the file inside the zip
* @param streamConsumer will be called with the input stream from the file inside the zip
* @return will return true if the file was found within the zip file
*/
public static boolean extractFileFromZip(final StoredFileHelper zipFile,
final String nameInZip,
final InputStreamConsumer streamConsumer)
throws IOException {
try (ZipInputStream inZip = new ZipInputStream(new BufferedInputStream(
new SharpInputStream(zipFile.getStream())))) {
ZipEntry ze;
while ((ze = inZip.getNextEntry()) != null) {
if (ze.getName().equals(nameInZip)) {
streamConsumer.acceptStream(inZip);
return true;
}
}
return false;
}
}
/**
* @param zipFile the zip file
* @param fileInZip the filename to check
* @return whether the provided filename is in the zip; only the first level is checked
*/
public static boolean zipContainsFile(final StoredFileHelper zipFile, final String fileInZip)
throws Exception {
try (ZipInputStream inZip = new ZipInputStream(new BufferedInputStream(
new SharpInputStream(zipFile.getStream())))) {
final byte[] data = new byte[BUFFER_SIZE];
boolean found = false;
ZipEntry ze;
while ((ze = inZip.getNextEntry()) != null) {
if (ze.getName().equals(name)) {
found = true;
// delete old file first
final File oldFile = new File(file);
if (oldFile.exists()) {
if (!oldFile.delete()) {
throw new Exception("Could not delete " + file);
}
}
try (FileOutputStream outFile = new FileOutputStream(file)) {
int count = 0;
while ((count = inZip.read(data)) != -1) {
outFile.write(data, 0, count);
}
}
inZip.closeEntry();
if (ze.getName().equals(fileInZip)) {
return true;
}
}
return found;
return false;
}
}

View file

@ -0,0 +1,15 @@
package org.schabi.newpipe.util.debounce;
import org.schabi.newpipe.error.ErrorInfo;
public interface DebounceSavable {
/**
* Execute operations to save the data. <br>
* Must set {@link DebounceSaver#setIsModified(boolean)} false in this method manually
* after the data has been saved.
*/
void saveImmediate();
void showError(ErrorInfo errorInfo);
}

View file

@ -0,0 +1,81 @@
package org.schabi.newpipe.util.debounce;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.subjects.PublishSubject;
public class DebounceSaver {
private final long saveDebounceMillis;
private final PublishSubject<Long> debouncedSaveSignal;
private final DebounceSavable debounceSavable;
// Has the object been modified
private final AtomicBoolean isModified;
// Default 10 seconds
private static final long DEFAULT_SAVE_DEBOUNCE_MILLIS = 10000;
/**
* Creates a new {@code DebounceSaver}.
*
* @param saveDebounceMillis Save the object milliseconds later after the last change
* occurred.
* @param debounceSavable The object containing data to be saved.
*/
public DebounceSaver(final long saveDebounceMillis, final DebounceSavable debounceSavable) {
this.saveDebounceMillis = saveDebounceMillis;
debouncedSaveSignal = PublishSubject.create();
this.debounceSavable = debounceSavable;
this.isModified = new AtomicBoolean();
}
/**
* Creates a new {@code DebounceSaver}. Save the object 10 seconds later after the last change
* occurred.
*
* @param debounceSavable The object containing data to be saved.
*/
public DebounceSaver(final DebounceSavable debounceSavable) {
this(DEFAULT_SAVE_DEBOUNCE_MILLIS, debounceSavable);
}
public boolean getIsModified() {
return isModified.get();
}
public void setNoChangesToSave() {
isModified.set(false);
}
public PublishSubject<Long> getDebouncedSaveSignal() {
return debouncedSaveSignal;
}
public Disposable getDebouncedSaver() {
return debouncedSaveSignal
.debounce(saveDebounceMillis, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribe(ignored -> debounceSavable.saveImmediate(), throwable ->
debounceSavable.showError(new ErrorInfo(throwable,
UserAction.SOMETHING_ELSE, "Debounced saver")));
}
public void setHasChangesToSave() {
if (isModified == null || debouncedSaveSignal == null) {
return;
}
isModified.set(true);
debouncedSaveSignal.onNext(System.currentTimeMillis());
}
}

View file

@ -0,0 +1,193 @@
package org.schabi.newpipe.util.text;
import android.graphics.Paint;
import android.text.Layout;
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 java.util.function.Consumer;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
/**
* <p>Class to ellipsize text inside a {@link TextView}.</p>
* This class provides all utils to automatically ellipsize and expand a text
*/
public final class TextEllipsizer {
private static final int EXPANDED_LINES = Integer.MAX_VALUE;
private static final String ELLIPSIS = "";
@NonNull private final CompositeDisposable disposable = new CompositeDisposable();
@NonNull private final TextView view;
private final int maxLines;
@NonNull private Description content;
@Nullable private StreamingService streamingService;
@Nullable private String streamUrl;
private boolean isEllipsized = false;
@Nullable private Boolean canBeEllipsized = null;
@NonNull private final Paint paintAtContentSize = new Paint();
private final float ellipsisWidthPx;
@Nullable private Consumer<Boolean> stateChangeListener = null;
@Nullable private Consumer<Boolean> onContentChanged;
public TextEllipsizer(@NonNull final TextView view,
final int maxLines,
@Nullable final StreamingService streamingService) {
this.view = view;
this.maxLines = maxLines;
this.content = Description.EMPTY_DESCRIPTION;
this.streamingService = streamingService;
paintAtContentSize.setTextSize(view.getTextSize());
ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS);
}
public void setOnContentChanged(@Nullable final Consumer<Boolean> onContentChanged) {
this.onContentChanged = onContentChanged;
}
public void setContent(@NonNull final Description content) {
this.content = content;
canBeEllipsized = null;
linkifyContentView(v -> {
final int currentMaxLines = view.getMaxLines();
view.setMaxLines(EXPANDED_LINES);
canBeEllipsized = view.getLineCount() > maxLines;
view.setMaxLines(currentMaxLines);
if (onContentChanged != null) {
onContentChanged.accept(canBeEllipsized);
}
});
}
public void setStreamUrl(@Nullable final String streamUrl) {
this.streamUrl = streamUrl;
}
public void setStreamingService(@NonNull final StreamingService streamingService) {
this.streamingService = streamingService;
}
/**
* Expand the {@link TextEllipsizer#content} to its full length.
*/
public void expand() {
view.setMaxLines(EXPANDED_LINES);
linkifyContentView(v -> isEllipsized = false);
}
/**
* Shorten the {@link TextEllipsizer#content} to the given number of
* {@link TextEllipsizer#maxLines maximum lines} and add trailing '{@code }'
* if the text was shorted.
*/
public void ellipsize() {
// expand text to see whether it is necessary to ellipsize the text
view.setMaxLines(EXPANDED_LINES);
linkifyContentView(v -> {
final CharSequence charSeqText = view.getText();
if (charSeqText != null && view.getLineCount() > maxLines) {
// 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 = charSeqText.toString();
final Layout layout = view.getLayout();
final float lineWidth = layout.getLineWidth(maxLines - 1);
final float layoutWidth = layout.getWidth();
final int lineStart = layout.getLineStart(maxLines - 1);
final int lineEnd = layout.getLineEnd(maxLines - 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;
view.setText(newVal);
isEllipsized = true;
} else {
isEllipsized = false;
}
view.setMaxLines(maxLines);
});
}
/**
* Toggle the view between the ellipsized and expanded state.
*/
public void toggle() {
if (isEllipsized) {
expand();
} else {
ellipsize();
}
}
/**
* Whether the {@link #view} can be ellipsized.
* This is only the case when the {@link #content} has more lines
* than allowed via {@link #maxLines}.
* @return {@code true} if the {@link #content} has more lines than allowed via
* {@link #maxLines} and thus can be shortened, {@code false} if the {@code content} fits into
* the {@link #view} without being shortened and {@code null} if the initialization is not
* completed yet.
*/
@Nullable
public Boolean canBeEllipsized() {
return canBeEllipsized;
}
private void linkifyContentView(final Consumer<View> consumer) {
final boolean oldState = isEllipsized;
disposable.clear();
TextLinkifier.fromDescription(view, content,
HtmlCompat.FROM_HTML_MODE_LEGACY, streamingService, streamUrl, disposable,
v -> {
consumer.accept(v);
notifyStateChangeListener(oldState);
});
}
/**
* Add a listener which is called when the given content is changed,
* either from <em>ellipsized</em> to <em>full</em> or vice versa.
* @param listener The listener to be called, or {@code null} to remove it.
* The Boolean parameter is the new state.
* <em>Ellipsized</em> content is represented as {@code true},
* normal or <em>full</em> content by {@code false}.
*/
public void setStateChangeListener(@Nullable final Consumer<Boolean> listener) {
this.stateChangeListener = listener;
}
private void notifyStateChangeListener(final boolean oldState) {
if (oldState != isEllipsized && stateChangeListener != null) {
stateChangeListener.accept(isEllipsized);
}
}
}

View file

@ -92,7 +92,7 @@ public final class TextLinkifier {
* {@link HtmlCompat#fromHtml(String, int)}.
* </p>
*
* @param textView the {@link TextView} to set the the HTML string block linked
* @param textView the {@link TextView} to set 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

View file

@ -416,7 +416,7 @@ public class DownloadManagerService extends Service {
mission.threadCount = threads;
mission.source = source;
mission.nearLength = nearLength;
mission.recoveryInfo = recovery.toArray(MissionRecoveryInfo[]::new);
mission.recoveryInfo = recovery.toArray(new MissionRecoveryInfo[0]);
if (segments != null && segments.length > 0) {
try {

View file

@ -517,7 +517,7 @@ public class MissionAdapter extends Adapter<ViewHolder> implements Handler.Callb
showError(mission, UserAction.DOWNLOAD_POSTPROCESSING, R.string.error_postprocessing_failed);
return;
case ERROR_INSUFFICIENT_STORAGE:
msg = R.string.error_insufficient_storage;
msg = R.string.error_insufficient_storage_left;
break;
case ERROR_UNKNOWN_EXCEPTION:
if (mission.errObject != null) {

View file

@ -2,6 +2,8 @@ package us.shandian.giga.util;
import android.content.Context;
import android.os.Build;
import android.os.Environment;
import android.os.StatFs;
import android.util.Log;
import androidx.annotation.ColorInt;
@ -26,10 +28,8 @@ import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.net.HttpURLConnection;
import java.util.Locale;
import java.util.Random;
import okio.ByteString;
import us.shandian.giga.get.DownloadMission;
public class Utility {

View file

@ -0,0 +1 @@
../layout/list_stream_item.xml

View file

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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:clickable="true"
android:focusable="true"
android:padding="@dimen/video_item_search_padding">
<ImageView
android:id="@+id/itemThumbnailView"
android:layout_width="90dp"
android:layout_height="50dp"
android:layout_alignParentStart="true"
android:layout_alignParentLeft="true"
android:layout_alignParentTop="true"
android:layout_marginRight="@dimen/video_item_search_image_right_margin"
android:scaleType="centerCrop"
android:src="@drawable/placeholder_thumbnail_playlist"
tools:ignore="RtlHardcoded" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/itemStreamCountView"
android:layout_width="45dp"
android:layout_height="match_parent"
android:layout_alignTop="@id/itemThumbnailView"
android:layout_alignRight="@id/itemThumbnailView"
android:layout_alignBottom="@id/itemThumbnailView"
android:background="@color/playlist_stream_count_background_color"
android:gravity="center"
android:paddingTop="4dp"
android:paddingBottom="6dp"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textColor="@color/duration_text_color"
android:textSize="@dimen/video_item_search_duration_text_size"
android:textStyle="bold"
app:drawableTint="@color/duration_text_color"
app:drawableTopCompat="@drawable/ic_playlist_play"
tools:ignore="RtlHardcoded"
tools:text="3141" />
<ImageView
android:id="@+id/itemHandle"
android:layout_width="wrap_content"
android:layout_height="55dp"
android:layout_alignParentRight="true"
android:layout_gravity="center_vertical"
android:contentDescription="@string/detail_drag_description"
android:paddingLeft="@dimen/video_item_search_image_right_margin"
android:scaleType="center"
app:srcCompat="@drawable/ic_drag_handle"
tools:ignore="RtlHardcoded,RtlSymmetry" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/itemTitleView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_alignParentTop="true"
android:layout_toStartOf="@id/itemHandle"
android:layout_toLeftOf="@id/itemHandle"
android:layout_toRightOf="@+id/itemThumbnailView"
android:ellipsize="end"
android:maxLines="2"
android:textAppearance="?android:attr/textAppearanceLarge"
android:textSize="@dimen/video_item_search_title_text_size"
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="match_parent"
android:layout_height="wrap_content"
android:layout_below="@+id/itemTitleView"
android:layout_toRightOf="@+id/itemThumbnailView"
android:lines="1"
android:textAppearance="?android:attr/textAppearanceSmall"
android:textSize="@dimen/video_item_search_uploader_text_size"
tools:ignore="RtlHardcoded"
tools:text="Uploader" />
</RelativeLayout>

View file

@ -80,10 +80,32 @@
tools:text="234 videos" />
</RelativeLayout>
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/playlist_description"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/playlist_meta"
android:paddingHorizontal="@dimen/video_item_search_padding"
android:paddingTop="6dp"
android:maxLines="5"
tools:text="This is a multiline playlist description. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blandit Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blandit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blandit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc tristique vitae sem vitae blandit" />
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/playlist_description_read_more"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/playlist_description"
android:gravity="end"
android:paddingHorizontal="@dimen/video_item_search_padding"
android:paddingTop="6dp"
android:text="@string/show_more"
android:layout_marginBottom="6dp"
android:textColor="?attr/colorPrimary" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/playlist_meta">
android:layout_below="@id/playlist_description_read_more">
<include
android:id="@+id/playlist_control"

View file

@ -1,5 +1,4 @@
<?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"
@ -8,64 +7,64 @@
android:paddingTop="16dp">
<org.schabi.newpipe.views.NewPipeTextView
android:id="@+id/textView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:clickable="false"
android:focusable="false"
android:gravity="center"
android:text="@string/notification_actions_summary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
android:id="@+id/summary"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
android:clickable="false"
android:focusable="false"
android:gravity="center"
android:text="@string/notification_actions_summary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<include
android:id="@+id/notificationAction0"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/textView" />
<include
android:id="@+id/notificationAction0"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/summary" />
<include
android:id="@+id/notificationAction1"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notificationAction0" />
<include
android:id="@+id/notificationAction1"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notificationAction0" />
<include
android:id="@+id/notificationAction2"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notificationAction1" />
<include
android:id="@+id/notificationAction2"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notificationAction1" />
<include
android:id="@+id/notificationAction3"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notificationAction2" />
<include
android:id="@+id/notificationAction3"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notificationAction2" />
<include
android:id="@+id/notificationAction4"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notificationAction3" />
<include
android:id="@+id/notificationAction4"
layout="@layout/settings_notification_action"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/notificationAction3" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<resources></resources>

View file

@ -78,7 +78,7 @@
<string name="short_billion">بليون</string>
<string name="feed_load_error_account_info">تعذر تحميل موجز \'%s\'.</string>
<string name="question_mark">؟</string>
<string name="manual_update_title">التحقق من وجود تحديثات</string>
<string name="check_for_updates">التحقق من وجود تحديثات</string>
<string name="peertube_instance_url_title">مثيلات خوادم پيرتيوب</string>
<string name="more_than_100_videos">+100 فيديو</string>
<string name="short_thousand">ألف</string>
@ -271,7 +271,7 @@
<string name="import_data_summary">يلغي السجل الحالي والاشتراكات وقوائم التشغيل والإعدادات (اختياريًا)</string>
<string name="app_ui_crash">تعطل التطبيق / واجهة المستخدم</string>
<string name="rename">إعادة التسمية</string>
<string name="error_insufficient_storage">لم يتبقى مساحة في الجهاز</string>
<string name="error_insufficient_storage_left">لم يتبقى مساحة في الجهاز</string>
<string name="could_not_setup_download_menu">تعذر إعداد قائمة التنزيل</string>
<string name="download_path_dialog_title">اختر مجلد التنزيل لملفات الفيديو</string>
<string name="notifications_disabled">تم تعطيل الإشعارات</string>

View file

@ -407,7 +407,7 @@
<string name="overwrite_failed">لا يمكن الكتابة فوق الملف</string>
<string name="download_already_pending">هناك تنزيل معلق بهذا الاسم</string>
<string name="error_postprocessing_stopped">تم إغلاق NewPipe أثناء العمل على الملف</string>
<string name="error_insufficient_storage">لم يتبقى مساحة في الجهاز</string>
<string name="error_insufficient_storage_left">لم يتبقى مساحة في الجهاز</string>
<string name="error_progress_lost">تم فقد التقدم بسبب حذف الملف</string>
<string name="error_timeout">انتهى وقت الاتصال</string>
<string name="confirm_prompt">هل تريد محو سجل التنزيل، أم تريد حذف جميع الملفات التي تم تنزيلها؟</string>
@ -584,7 +584,7 @@
<string name="notification_action_shuffle">خلط</string>
<string name="notification_action_repeat">تكرار</string>
<string name="notification_actions_at_most_three">يمكنك تحديد ثلاثة إجراءات كحد أقصى لإظهارها في الإشعار المضغوط!</string>
<string name="notification_actions_summary">قم بتحرير كل إشعار أدناه من خلال النقر عليه. حدد ما يصل إلى ثلاثة منها لتظهر في الإشعار المضغوط باستخدام مربعات الاختيار الموجودة على اليمين</string>
<string name="notification_actions_summary">قم بتحرير كل إجراء إعلام أدناه من خلال النقر عليه. حدد ما يصل إلى ثلاثة منها ليتم عرضها في الإشعار المضغوط باستخدام مربعات الاختيار الموجودة على اليمين.</string>
<string name="notification_action_4_title">زر الإجراء الخامس</string>
<string name="notification_action_3_title">زر الإجراء الرابع</string>
<string name="notification_action_2_title">زر الإجراء الثالث</string>
@ -700,7 +700,7 @@
<string name="enqueue_next_stream">وضع التالي على قائمة الانتظار</string>
<string name="enqueued_next">تم وضع التالي على قائمة الانتظار</string>
<string name="processing_may_take_a_moment">جاري المعالجة ... قد يستغرق لحظة</string>
<string name="manual_update_title">التحقق من وجود تحديثات</string>
<string name="check_for_updates">التحقق من وجود تحديثات</string>
<string name="manual_update_description">التحقق يدويا من وجود إصدارات جديدة</string>
<string name="checking_updates_toast">جاري التحقق من وجود تحديثات…</string>
<string name="feed_new_items">عناصر تغذية جديدة</string>
@ -858,4 +858,26 @@
<string name="share_playlist">مشاركة قائمة التشغيل</string>
<string name="share_playlist_with_titles_message">شارك تفاصيل قائمة التشغيل مثل اسم قائمة التشغيل وعناوين الفيديو أو كقائمة بسيطة من عناوين URL للفيديو</string>
<string name="video_details_list_item">- %1$s: %2$s</string>
<plurals name="replies">
<item quantity="zero">رد %s</item>
<item quantity="one">رد %s</item>
<item quantity="two">ردان%s</item>
<item quantity="few">ردود%s</item>
<item quantity="many">ردود %s</item>
<item quantity="other">ردود %s</item>
</plurals>
<string name="show_more">عرض المزيد</string>
<string name="show_less">عرض أقل</string>
<string name="notification_actions_summary_android13">قم بتحرير كل إجراء إعلام أدناه من خلال النقر عليه. يتم تعيين الإجراءات الثلاثة الأولى (تشغيل/إيقاف مؤقت، السابق والتالي) بواسطة النظام ولا يمكن تخصيصها.</string>
<string name="error_insufficient_storage">لا توجد مساحة خالية كافية على الجهاز</string>
<string name="reset_settings_title">اعادة ضبط الإعداداتِ</string>
<string name="settings_category_backup_restore_title">النسخ الاحتياطيُّ والاستعادة</string>
<string name="reset_settings_summary">أعيدوا جميع الإعدادات إلى قيمهم الافتراضية</string>
<string name="reset_all_settings">ستؤدي إعادة ضبط جميع الإعدادات إلى تجاهل جميع إعداداتك المفضلة وإعادة تشغيل التطبيق.
\n
\nهل انت متأكد انك تريد المتابعة؟</string>
<string name="yes">نعم</string>
<string name="auto_update_check_description">يمكن لـ NewPipe البحث تلقائيًا عن الإصدارات الجديدة من وقت لآخر وإعلامك بمجرد توفرها.
\nهل تريد تمكين هذا؟</string>
<string name="no">لا</string>
</resources>

View file

@ -448,7 +448,7 @@
<item quantity="one">%s video</item>
<item quantity="other">%s video</item>
</plurals>
<string name="manual_update_title">Yeniləmələri yoxla</string>
<string name="check_for_updates">Yeniləmələri yoxla</string>
<string name="seekbar_preview_thumbnail_title">Axtarış çubuğunun miniatür önizləməsi</string>
<string name="permission_denied">Əməliyyat sistem tərəfindən ləğv edildi</string>
<string name="auto">Avto</string>
@ -548,7 +548,7 @@
<string name="remove_watched">İzləniləni sil</string>
<string name="downloads_storage_use_saf_title">Sistem qovluğu seçicisini (SAF) istifadə et</string>
<string name="error_timeout">Bağlantı fasiləsi</string>
<string name="error_insufficient_storage">Cihazda yer qalmayıb</string>
<string name="error_insufficient_storage_left">Cihazda yer qalmayıb</string>
<string name="error_postprocessing_stopped">Fayl üzərində işləyərkən NewPipe bağlandı</string>
<string name="error_postprocessing_failed">Emaldan sonra uğursuz oldu</string>
<string name="error_connect_host">Serverə qoşulmaq mümkün deyil</string>
@ -694,7 +694,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ə linklər kliklənməyə bilər.</string>
<string name="notification_scale_to_square_image_summary">Bildirişdə göstərilən video miniatürünü 16:9-dan 1:1 görünüş nisbətinə qədər kəs</string>
<string name="notification_actions_summary">Aşağıdakı hər bir bildiriş fəaliyyətini üzərinə toxunaraq redaktə 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="notification_actions_summary">Aşağıdakı hər bir bildiriş fəaliyyətini üzərinə toxunaraq düzəliş edin. Sağdakı təsdiq qutularından istifadə edərək yığcam bildirişdə göstərmək üçün onların üçünü seçin.</string>
<string name="invalid_source">Belə fayl/məzmun mənbəyi yoxdur</string>
<string name="selected_stream_external_player_not_supported">Seçilən yayım xarici oynadıcılar tərəfindən dəstəklənmir</string>
<string name="streams_not_yet_supported_removed">Yükləyici tərəfindən hələ dəstəklənməyən yayımlar göstərilmir</string>
@ -769,4 +769,5 @@
<string name="feed_fetch_channel_tabs_summary">Axın yenilənərkən əldə edilən səhifələr.Kanal sürətli rejim istifadə edərək yenilənirsə, bu seçimin heç bir təsiri yoxdur.</string>
<string name="metadata_uploader_avatars">Yükləyici avatarları</string>
<string name="metadata_thumbnails">Miniatürlər</string>
<string name="notification_actions_summary_android13">Aşağıdakı hər bildirişə vuraraq ona düzəliş edin. İlk üç əməl (oynatma/fasilə, əvvəlki və sonrakı) sistem tərəfindən təyin olunub və dəyişdirilə bilməz.</string>
</resources>

View file

@ -171,7 +171,7 @@
<string name="overwrite_finished_warning">Yá esiste un ficheru baxáu con esti nome</string>
<string name="overwrite_failed">nun pue sobrecribise\'l ficheru</string>
<string name="download_already_pending">Hai una descarga pendiente con esti nome</string>
<string name="error_insufficient_storage">Nun queda espaciu nel preséu</string>
<string name="error_insufficient_storage_left">Nun queda espaciu nel preséu</string>
<string name="error_timeout">Escosó\'l tiempu d\'espera de la conexón</string>
<string name="subscriptions_import_unsuccessful">Nun pudieron importase les soscripciones</string>
<string name="caption_setting_title">Sotítulos</string>

View file

@ -58,7 +58,7 @@
<string name="play_with_kodi_title">Kodi bilan ijro etish</string>
<string name="show_higher_resolutions_summary">Faqat ba\'zi qurilmalar 2K / 4K videolarni ijro etishi mumkin</string>
<string name="show_higher_resolutions_title">Yuqori o\'lchamlarni ko\'rsatish</string>
<string name="default_popup_resolution_title">"Standart pop-up o\'lchamlari"</string>
<string name="default_popup_resolution_title">Standart pop-up o\'lchamlari</string>
<string name="default_resolution_title">Standart o\'lchamlari</string>
<string name="download_path_audio_dialog_title">Audio fayllar uchun yuklab olish papkasini tanlash</string>
<string name="download_path_summary">Yuklab olingan videofayllar shu yerda saqlanadi</string>
@ -416,7 +416,7 @@
<string name="error_download_resource_gone">Ushbu yuklab olishni tiklab bo\'lmaydi</string>
<string name="error_timeout">Ulanish vaqti tugadi</string>
<string name="error_progress_lost">Siljish yo\'qoldi, chunki fayl o\'chirildi</string>
<string name="error_insufficient_storage">Qurilmada bo\'sh joy qolmadi</string>
<string name="error_insufficient_storage_left">Qurilmada bo\'sh joy qolmadi</string>
<string name="error_postprocessing_stopped">NewPipe fayl ustida ishlash paytida yopilgan</string>
<string name="error_postprocessing_failed">Keyingi ishlov berilmadi</string>
<string name="error_http_not_found">Topilmadi</string>

View file

@ -6,8 +6,8 @@
<string name="no_player_found_toast">Патокавы плэер не знойдзены (вы можаце ўсталяваць VLC каб прайграць).</string>
<string name="install">Усталяваць</string>
<string name="cancel">Скасаваць</string>
<string name="open_in_browser">Адкрыць у браўзеры</string>
<string name="open_in_popup_mode">Адкрыць у асобным акне</string>
<string name="open_in_browser">Адкрыць ў браўзеры</string>
<string name="open_in_popup_mode">Адкрыць ў асобным акне</string>
<string name="share">Падзяліцца</string>
<string name="download">Спампаваць</string>
<string name="controls_download_desc">Загрузка файла прамой трансляцыі</string>
@ -37,12 +37,12 @@
<string name="download_path_audio_summary">Загружаныя аўдыёфайлы захоўваюцца тут</string>
<string name="download_path_audio_dialog_title">Абярыце тэчку загрузкі для аўдыёфайлаў</string>
<string name="default_resolution_title">Разрознянне па змаўчанні</string>
<string name="default_popup_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="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="play_audio">Аўдыё</string>
<string name="default_audio_format_title">Фармат аўдыё па змаўчанні</string>
@ -70,7 +70,7 @@
<string name="resume_on_audio_focus_gain_title">Узнавіць прайграванне</string>
<string name="resume_on_audio_focus_gain_summary">Працягваць прайграванне пасля перапынкаў (напрыклад, тэлефонных званкоў)</string>
<string name="download_dialog_title">Загрузіць</string>
<string name="show_next_and_similar_title">\"Наступнае\" и \"Прапанаванае\" відэа</string>
<string name="show_next_and_similar_title">\"Наступнае\" і \"Прапанаванае\" відэа</string>
<string name="show_hold_to_append_title">Паказаць падказку \"Утрымлівайце, каб паставіць у чаргу\"</string>
<string name="show_hold_to_append_summary">Паказаць падказку пры націсканні фонавай або ўсплывальнай кнопкі ў відэа \"Падрабязнасці:\"</string>
<string name="unsupported_url">URL не падтрымліваецца</string>
@ -227,7 +227,7 @@
\nПалітыка прыватнасці NewPipe падрабязна тлумачыць, якія дадзеныя адпраўляюцца і захоўваюцца пры адпраўцы справаздачы аб збоях.</string>
<string name="read_privacy_policy">Прачытаць палітыку</string>
<string name="app_license_title">Ліцэнзія NewPipe</string>
<string name="app_license">NewPipe - гэта праграмнае забеспячэнне, свабоднае ад копілефта: вы можаце выкарыстоўваць, вывучаць, дзяліцца і паляпшаць яго па жаданні. У прыватнасці, вы можаце распаўсюджваць і/ці змяняць яго ў адпаведнасці з умовамі Агульнай грамадскай ліцэнзіі GNU, апублікаванай Фондам свабоднага праграмнага забеспячэння, альбо версіі 3 Ліцэнзіі, альбо (на ваш выбар) любой пазнейшай версіі.</string>
<string name="app_license">NewPipe - гэта праграмнае забеспячэнне, свабоднае ад копілефта: вы можаце выкарыстоўваць, вывучаць, дзяліцца і паляпшаць яго па жаданні. Ў прыватнасці, вы можаце распаўсюджваць і/ці змяняць яго ў адпаведнасці з умовамі Агульнай грамадскай ліцэнзіі GNU, апублікаванай Фондам свабоднага праграмнага забеспячэння, альбо версіі 3 Ліцэнзіі, альбо (на ваш выбар) любой пазнейшай версіі.</string>
<string name="read_full_license">Прачытаць ліцэнзію</string>
<string name="title_activity_history">Гісторыя</string>
<string name="action_history">Гісторыя</string>
@ -253,8 +253,8 @@
<string name="play_queue_remove">Выдаліць</string>
<string name="play_queue_stream_detail">Падрабязнасці</string>
<string name="play_queue_audio_settings">Налады аўдыё</string>
<string name="hold_to_append">Утрымлівайце, каб дадаць у чаргу</string>
<string name="start_here_on_background">Пачаць адсюль у фоне</string>
<string name="hold_to_append">Утрымлівайце, каб дадаць ў чаргу</string>
<string name="start_here_on_background">Пачаць адсюль ў фоне</string>
<string name="start_here_on_popup">Пачніце гуляць ва ўсплываючым акне</string>
<string name="drawer_open">Адкрыць бакавую панэль</string>
<string name="drawer_close">Зачыніць бакавую панэль</string>
@ -262,16 +262,16 @@
<string name="preferred_open_action_settings_summary">Пры адкрыцці спасылкі на кантэнт — %s</string>
<string name="video_player">Відэаплэер</string>
<string name="background_player">Фонавы плэер</string>
<string name="popup_player">Плэер у акне</string>
<string name="popup_player">Аконны прайгравальнік</string>
<string name="always_ask_open_action">Заўсёды пытацца</string>
<string name="preferred_player_fetcher_notification_title">Атрыманне звестак…</string>
<string name="preferred_player_fetcher_notification_message">Загрузка запытанага кантэнту</string>
<string name="create_playlist">Стварыць плэйліст</string>
<string name="rename_playlist">Перайменаваць</string>
<string name="name">Імя</string>
<string name="add_to_playlist">Дадаць у плэйліст</string>
<string name="set_as_playlist_thumbnail">Усталяваць як мініяцюру плэйліста</string>
<string name="bookmark_playlist">Дадаць плэйліст у закладкі</string>
<string name="add_to_playlist">Дадаць ў плэйліст</string>
<string name="set_as_playlist_thumbnail">Ўсталяваць як мініяцюру плэйліста</string>
<string name="bookmark_playlist">Дадаць плэйліст ў закладкі</string>
<string name="unbookmark_playlist">Выдаліць закладку</string>
<string name="delete_playlist_prompt">Выдаліць плэйліст\?</string>
<string name="playlist_creation_success">Плэйліст створаны</string>
@ -289,7 +289,7 @@
<string name="enable_disposed_exceptions_summary">Прымусова паведамляць пра недастаўляемыя Rx-выключэнні па-за фрагментам або жыццёвым цыкле пасля выдалення</string>
<string name="import_title">Імпарт</string>
<string name="import_from">Імпарт з</string>
<string name="export_to">Экспарт у</string>
<string name="export_to">Экспарт ў</string>
<string name="import_ongoing">Імпарт…</string>
<string name="export_ongoing">Экспарт…</string>
<string name="import_file_title">Імпарт файла</string>
@ -299,17 +299,17 @@
<string name="import_youtube_instructions">Імпарт падпісак YouTube з Google Takeout:
\n
\n1. Перайдзіце па гэтым URL: %1$s
\n2. Увайдзіце, калі вас папросяць
\n2. Ўвайдзіце, калі вас папросяць
\n3. Націсніце на «Усе дадзеныя ўключаны», затым на «Адмяніць выбар усіх», затым выберыце толькі «падпіскі» і націсніце «ОК»
\n4. Націсніце на «Наступны крок», а затым на «Стварыць экспарт»
\n5. Націсніце на кнопку «Спампаваць» пасля таго, як яна з\'явіцца
\n6. Пстрыкніце ФАЙЛ ІМПАРТУВАЦЬ ніжэй і выберыце спампаваны файл .zip
\n7. [Калі імпарт .zip не ўдаецца] Распакуйце файл .csv (звычайна ў раздзеле \"YouTube і YouTube Music/subscriptions/subscriptions.csv\"), націсніце ФАЙЛ ІМПАРТУВАЦЬ ніжэй і выберыце выняты файл CSV</string>
<string name="import_soundcloud_instructions">Імпарт падпісак з SoundCloud набраўшы альбо URL, альбо ваш ID:
\n
\n1. Уключыце \"рэжым працоўнага стала\" у браўзэры (сайт недаступны на тэлефоне)
\n2. Перайдзіце на: %1$s
\n3. Увайдзіце, калі неабходна
<string name="import_soundcloud_instructions">Імпарт падпісак з SoundCloud набраўшы альбо URL, альбо ваш ID:
\n
\n1. Ўключыце \"рэжым працоўнага стала\" ў браўзэры (сайт недаступны на тэлефоне)
\n2. Перайдзіце на: %1$s
\n3. Увайдзіце, калі неабходна
\n4. Скапіруйце адрас з адраснага радка.</string>
<string name="import_soundcloud_instructions_hint">вашID, soundcloud.com/вашID</string>
<string name="import_network_expensive_warning">Гэтае дзеянне можа выклікаць вялікі расход трафіку.
@ -322,7 +322,7 @@
<string name="skip_silence_checkbox">Прапускаць цішыню</string>
<string name="playback_step">Крок</string>
<string name="playback_reset">Скід</string>
<string name="start_accept_privacy_policy">У адпаведнасці з Агульным рэгламентам па абароне дадзеных ЕС (GDPR), звяртаем вашу ўвагу на палітыку прыватнасці NewPipe. Калі ласка, уважліва азнаёмцеся з ёй.
<string name="start_accept_privacy_policy">Ў адпаведнасці з Агульным рэгламентам па абароне дадзеных ЕС (GDPR), звяртаем вашу ўвагу на палітыку прыватнасці NewPipe. Калі ласка, уважліва азнаёмцеся з ёй.
\nВам неабходна прыняць яе ўмовы, каб адправіць нам справаздачу пра памылку.</string>
<string name="accept">Прыняць</string>
<string name="decline">Адмовіцца</string>
@ -331,8 +331,8 @@
<string name="minimize_on_exit_title">Пры згортванні плэера</string>
<string name="minimize_on_exit_summary">Дзеянне пры пераключэнні са стандартнага плэера на іншае прыкладанне — %s</string>
<string name="minimize_on_exit_none_description">Нічога не рабіць</string>
<string name="minimize_on_exit_background_description">Згарнуць у фонавы плэер</string>
<string name="minimize_on_exit_popup_description">Плэер у акне</string>
<string name="minimize_on_exit_background_description">Згарнуць ў фонавы плэер</string>
<string name="minimize_on_exit_popup_description">Плэер ў акне</string>
<string name="unsubscribe">Адпісацца</string>
<string name="tab_choose">Абярыце ўкладку</string>
<string name="settings_category_updates_title">Абнаўленні</string>
@ -356,14 +356,14 @@
<string name="missions_header_finished">Скончана</string>
<string name="missions_header_pending">У чарзе</string>
<string name="paused">прыпынена</string>
<string name="queued">у чарзе</string>
<string name="queued">дададзены ў чаргу</string>
<string name="post_processing">постапрацоўка</string>
<string name="enqueue">Паставіць у чаргу</string>
<string name="enqueue">Дадаць ў чаргу</string>
<string name="permission_denied">Дзеянне забаронена сістэмай</string>
<string name="download_failed">Памылка загрузкі</string>
<string name="generate_unique_name">Стварыць унікальнае імя</string>
<string name="overwrite">Перазапісаць</string>
<string name="download_already_running">Загрузка з такім імем ужо выконваецца</string>
<string name="download_already_running">Загрузка з такім імем ўжо выконваецца</string>
<string name="show_error">Паказаць тэкст памылкі</string>
<string name="error_path_creation">Немагчыма стварыць папку прызначэння</string>
<string name="error_file_creation">Немагчыма стварыць файл</string>
@ -377,7 +377,7 @@
<string name="stop">Спыніць</string>
<string name="max_retry_msg">Максімум спробаў</string>
<string name="max_retry_desc">Колькасць спробаў перад адменай загрузкі</string>
<string name="pause_downloads_on_mobile">Перапыніць у платных сетках</string>
<string name="pause_downloads_on_mobile">Перапыніць ў платных сетках</string>
<string name="pause_downloads_on_mobile_desc">Карысна пры пераключэнні на мабільную сетку, хоць некаторыя загрузкі не могуць быць прыпыненыя</string>
<string name="events">Падзеі</string>
<string name="conferences">Канферэнцыі</string>
@ -394,12 +394,12 @@
<string name="settings_category_clear_data_title">Ачысціць дадзеныя</string>
<string name="watch_history_states_deleted">Пазіцыі прайгравання выдалены</string>
<string name="missing_file">Файл перамешчаны ці выдалены</string>
<string name="overwrite_unrelated_warning">Файл з такім імем ужо існуе</string>
<string name="overwrite_finished_warning">Файл з такім імем ужо існуе</string>
<string name="overwrite_unrelated_warning">Файл з такім імем ўжо існуе</string>
<string name="overwrite_finished_warning">Файл з такім імем ўжо існуе</string>
<string name="overwrite_failed">немагчыма перазапісаць файл</string>
<string name="download_already_pending">У чарзе ўжо ёсць загрузка з такім імем</string>
<string name="download_already_pending">Ў чарзе ўжо ёсць загрузка з такім імем</string>
<string name="error_postprocessing_stopped">NewPipe была зачынена падчас працы над файлам</string>
<string name="error_insufficient_storage">Скончылася вольнае месца на прыладзе</string>
<string name="error_insufficient_storage_left">Скончылася вольнае месца на прыладзе</string>
<string name="error_progress_lost">Прагрэс страчаны, так як файл быў выдалены</string>
<string name="error_timeout">Час злучэння выйшла</string>
<string name="confirm_prompt">Вы ўпэўнены\?</string>
@ -408,8 +408,8 @@
<string name="start_downloads">Пачаць загрузку</string>
<string name="pause_downloads">Прыпыніць загрузку</string>
<string name="downloads_storage_ask_title">Запытваць тэчку загрузкі</string>
<string name="downloads_storage_ask_summary">Вам будзе прапанавана ўказаць месца захавання кожнай загрузкі.
\nУключыце сістэмны выбарнік тэчкі (SAF), калі вы хочаце загружаць файлы на знешнюю SD-картку</string>
<string name="downloads_storage_ask_summary">Вам будзе прапанавана указаць месца захавання кожнай загрузкі.
\nЎключыце сістэмны выбарнік тэчкі (SAF), калі вы хочаце загружаць файлы на знешнюю SD-картку</string>
<string name="downloads_storage_use_saf_title">Выкарыстоўвайце сродак выбару сістэмных тэчак (SAF)</string>
<string name="downloads_storage_use_saf_summary">\'Storage Access Framework\' дазваляе загружаць на знешнюю SD-картку</string>
<string name="drawer_header_description">Пераключыць службу, выбраную ў дадзены момант:</string>
@ -426,7 +426,7 @@
<string name="notification_action_1_title">Кнопка другога дзеяння</string>
<string name="notification_action_0_title">Кнопка першага дзеяння</string>
<string name="feed_groups_header_title">Групы каналаў</string>
<string name="systems_language">Як у сістэме</string>
<string name="systems_language">Як ў сістэме</string>
<string name="app_language_title">Мова прылады</string>
<string name="choose_instance_prompt">Выберыце экзэмпляр</string>
<string name="delete_downloaded_files">Выдаліць загружаныя файлы</string>
@ -442,15 +442,15 @@
<string name="never">Ніколі</string>
<string name="wifi_only">Толькі па Wi-Fi</string>
<string name="show_original_time_ago_title">Паказаць арыгінальны час на элементах</string>
<string name="unmute">Уключыць гук</string>
<string name="unmute">Ўключыць гук</string>
<string name="mute">Цішына</string>
<string name="enqueue_stream">Дадаць у чаргу</string>
<string name="enqueued">Даданае у чаргу</string>
<string name="enqueue_stream">Дадаць ў чаргу</string>
<string name="enqueued">Даданае ў чаргу</string>
<string name="title_activity_play_queue">Чарга прайгравання</string>
<string name="most_liked">Найбольш папулярнае</string>
<string name="local">Лакальнае</string>
<string name="recently_added">Нядаўна дададзенае</string>
<string name="no_playlist_bookmarked_yet">Няма закладак у плейлісце</string>
<string name="no_playlist_bookmarked_yet">Няма закладак ў плейлісце</string>
<string name="select_a_playlist">Выберыце плэйліст</string>
<string name="default_kiosk_page_summary">Кіёск па змаўчанні</string>
<string name="done">Так</string>
@ -478,7 +478,7 @@
<string name="notification_action_4_title">Кнопка пятага дзеяння</string>
<string name="notification_colorize_summary">Афарбоўваць апавяшчэнне асноўным колерам мініяцюры. Падтрымваецца не ўсімі прыладамі</string>
<string name="notification_actions_at_most_three">У кампактным апавяшчэнні дасяжна не больш за тры дзеянні!</string>
<string name="notification_actions_summary">Дзеянні можна змяніць, націснуўшы на іх. Адзначце не больш за трох для адлюстравання ў кампактным апавяшчэнні</string>
<string name="notification_actions_summary">Адрэдагуйце кожнае дзеянне апавяшчэння, націснуўшы на яго. Выберыце да трох з іх, якія будуць адлюстроўвацца ў кампактным апавяшчэнні, выкарыстоўваючы сцяжкі справа.</string>
<string name="unsupported_url_dialog_message">Не ўдалося распазнаць URL-адрас. Адкрыць у іншай праграме\?</string>
<string name="settings_category_player_notification_title">Апавяшченне плэера</string>
<string name="notifications">Апавяшчэнні</string>
@ -497,9 +497,9 @@
<string name="feed_group_dialog_empty_selection">Падпіскі не выбраны</string>
<string name="feed_oldest_subscription_update">Апошняе абнаўленне: %s</string>
<string name="auto_device_theme_title">Аўтаматычна (тэма прылады)</string>
<string name="night_theme_summary">Выберыце ўлюбёную начную тэму - %s</string>
<string name="night_theme_summary">Выберыце любімую начную тэму - %s</string>
<string name="description_select_enable">Дазвол вылучэння тэксту ў апісанні</string>
<string name="select_night_theme_toast">Ніжэй вы можаце абраць улюбёную начную тэму</string>
<string name="select_night_theme_toast">Вы можаце выбраць сваю любімую начную тэму ніжэй</string>
<string name="night_theme_available">Гэта опцыя даступна толькі тады, калі %s будзе выбранай тэмаю</string>
<string name="download_has_started">Загрузка пачалась</string>
<string name="notifications_disabled">Апавяшчэнні адключаныя</string>
@ -519,7 +519,7 @@
<string name="open_with">Адкрыць з дапамогай</string>
<string name="night_theme_title">Начная тэма</string>
<string name="open_website_license">Адкрыць вэб-сайт</string>
<string name="description_select_note">Цяпер Вы можаце вылучаць тэкст у апісанні. Звярніце ўвагу, што ў рэжыме вылучэння старонка можа мігацець, а спасылкі могуць быць недаступныя для націскання.</string>
<string name="description_select_note">Цяпер Вы можаце вылучаць тэкст ў апісанні. Звярніце ўвагу, што ў рэжыме вылучэння старонка можа мігацець, а спасылкі могуць быць недаступныя для націскання.</string>
<string name="start_main_player_fullscreen_title">Запусціць галоўны прайгравальнік у поўнаэкранным рэжыме</string>
<string name="show_channel_details">Паказаць дэталі канала</string>
<string name="low_quality_smaller">Нізкая якасць (менш)</string>
@ -574,14 +574,14 @@
<item quantity="many">Выдалена %1$s зазагрузак</item>
<item quantity="other">Выдалена %1$s зазагрузак</item>
</plurals>
<string name="delete_downloaded_files_confirm">Выдаліць усе загружаныя файлы з дыска\?</string>
<string name="delete_downloaded_files_confirm">Выдаліць ўсе загружаныя файлы з дыска?</string>
<plurals name="minutes">
<item quantity="one">%d хвіліна</item>
<item quantity="few">%d хвіліны</item>
<item quantity="many">%d хвілінаў</item>
<item quantity="other">%d хвілінаў</item>
</plurals>
<string name="progressive_load_interval_summary">Змяніць памер інтэрвалу загрузкі прагрэсіўнага змесціва (у цяперашні час %s). Меншае значэнне можа паскорыць іх першапачатковую загрузку</string>
<string name="progressive_load_interval_summary">Змяніць памер інтэрвалу загрузкі прагрэсіўнага змесціва (ў цяперашні час %s). Меншае значэнне можа паскорыць іх першапачатковую загрузку</string>
<string name="show_description_summary">Выключыце, каб схаваць апісанне відэа і дадатковую інфармацыю</string>
<string name="local_search_suggestions">Прапановы лакальнага пошуку</string>
<string name="settings_category_player_notification_summary">Наладзіць апавяшчэнне аб бягучым прайграванні патоку</string>
@ -608,7 +608,7 @@
<string name="msg_calculating_hash">Разлік хэша</string>
<string name="recaptcha_solve">Вырашана</string>
<string name="playlist_no_uploader">Створана аўтаматычна (запампавальнік не знойдзены)</string>
<string name="duplicate_in_playlist">Плэйлісты, якія пазначаны шэрым, ужо ўтрымліваюць гэты элемент.</string>
<string name="duplicate_in_playlist">Плэйлісты, якія пазначаны шэрым, ўжо ўтрымліваюць гэты элемент.</string>
<plurals name="new_streams">
<item quantity="one">%s новы стрым</item>
<item quantity="few">%s новыя стрымы</item>
@ -616,8 +616,8 @@
<item quantity="other">%s новых стрымаў</item>
</plurals>
<string name="comments_tab_description">Каментарыі</string>
<string name="enqueue_next_stream">У чаргу далей</string>
<string name="enqueued_next">У чарзе наступны</string>
<string name="enqueue_next_stream">Ў чаргу далей</string>
<string name="enqueued_next">Ў чарзе наступны</string>
<string name="loading_stream_details">Загрузка звестак аб стрыме…</string>
<string name="processing_may_take_a_moment">Апрацоўка... Можа заняць некаторы час</string>
<string name="playlist_add_stream_success_duplicate">Дублікат дададзены %d раз</string>
@ -650,7 +650,7 @@
<string name="checking_updates_toast">Праверка абнаўленняў…</string>
<string name="remove_duplicates_title">Выдаліць дублікаты\?</string>
<string name="remove_duplicates">Выдаліць дублікаты</string>
<string name="remove_duplicates_message">Вы хочаце выдаліць усе паўтаральныя стрымы ў гэтым плэйлісце\?</string>
<string name="remove_duplicates_message">Вы хочаце выдаліць ўсе паўтаральныя стрымы ў гэтым плэйлісце?</string>
<string name="feed_new_items">Новыя элементы стужкі</string>
<plurals name="feed_group_dialog_selection_count">
<item quantity="one">%d выбраны</item>
@ -670,7 +670,7 @@
<string name="enable_streams_notifications_summary">Апавяшчаць аб новых стрымах з падпісак</string>
<string name="streams_notifications_interval_title">Частата праверкі</string>
<string name="streams_notifications_network_title">Патрабуецца падключэнне да сеткі</string>
<string name="manual_update_title">Праверце наяўнасць абнаўленняў</string>
<string name="check_for_updates">Праверце наяўнасць абнаўленняў</string>
<string name="manual_update_description">Праверце новыя версіі ўручную</string>
<string name="autoplay_summary">Аўтаматычны запуск прайгравання — %s</string>
<string name="card">Картка</string>
@ -687,12 +687,12 @@
<string name="settings_category_feed_title">Стужка</string>
<string name="feed_update_threshold_summary">Час пасля апошняга абнаўлення, перш чым падпіска лічыцца састарэлай — %s</string>
<string name="feed_load_error">Памылка загрузкі стужкі</string>
<string name="feed_load_error_terminated">Уліковы запіс аўтара быў спынены.
\nNewPipe не зможа загрузіць гэты канал у будучыні.
\nВы хочаце адмовіцца ад падпіскі на гэты канал\?</string>
<string name="feed_load_error_terminated">Ўліковы запіс аўтара быў спынены.
\nNewPipe не зможа загрузіць гэты канал ў будучыні.
\nВы хочаце адмовіцца ад падпіскі на гэты канал?</string>
<string name="feed_load_error_fast_unknown">Рэжым хуткай загрузкі стужкі не дае дадатковай інфармацыі аб гэтым.</string>
<string name="feed_use_dedicated_fetch_method_title">Атрымлівайце са спецыяльнага канала, калі ён даступны</string>
<string name="feed_use_dedicated_fetch_method_enable_button">Уключыць хуткі рэжым</string>
<string name="feed_use_dedicated_fetch_method_enable_button">Ўключыць хуткі рэжым</string>
<string name="metadata_category">Катэгорыя</string>
<string name="metadata_tags">Тэгі</string>
<string name="metadata_licence">Ліцэнзія</string>
@ -700,7 +700,7 @@
<string name="metadata_privacy_unlisted">Не ў спісе</string>
<string name="metadata_privacy_private">Прыватная</string>
<string name="enumeration_comma">,</string>
<string name="toggle_all">Пераключыць усё</string>
<string name="toggle_all">Пераключыць ўсё</string>
<string name="streams_not_yet_supported_removed">Стрымы, якія яшчэ не падтрымліваюцца загрузчыкам, не адлюстроўваюцца</string>
<string name="detail_sub_channel_thumbnail_view_description">Мініяцюра аватара канала</string>
<string name="video_detail_by">Аўтар: %s</string>
@ -729,7 +729,7 @@
<string name="account_terminated">Уліковы запіс спынены</string>
<string name="service_provides_reason">%s дае наступную прычыну:</string>
<string name="featured">Рэкамендаваны</string>
<string name="metadata_privacy_internal">Унутраная</string>
<string name="metadata_privacy_internal">Ўнутраная</string>
<string name="feed_show_watched">Цалкам прагледзеў</string>
<string name="paid_content">Гэты кантэнт даступны толькі для аплачаных карыстальнікаў, таму NewPipe не можа яго трансляваць або спампоўваць.</string>
<string name="feed_use_dedicated_fetch_method_summary">Даступны ў некаторых службах, звычайна нашмат хутчэй, але можа вяртаць абмежаваную колькасць элементаў і часта няпоўную інфармацыю (напрыклад, без працягласці, тыпу элемента, без актыўнага стану)</string>
@ -739,7 +739,7 @@
<string name="no_app_to_open_intent">Ніякая праграма на вашай прыладзе не можа адкрыць гэта</string>
<string name="progressive_load_interval_exoplayer_default">Стандартнае значэнне ExoPlayer</string>
<string name="feed_show_partially_watched">Часткова прагледжана</string>
<string name="feed_use_dedicated_fetch_method_help_text">Як вы думаеце, загрузка корму адбываецца занадта павольна\? Калі так, паспрабуйце ўключыць хуткую загрузку (гэта можна змяніць у наладах або націснуўшы кнопку ніжэй).
<string name="feed_use_dedicated_fetch_method_help_text">Як вы думаеце, загрузка корму адбываецца занадта павольна? Калі так, паспрабуйце ўключыць хуткую загрузку (гэта можна змяніць ў наладах або націснуўшы кнопку ніжэй).
\n
\nNewPipe прапануе дзве стратэгіі загрузкі корму:
\n• Атрыманне ўсяго канала падпіскі павольнае, але поўнае.
@ -775,8 +775,8 @@
<string name="audio_track_type_original">арыгінальны</string>
<string name="audio_track_type_dubbed">дубляваны</string>
<string name="audio_track_type_descriptive">апісальны</string>
<string name="audio_track_present_in_video">Гукавая дарожка ўжо павінна прысутнічаць у гэтай плыні</string>
<string name="use_exoplayer_decoder_fallback_summary">Уключыце гэту опцыю, калі ў вас ёсць праблемы з ініцыялізацыяй дэкодэра, якая вяртаецца да дэкодэраў з больш нізкім прыярытэтам, калі ініцыялізацыя асноўных дэкодэраў не ўдаецца. Гэта можа прывесці да нізкай прадукцыйнасці прайгравання, чым пры выкарыстанні асноўных дэкодэраў</string>
<string name="audio_track_present_in_video">Гукавая дарожка ўжо павінна прысутнічаць ў гэтай плыні</string>
<string name="use_exoplayer_decoder_fallback_summary">Ўключыце гэту опцыю, калі ў вас ёсць праблемы з ініцыялізацыяй дэкодэра, якая вяртаецца да дэкодэраў з больш нізкім прыярытэтам, калі ініцыялізацыя асноўных дэкодэраў не ўдаецца. Гэта можа прывесці да нізкай прадукцыйнасці прайгравання, чым пры выкарыстанні асноўных дэкодэраў</string>
<string name="settings_category_exoplayer_summary">Кіраванне некаторымі наладамі ExoPlayer. Каб гэтыя змены ўступілі ў сілу, патрабуецца перазапуск гульца</string>
<string name="always_use_exoplayer_set_output_surface_workaround_summary">Гэты абыходны шлях вызваляе і паўторна стварае відэакодэкі, калі адбываецца змяненне паверхні, замест таго, каб усталёўваць паверхню непасрэдна для кодэка. ExoPlayer ужо выкарыстоўваецца на некаторых прыладах з гэтай праблемай, гэты параметр мае ўплыў толькі на прыладах з Android 6 і вышэй
\n
@ -820,7 +820,7 @@
<string name="video_details_list_item">- %1$s: %2$s</string>
<string name="main_tabs_position_summary">Перамясціць селектар галоўнай укладкі ўніз</string>
<string name="no_live_streams">Няма жывых трансляцый</string>
<string name="image_quality_summary">Выберыце якасць выявы і ці спампоўваць выявы ўвогуле, каб паменшыць выкарыстанне дадзеных і памяці. Змены ачышчаюць кэш малюнкаў як у памяці, так і на дыску - %s</string>
<string name="image_quality_summary">Выберыце якасць выявы і ці спампоўваць выявы ўвогуле, каб паменшыць выкарыстанне дадзеных і памяці. Змены ачышчаюць кэш малюнкаў як ў памяці, так і на дыску - %s</string>
<string name="play">Прайграць</string>
<string name="more_options">Іншыя опцыі</string>
<string name="metadata_thumbnails">Мініяцюры</string>
@ -830,4 +830,14 @@
<string name="channel_tab_channels">Каналы</string>
<string name="previous_stream">Папярэдні стрым</string>
<string name="channel_tab_livestreams">Жывая трансляцыя</string>
<plurals name="replies">
<item quantity="one">%s адказ</item>
<item quantity="few">%s адказы</item>
<item quantity="many">%s адказаў</item>
<item quantity="other">%s адказаў</item>
</plurals>
<string name="show_more">Паказаць больш</string>
<string name="show_less">Паказаць менш</string>
<string name="notification_actions_summary_android13">Адрэдагуйце кожнае дзеянне апавяшчэння, націснуўшы на яго. Першыя тры дзеянні (прайграванне/паўза, папярэдняе і наступнае) задаюцца сістэмай і не могуць быць зменены.</string>
<string name="error_insufficient_storage">Недастаткова вольнага месца на прыладзе</string>
</resources>

View file

@ -467,7 +467,7 @@
<string name="choose_instance_prompt">Изберете инстанция</string>
<string name="comments_are_disabled">Коментарите са изключени</string>
<string name="main_page_content_summary">Кои раздели се показват на началната страница</string>
<string name="error_insufficient_storage">Няма свободно място на устройството</string>
<string name="error_insufficient_storage_left">Няма свободно място на устройството</string>
<plurals name="seconds">
<item quantity="one">%d секунда</item>
<item quantity="other">%d секунди</item>
@ -548,7 +548,7 @@
<string name="recaptcha_cookies_cleared">Бисквитките от reCAPTCHA бяха почистени</string>
<string name="checking_updates_toast">Проверяване за актуализации…</string>
<string name="enumeration_comma">,</string>
<string name="manual_update_title">Провери за актуализации</string>
<string name="check_for_updates">Провери за актуализации</string>
<string name="percent">Процент</string>
<string name="unknown_quality">Неизвестно качество</string>
<string name="unknown_format">Неизвестен формат</string>

View file

@ -165,7 +165,7 @@
<string name="stop">বন্ধ করুন</string>
<string name="delete_downloaded_files">ডাউনলোড করা ফাইলগুলো ডিলিট করুন</string>
<string name="clear_download_history">ডাওন লোড ইতিহাস মুছুন</string>
<string name="error_insufficient_storage">ডিভাইস এ স্পেস নেই</string>
<string name="error_insufficient_storage_left">ডিভাইস এ স্পেস নেই</string>
<string name="error_http_not_found">পাওয়া যায় নি</string>
<string name="error_unknown_host">সার্ভার পাওয়া যায় নি</string>
<string name="show_error">এরর দেখান</string>
@ -297,7 +297,7 @@
<string name="notification_scale_to_square_image_title">থাম্বনেল ১:১ অনুপাতে সেট করো</string>
<string name="systems_language">সিস্টেম ডিফল্ট</string>
<string name="bookmark_playlist">প্লেলিস্ট বুকমার্ক করুন</string>
<string name="feed_use_dedicated_fetch_method_title">"যখন পর্যাপ্ত নিবেদিত ফিড থেকে ডাটা সংগ্রহ করুন"</string>
<string name="feed_use_dedicated_fetch_method_title">যখন পর্যাপ্ত নিবেদিত ফিড থেকে ডাটা সংগ্রহ করুন</string>
<string name="feed_update_threshold_option_always_update">সবসময় হালনগাদ করুন</string>
<string name="feed_update_threshold_summary">শেষ হালনাগাদের পর একটি সাবস্ক্রিপশনের আগের সময় সেকেলে বিবেচিত — %s</string>
<string name="feed_update_threshold_title">ফিড হালনাগাদ প্রবেশস্থল</string>
@ -317,11 +317,11 @@
<string name="feed_groups_header_title">চ্যানেল গ্রুপ</string>
<plurals name="days">
<item quantity="one">%d দিন</item>
<item quantity="other">"%d দিন"</item>
<item quantity="other">%d দিন</item>
</plurals>
<plurals name="hours">
<item quantity="one">%d ঘন্টা</item>
<item quantity="other">"%d ঘন্টা"</item>
<item quantity="other">%d ঘন্টা</item>
</plurals>
<plurals name="minutes">
<item quantity="one">%d মিনিট</item>
@ -401,7 +401,7 @@
<string name="default_content_country_title">কনটেন্টের জন্য পূর্বনির্ধারিত দেশ</string>
<string name="external_player_unsupported_link_type">বাইরের প্লেয়ারসমূহ এ ধরনের লিঙ্কসমূহ সমর্থন করে না</string>
<string name="msg_calculating_hash">হ্যাশ হিসাব করা হচ্ছে</string>
<string name="manual_update_title">আপডেট চেক করো</string>
<string name="check_for_updates">আপডেট চেক করো</string>
<plurals name="watching">
<item quantity="one">%s জন দেখছে</item>
<item quantity="other">%s জন দেখছে</item>

View file

@ -141,7 +141,7 @@
<string name="app_language_title">অ্যাপ এর ভাষা</string>
<string name="stop">বন্ধ করুন</string>
<string name="clear_download_history">ডাওন লোড ইতিহাস মুছুন</string>
<string name="error_insufficient_storage">ডিভাইস এ স্পেস নেই</string>
<string name="error_insufficient_storage_left">ডিভাইস এ স্পেস নেই</string>
<string name="error_http_not_found">পাওয়া যায় নি</string>
<string name="error_unknown_host">সার্ভার পাওয়া যায় নি</string>
<string name="download_failed">ডাউন লোড হয় নি</string>
@ -275,7 +275,7 @@
<string name="website_encouragement">নিউ পাইপ ওয়েব সাইট এ যান বিস্তারিত বিবরণ ও খবর এর জন্য</string>
<string name="more_than_100_videos">১০০+ ভিডিও</string>
<plurals name="listening">
<item quantity="one">"%s শ্রোতা"</item>
<item quantity="one">%s শ্রোতা</item>
<item quantity="other">%s শ্রোতা গন</item>
</plurals>
<string name="description_tab_description">বিবরণ</string>

View file

@ -10,7 +10,7 @@
<string name="stop">বন্ধ করুন</string>
<string name="delete_downloaded_files">ডাউনলোড করা ফাইলগুলো ডিলিট করুন</string>
<string name="clear_download_history">ডাওন লোড ইতিহাস মুছুন</string>
<string name="error_insufficient_storage">ডিভাইস এ স্পেস নেই</string>
<string name="error_insufficient_storage_left">ডিভাইস এ স্পেস নেই</string>
<string name="error_http_not_found">পাওয়া যায় নি</string>
<string name="error_unknown_host">সার্ভার পাওয়া যায় নি</string>
<string name="show_error">এরর দেখান</string>
@ -596,7 +596,7 @@
<string name="detail_pinned_comment_view_description">পিনকৃত মন্তব্য</string>
<string name="notifications">বিজ্ঞপ্তি</string>
<string name="checking_updates_toast">হালনাগাদ দেখা হচ্ছে …</string>
<string name="manual_update_title">হালনাগাদ আছে কিনা দেখো</string>
<string name="check_for_updates">হালনাগাদ আছে কিনা দেখো</string>
<string name="start_main_player_fullscreen_title">মূল প্লেয়ার ফুল স্ক্রীন এ শুরু করুন</string>
<string name="feed_new_items">ধারার নতুন ভুক্তি</string>
<string name="error_report_channel_name">ত্রুটি প্রতিবেদন এর বিজ্ঞপ্তি</string>

View file

@ -151,7 +151,7 @@
<string name="invalid_file">El fitxer no existeix o bé no teniu permisos de lectura/escriptura</string>
<string name="file_name_empty_error">El nom del fitxer no pot estar en blanc</string>
<string name="error_occurred_detail">S\'ha produït un error: %1$s</string>
<string name="error_report_button_text">Informeu de l\'error per correu electrònic</string>
<string name="error_report_button_text">Informeu per correu electrònic</string>
<string name="error_snackbar_message">S\'han produït alguns errors.</string>
<string name="error_snackbar_action">Informe</string>
<string name="what_device_headline">Informació:</string>
@ -385,7 +385,7 @@
<string name="enable_playback_resume_title">Reprèn la reproducció</string>
<string name="overwrite_failed">No es pot sobreescriure el fitxer</string>
<string name="download_already_pending">Hi ha una baixada pendent amb aquest nom</string>
<string name="error_insufficient_storage">No hi ha espai disponible al dispositiu</string>
<string name="error_insufficient_storage_left">No hi ha espai disponible al dispositiu</string>
<string name="error_progress_lost">S\'ha perdut el progrés perquè s\'ha eliminat el fitxer</string>
<string name="error_timeout">S\'ha excedit el temps d\'espera de la connexió</string>
<string name="confirm_prompt">Esteu segurs que voleu esborrar el vostre historial de baixades o esborrar-ne tots els fitxers\?</string>
@ -561,7 +561,7 @@
<string name="notification_action_shuffle">Mescla</string>
<string name="notification_action_repeat">Repeteix</string>
<string name="notification_actions_at_most_three">El màxim d\'accions que poden aparèixer en una notificació compacta és de tres!</string>
<string name="notification_actions_summary">Editeu cada acció de la notificació tocant el botó corresponent. Podeu seleccionar-ne fins a tres, que es mostraran a les notificacions en format compacte</string>
<string name="notification_actions_summary">Editeu cada acció de la notificació tocant el botó corresponent. Podeu seleccionar-ne fins a tres, que es mostraran a les notificacions en format compacte.</string>
<string name="notification_action_4_title">Cinquè botó d\'acció</string>
<string name="notification_action_3_title">Quart botó d\'acció</string>
<string name="notification_action_2_title">Tercer botó d\'acció</string>
@ -640,7 +640,7 @@
<string name="start_main_player_fullscreen_summary">Si la rotació automàtica està bloquejada, no inicieu vídeos al mini reproductor, sinó que aneu directament al mode de pantalla completa. Podeu accedir igualment al mini reproductor sortint de pantalla completa</string>
<string name="error_report_channel_name">Notificació d\'informe d\'error</string>
<string name="crash_the_player">Tancar abruptament el reproductor</string>
<string name="manual_update_title">Comprovar si hi ha actualitzacions</string>
<string name="check_for_updates">Comprovar si hi ha actualitzacions</string>
<string name="manual_update_description">Comprovar manualment si hi ha noves versions</string>
<plurals name="download_finished_notification">
<item quantity="one">Baixada finalitzada</item>
@ -692,9 +692,26 @@
<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>
<string name="progressive_load_interval_summary">Canvia la mida de l\'interval de càrrega en continguts progressius (actualment %s). Un valor inferior pot accelerar la càrrega inicial del vídeo.</string>
<string name="ignore_hardware_media_buttons_title">Ignora els esdeveniments dels botons de reproducció físics</string>
<string name="ignore_hardware_media_buttons_summary">Útil, per exemple, si feu servir uns auriculars amb els botons físicament trencats</string>
<string name="left_gesture_control_summary">Trieu un gest per la part esquerra de la pantalla</string>
<string name="progressive_load_interval_title">Mida de l\'interval de càrrega de reproducció</string>
<string name="left_gesture_control_title">Acció de gest esquerra</string>
<string name="notification_actions_summary_android13">Editeu cada acció de notificació de sota tocant-la. Les tres primeres accions (reproduir/pausa, anterior i següent) són establertes pel sistema i no es poden personalitzar.</string>
<string name="right_gesture_control_summary">Tria un gest per a la meitat dreta del reproductor</string>
<string name="right_gesture_control_title">Acció del gest dret</string>
<string name="brightness">Brillantor</string>
<string name="volume">Volum</string>
<string name="none">Cap</string>
<string name="main_tabs_position_summary">Mou el selector de pestanya principal a la part inferior</string>
<string name="main_tabs_position_title">Posició de les pestanyes principals</string>
<string name="prefer_descriptive_audio_title">Prefereix àudio descriptiu</string>
<string name="prefer_original_audio_summary">Seleccioneu la pista d\'àudio original independentment de l\'idioma</string>
<string name="prefer_original_audio_title">Prefereix l\'àudio original</string>
<string name="fast_mode">Mode ràpid</string>
<string name="loading_metadata_title">Carregant Metadades…</string>
<string name="prefer_descriptive_audio_summary">Seleccioneu una pista d\'àudio amb descripcions per a persones amb discapacitat visual si està disponible</string>
<string name="streams_notification_channel_name">Nous streams</string>
<string name="streams_notification_channel_description">Notificacions sobre nous streams per a subscripcions</string>
</resources>

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