mirror of
https://github.com/TeamNewPipe/NewPipe
synced 2024-12-23 08:30:44 +00:00
Merge pull request #9711 from TeamNewPipe/release-0.25.0
Release v0.25.0 (992)
This commit is contained in:
commit
831f36e18e
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -126,4 +126,4 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
||||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||||
run: ./gradlew build sonarqube --info
|
run: ./gradlew build sonar --info
|
||||||
|
6
.github/workflows/image-minimizer.js
vendored
6
.github/workflows/image-minimizer.js
vendored
@ -55,6 +55,7 @@ module.exports = async ({github, context}) => {
|
|||||||
return match;
|
return match;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let probeAspectRatio = 0;
|
||||||
let shouldModify = false;
|
let shouldModify = false;
|
||||||
try {
|
try {
|
||||||
console.log(`Probing ${g2}`);
|
console.log(`Probing ${g2}`);
|
||||||
@ -76,7 +77,8 @@ module.exports = async ({github, context}) => {
|
|||||||
}
|
}
|
||||||
console.log(`Probing resulted in ${probeResult.width}x${probeResult.height}px`);
|
console.log(`Probing resulted in ${probeResult.width}x${probeResult.height}px`);
|
||||||
|
|
||||||
shouldModify = probeResult.height > IMG_MAX_HEIGHT_PX && (probeResult.width / probeResult.height) < MIN_ASPECT_RATIO;
|
probeAspectRatio = probeResult.width / probeResult.height;
|
||||||
|
shouldModify = probeResult.height > IMG_MAX_HEIGHT_PX && probeAspectRatio < MIN_ASPECT_RATIO;
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
console.log('Probing failed:', e);
|
console.log('Probing failed:', e);
|
||||||
// Immediately abort
|
// Immediately abort
|
||||||
@ -86,7 +88,7 @@ module.exports = async ({github, context}) => {
|
|||||||
if (shouldModify) {
|
if (shouldModify) {
|
||||||
wasMatchModified = true;
|
wasMatchModified = true;
|
||||||
console.log(`Modifying match '${match}'`);
|
console.log(`Modifying match '${match}'`);
|
||||||
return `<img alt="${g1}" src="${g2}" height=${IMG_MAX_HEIGHT_PX} />`;
|
return `<img alt="${g1}" src="${g2}" width=${Math.min(600, (IMG_MAX_HEIGHT_PX * probeAspectRatio).toFixed(0))} />`;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Match '${match}' is ok/will not be modified`);
|
console.log(`Match '${match}' is ok/will not be modified`);
|
||||||
|
@ -1,23 +1,27 @@
|
|||||||
|
import com.android.tools.profgen.ArtProfileKt
|
||||||
|
import com.android.tools.profgen.ArtProfileSerializer
|
||||||
|
import com.android.tools.profgen.DexFile
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id "com.android.application"
|
id "com.android.application"
|
||||||
id "kotlin-android"
|
id "kotlin-android"
|
||||||
id "kotlin-kapt"
|
id "kotlin-kapt"
|
||||||
id "kotlin-parcelize"
|
id "kotlin-parcelize"
|
||||||
id "checkstyle"
|
id "checkstyle"
|
||||||
id "org.sonarqube" version "3.3"
|
id "org.sonarqube" version "3.5.0.2730"
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdk 32
|
compileSdk 33
|
||||||
namespace 'org.schabi.newpipe'
|
namespace 'org.schabi.newpipe'
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "org.schabi.newpipe"
|
applicationId "org.schabi.newpipe"
|
||||||
resValue "string", "app_name", "NewPipe"
|
resValue "string", "app_name", "NewPipe"
|
||||||
minSdk 21
|
minSdk 21
|
||||||
targetSdk 29
|
targetSdk 33
|
||||||
versionCode 991
|
versionCode 992
|
||||||
versionName "0.24.1"
|
versionName "0.25.0"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
@ -107,7 +111,7 @@ ext {
|
|||||||
groupieVersion = '2.10.1'
|
groupieVersion = '2.10.1'
|
||||||
markwonVersion = '4.6.2'
|
markwonVersion = '4.6.2'
|
||||||
|
|
||||||
leakCanaryVersion = '2.5'
|
leakCanaryVersion = '2.9.1'
|
||||||
stethoVersion = '1.6.0'
|
stethoVersion = '1.6.0'
|
||||||
mockitoVersion = '4.0.0'
|
mockitoVersion = '4.0.0'
|
||||||
assertJVersion = '3.23.1'
|
assertJVersion = '3.23.1'
|
||||||
@ -169,7 +173,7 @@ afterEvaluate {
|
|||||||
preDebugBuild.dependsOn runCheckstyle, runKtlint
|
preDebugBuild.dependsOn runCheckstyle, runKtlint
|
||||||
}
|
}
|
||||||
|
|
||||||
sonarqube {
|
sonar {
|
||||||
properties {
|
properties {
|
||||||
property "sonar.projectKey", "TeamNewPipe_NewPipe"
|
property "sonar.projectKey", "TeamNewPipe_NewPipe"
|
||||||
property "sonar.organization", "teamnewpipe"
|
property "sonar.organization", "teamnewpipe"
|
||||||
@ -179,7 +183,7 @@ sonarqube {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
/** Desugaring **/
|
/** Desugaring **/
|
||||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.6'
|
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.2'
|
||||||
|
|
||||||
/** NewPipe libraries **/
|
/** NewPipe libraries **/
|
||||||
// You can use a local version by uncommenting a few lines in settings.gradle
|
// You can use a local version by uncommenting a few lines in settings.gradle
|
||||||
@ -187,7 +191,7 @@ dependencies {
|
|||||||
// name and the commit hash with the commit hash of the (pushed) commit you want to test
|
// 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/
|
// This works thanks to JitPack: https://jitpack.io/
|
||||||
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
|
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
|
||||||
implementation 'com.github.TeamNewPipe:NewPipeExtractor:eb07d70a2ce03bee3cc74fc33b2e4173e1c21436'
|
implementation 'com.github.TeamNewPipe:NewPipeExtractor:7e793c11aec46358ccbfd8bcfcf521105f4f093a'
|
||||||
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
|
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
|
||||||
|
|
||||||
/** Checkstyle **/
|
/** Checkstyle **/
|
||||||
@ -259,14 +263,14 @@ dependencies {
|
|||||||
implementation "io.noties.markwon:linkify:${markwonVersion}"
|
implementation "io.noties.markwon:linkify:${markwonVersion}"
|
||||||
|
|
||||||
// Crash reporting
|
// Crash reporting
|
||||||
implementation "ch.acra:acra-core:5.9.3"
|
implementation "ch.acra:acra-core:5.9.7"
|
||||||
|
|
||||||
// Properly restarting
|
// Properly restarting
|
||||||
implementation 'com.jakewharton:process-phoenix:2.1.2'
|
implementation 'com.jakewharton:process-phoenix:2.1.2'
|
||||||
|
|
||||||
// Reactive extensions for Java VM
|
// Reactive extensions for Java VM
|
||||||
implementation "io.reactivex.rxjava3:rxjava:3.0.13"
|
implementation "io.reactivex.rxjava3:rxjava:3.1.5"
|
||||||
implementation "io.reactivex.rxjava3:rxandroid:3.0.0"
|
implementation "io.reactivex.rxjava3:rxandroid:3.0.2"
|
||||||
// RxJava binding APIs for Android UI widgets
|
// RxJava binding APIs for Android UI widgets
|
||||||
implementation "com.jakewharton.rxbinding4:rxbinding:4.0.0"
|
implementation "com.jakewharton.rxbinding4:rxbinding:4.0.0"
|
||||||
|
|
||||||
@ -308,3 +312,24 @@ static String getGitWorkingBranch() {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
project.afterEvaluate {
|
||||||
|
tasks.compileReleaseArtProfile.doLast {
|
||||||
|
outputs.files.each { file ->
|
||||||
|
if (file.toString().endsWith(".profm")) {
|
||||||
|
println("Sorting ${file} ...")
|
||||||
|
def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
|
||||||
|
def profile = ArtProfileKt.ArtProfile(file)
|
||||||
|
def keys = new ArrayList(profile.profileData.keySet())
|
||||||
|
def sortedData = new LinkedHashMap()
|
||||||
|
Collections.sort keys, new DexFile.Companion()
|
||||||
|
keys.each { key -> sortedData[key] = profile.profileData[key] }
|
||||||
|
new FileOutputStream(file).with {
|
||||||
|
write(version.magicBytes$profgen)
|
||||||
|
write(version.versionBytes$profgen)
|
||||||
|
version.write$profgen(it, sortedData, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
737
app/schemas/org.schabi.newpipe.database.AppDatabase/6.json
Normal file
737
app/schemas/org.schabi.newpipe.database.AppDatabase/6.json
Normal file
@ -0,0 +1,737 @@
|
|||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 6,
|
||||||
|
"identityHash": "4084aa342aef315dc7b558770a7755a9",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "subscriptions",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT, `name` TEXT, `avatar_url` TEXT, `subscriber_count` INTEGER, `description` TEXT, `notification_mode` INTEGER NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serviceId",
|
||||||
|
"columnName": "service_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "avatarUrl",
|
||||||
|
"columnName": "avatar_url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subscriberCount",
|
||||||
|
"columnName": "subscriber_count",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "notificationMode",
|
||||||
|
"columnName": "notification_mode",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"uid"
|
||||||
|
],
|
||||||
|
"autoGenerate": true
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_subscriptions_service_id_url",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"service_id",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_subscriptions_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "search_history",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`creation_date` INTEGER, `service_id` INTEGER NOT NULL, `search` TEXT, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "creationDate",
|
||||||
|
"columnName": "creation_date",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serviceId",
|
||||||
|
"columnName": "service_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "search",
|
||||||
|
"columnName": "search",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"autoGenerate": true
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_search_history_search",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"search"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_search_history_search` ON `${TABLE_NAME}` (`search`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "streams",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `url` TEXT NOT NULL, `title` TEXT NOT NULL, `stream_type` TEXT NOT NULL, `duration` INTEGER NOT NULL, `uploader` TEXT NOT NULL, `uploader_url` TEXT, `thumbnail_url` TEXT, `view_count` INTEGER, `textual_upload_date` TEXT, `upload_date` INTEGER, `is_upload_date_approximation` INTEGER)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serviceId",
|
||||||
|
"columnName": "service_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "streamType",
|
||||||
|
"columnName": "stream_type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "duration",
|
||||||
|
"columnName": "duration",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uploader",
|
||||||
|
"columnName": "uploader",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uploaderUrl",
|
||||||
|
"columnName": "uploader_url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "thumbnailUrl",
|
||||||
|
"columnName": "thumbnail_url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "viewCount",
|
||||||
|
"columnName": "view_count",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "textualUploadDate",
|
||||||
|
"columnName": "textual_upload_date",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uploadDate",
|
||||||
|
"columnName": "upload_date",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isUploadDateApproximation",
|
||||||
|
"columnName": "is_upload_date_approximation",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"uid"
|
||||||
|
],
|
||||||
|
"autoGenerate": true
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_streams_service_id_url",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"service_id",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_streams_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "stream_history",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, `repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "streamUid",
|
||||||
|
"columnName": "stream_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "accessDate",
|
||||||
|
"columnName": "access_date",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "repeatCount",
|
||||||
|
"columnName": "repeat_count",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"stream_id",
|
||||||
|
"access_date"
|
||||||
|
],
|
||||||
|
"autoGenerate": false
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_stream_history_stream_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"stream_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_stream_history_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "streams",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"stream_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"uid"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "stream_state",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "streamUid",
|
||||||
|
"columnName": "stream_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "progressMillis",
|
||||||
|
"columnName": "progress_time",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"stream_id"
|
||||||
|
],
|
||||||
|
"autoGenerate": false
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "streams",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"stream_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"uid"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "playlists",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `thumbnail_url` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "thumbnailUrl",
|
||||||
|
"columnName": "thumbnail_url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isThumbnailPermanent",
|
||||||
|
"columnName": "is_thumbnail_permanent",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"uid"
|
||||||
|
],
|
||||||
|
"autoGenerate": true
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_playlists_name",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlists_name` ON `${TABLE_NAME}` (`name`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "playlist_stream_join",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, `join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "playlistUid",
|
||||||
|
"columnName": "playlist_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "streamUid",
|
||||||
|
"columnName": "stream_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "index",
|
||||||
|
"columnName": "join_index",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"playlist_id",
|
||||||
|
"join_index"
|
||||||
|
],
|
||||||
|
"autoGenerate": false
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_playlist_stream_join_playlist_id_join_index",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"playlist_id",
|
||||||
|
"join_index"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_playlist_stream_join_playlist_id_join_index` ON `${TABLE_NAME}` (`playlist_id`, `join_index`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_playlist_stream_join_stream_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"stream_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_stream_join_stream_id` ON `${TABLE_NAME}` (`stream_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "playlists",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"playlist_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"uid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "streams",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"stream_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"uid"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "remote_playlists",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, `thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "serviceId",
|
||||||
|
"columnName": "service_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "url",
|
||||||
|
"columnName": "url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "thumbnailUrl",
|
||||||
|
"columnName": "thumbnail_url",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "uploader",
|
||||||
|
"columnName": "uploader",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "streamCount",
|
||||||
|
"columnName": "stream_count",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"uid"
|
||||||
|
],
|
||||||
|
"autoGenerate": true
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_remote_playlists_name",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_remote_playlists_name` ON `${TABLE_NAME}` (`name`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_remote_playlists_service_id_url",
|
||||||
|
"unique": true,
|
||||||
|
"columnNames": [
|
||||||
|
"service_id",
|
||||||
|
"url"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_remote_playlists_service_id_url` ON `${TABLE_NAME}` (`service_id`, `url`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "feed",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`stream_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `subscription_id`), FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "streamId",
|
||||||
|
"columnName": "stream_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subscriptionId",
|
||||||
|
"columnName": "subscription_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"stream_id",
|
||||||
|
"subscription_id"
|
||||||
|
],
|
||||||
|
"autoGenerate": false
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_feed_subscription_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"subscription_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "streams",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"stream_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"uid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "subscriptions",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"subscription_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"uid"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "feed_group",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `icon_id` INTEGER NOT NULL, `sort_order` INTEGER NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "uid",
|
||||||
|
"columnName": "uid",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "icon",
|
||||||
|
"columnName": "icon_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sortOrder",
|
||||||
|
"columnName": "sort_order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"uid"
|
||||||
|
],
|
||||||
|
"autoGenerate": true
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_feed_group_sort_order",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"sort_order"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_sort_order` ON `${TABLE_NAME}` (`sort_order`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "feed_group_subscription_join",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`group_id` INTEGER NOT NULL, `subscription_id` INTEGER NOT NULL, PRIMARY KEY(`group_id`, `subscription_id`), FOREIGN KEY(`group_id`) REFERENCES `feed_group`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "feedGroupId",
|
||||||
|
"columnName": "group_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "subscriptionId",
|
||||||
|
"columnName": "subscription_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"group_id",
|
||||||
|
"subscription_id"
|
||||||
|
],
|
||||||
|
"autoGenerate": false
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_feed_group_subscription_join_subscription_id",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"subscription_id"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_feed_group_subscription_join_subscription_id` ON `${TABLE_NAME}` (`subscription_id`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "feed_group",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"group_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"uid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"table": "subscriptions",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"subscription_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"uid"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "feed_last_updated",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`subscription_id` INTEGER NOT NULL, `last_updated` INTEGER, PRIMARY KEY(`subscription_id`), FOREIGN KEY(`subscription_id`) REFERENCES `subscriptions`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "subscriptionId",
|
||||||
|
"columnName": "subscription_id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastUpdated",
|
||||||
|
"columnName": "last_updated",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"columnNames": [
|
||||||
|
"subscription_id"
|
||||||
|
],
|
||||||
|
"autoGenerate": false
|
||||||
|
},
|
||||||
|
"indices": [],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "subscriptions",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "CASCADE",
|
||||||
|
"columns": [
|
||||||
|
"subscription_id"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"uid"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"views": [],
|
||||||
|
"setupQueries": [
|
||||||
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||||
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4084aa342aef315dc7b558770a7755a9')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
@ -33,7 +33,8 @@ class DatabaseMigrationTest {
|
|||||||
@get:Rule
|
@get:Rule
|
||||||
val testHelper = MigrationTestHelper(
|
val testHelper = MigrationTestHelper(
|
||||||
InstrumentationRegistry.getInstrumentation(),
|
InstrumentationRegistry.getInstrumentation(),
|
||||||
AppDatabase::class.java.canonicalName, FrameworkSQLiteOpenHelperFactory()
|
AppDatabase::class.java.canonicalName,
|
||||||
|
FrameworkSQLiteOpenHelperFactory()
|
||||||
)
|
)
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -42,7 +43,8 @@ class DatabaseMigrationTest {
|
|||||||
|
|
||||||
databaseInV2.run {
|
databaseInV2.run {
|
||||||
insert(
|
insert(
|
||||||
"streams", SQLiteDatabase.CONFLICT_FAIL,
|
"streams",
|
||||||
|
SQLiteDatabase.CONFLICT_FAIL,
|
||||||
ContentValues().apply {
|
ContentValues().apply {
|
||||||
put("service_id", DEFAULT_SERVICE_ID)
|
put("service_id", DEFAULT_SERVICE_ID)
|
||||||
put("url", DEFAULT_URL)
|
put("url", DEFAULT_URL)
|
||||||
@ -54,14 +56,16 @@ class DatabaseMigrationTest {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
insert(
|
insert(
|
||||||
"streams", SQLiteDatabase.CONFLICT_FAIL,
|
"streams",
|
||||||
|
SQLiteDatabase.CONFLICT_FAIL,
|
||||||
ContentValues().apply {
|
ContentValues().apply {
|
||||||
put("service_id", DEFAULT_SECOND_SERVICE_ID)
|
put("service_id", DEFAULT_SECOND_SERVICE_ID)
|
||||||
put("url", DEFAULT_SECOND_URL)
|
put("url", DEFAULT_SECOND_URL)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
insert(
|
insert(
|
||||||
"streams", SQLiteDatabase.CONFLICT_FAIL,
|
"streams",
|
||||||
|
SQLiteDatabase.CONFLICT_FAIL,
|
||||||
ContentValues().apply {
|
ContentValues().apply {
|
||||||
put("service_id", DEFAULT_SERVICE_ID)
|
put("service_id", DEFAULT_SERVICE_ID)
|
||||||
}
|
}
|
||||||
@ -70,18 +74,31 @@ class DatabaseMigrationTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
testHelper.runMigrationsAndValidate(
|
testHelper.runMigrationsAndValidate(
|
||||||
AppDatabase.DATABASE_NAME, Migrations.DB_VER_3,
|
AppDatabase.DATABASE_NAME,
|
||||||
true, Migrations.MIGRATION_2_3
|
Migrations.DB_VER_3,
|
||||||
|
true,
|
||||||
|
Migrations.MIGRATION_2_3
|
||||||
)
|
)
|
||||||
|
|
||||||
testHelper.runMigrationsAndValidate(
|
testHelper.runMigrationsAndValidate(
|
||||||
AppDatabase.DATABASE_NAME, Migrations.DB_VER_4,
|
AppDatabase.DATABASE_NAME,
|
||||||
true, Migrations.MIGRATION_3_4
|
Migrations.DB_VER_4,
|
||||||
|
true,
|
||||||
|
Migrations.MIGRATION_3_4
|
||||||
)
|
)
|
||||||
|
|
||||||
testHelper.runMigrationsAndValidate(
|
testHelper.runMigrationsAndValidate(
|
||||||
AppDatabase.DATABASE_NAME, Migrations.DB_VER_5,
|
AppDatabase.DATABASE_NAME,
|
||||||
true, Migrations.MIGRATION_4_5
|
Migrations.DB_VER_5,
|
||||||
|
true,
|
||||||
|
Migrations.MIGRATION_4_5
|
||||||
|
)
|
||||||
|
|
||||||
|
testHelper.runMigrationsAndValidate(
|
||||||
|
AppDatabase.DATABASE_NAME,
|
||||||
|
Migrations.DB_VER_6,
|
||||||
|
true,
|
||||||
|
Migrations.MIGRATION_5_6
|
||||||
)
|
)
|
||||||
|
|
||||||
val migratedDatabaseV3 = getMigratedDatabase()
|
val migratedDatabaseV3 = getMigratedDatabase()
|
||||||
@ -121,7 +138,8 @@ class DatabaseMigrationTest {
|
|||||||
private fun getMigratedDatabase(): AppDatabase {
|
private fun getMigratedDatabase(): AppDatabase {
|
||||||
val database: AppDatabase = Room.databaseBuilder(
|
val database: AppDatabase = Room.databaseBuilder(
|
||||||
ApplicationProvider.getApplicationContext(),
|
ApplicationProvider.getApplicationContext(),
|
||||||
AppDatabase::class.java, AppDatabase.DATABASE_NAME
|
AppDatabase::class.java,
|
||||||
|
AppDatabase.DATABASE_NAME
|
||||||
)
|
)
|
||||||
.build()
|
.build()
|
||||||
testHelper.closeWhenFinished(database)
|
testHelper.closeWhenFinished(database)
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
package org.schabi.newpipe.util
|
package org.schabi.newpipe.util
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.SparseArray
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.View.GONE
|
import android.view.View.GONE
|
||||||
import android.view.View.INVISIBLE
|
import android.view.View.INVISIBLE
|
||||||
import android.view.View.VISIBLE
|
import android.view.View.VISIBLE
|
||||||
import android.widget.Spinner
|
import android.widget.Spinner
|
||||||
|
import androidx.collection.SparseArrayCompat
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
@ -39,9 +39,7 @@ class StreamItemAdapterTest {
|
|||||||
@Test
|
@Test
|
||||||
fun videoStreams_noSecondaryStream() {
|
fun videoStreams_noSecondaryStream() {
|
||||||
val adapter = StreamItemAdapter<VideoStream, AudioStream>(
|
val adapter = StreamItemAdapter<VideoStream, AudioStream>(
|
||||||
context,
|
getVideoStreams(true, true, true, true)
|
||||||
getVideoStreams(true, true, true, true),
|
|
||||||
null
|
|
||||||
)
|
)
|
||||||
|
|
||||||
spinner.adapter = adapter
|
spinner.adapter = adapter
|
||||||
@ -54,7 +52,6 @@ class StreamItemAdapterTest {
|
|||||||
@Test
|
@Test
|
||||||
fun videoStreams_hasSecondaryStream() {
|
fun videoStreams_hasSecondaryStream() {
|
||||||
val adapter = StreamItemAdapter(
|
val adapter = StreamItemAdapter(
|
||||||
context,
|
|
||||||
getVideoStreams(false, true, false, true),
|
getVideoStreams(false, true, false, true),
|
||||||
getAudioStreams(false, true, false, true)
|
getAudioStreams(false, true, false, true)
|
||||||
)
|
)
|
||||||
@ -69,7 +66,6 @@ class StreamItemAdapterTest {
|
|||||||
@Test
|
@Test
|
||||||
fun videoStreams_Mixed() {
|
fun videoStreams_Mixed() {
|
||||||
val adapter = StreamItemAdapter(
|
val adapter = StreamItemAdapter(
|
||||||
context,
|
|
||||||
getVideoStreams(true, true, true, true, true, false, true, true),
|
getVideoStreams(true, true, true, true, true, false, true, true),
|
||||||
getAudioStreams(false, true, false, false, false, true, true, true)
|
getAudioStreams(false, true, false, false, false, true, true, true)
|
||||||
)
|
)
|
||||||
@ -88,7 +84,6 @@ class StreamItemAdapterTest {
|
|||||||
@Test
|
@Test
|
||||||
fun subtitleStreams_noIcon() {
|
fun subtitleStreams_noIcon() {
|
||||||
val adapter = StreamItemAdapter<SubtitlesStream, Stream>(
|
val adapter = StreamItemAdapter<SubtitlesStream, Stream>(
|
||||||
context,
|
|
||||||
StreamItemAdapter.StreamSizeWrapper(
|
StreamItemAdapter.StreamSizeWrapper(
|
||||||
(0 until 5).map {
|
(0 until 5).map {
|
||||||
SubtitlesStream.Builder()
|
SubtitlesStream.Builder()
|
||||||
@ -99,8 +94,7 @@ class StreamItemAdapterTest {
|
|||||||
.build()
|
.build()
|
||||||
},
|
},
|
||||||
context
|
context
|
||||||
),
|
)
|
||||||
null
|
|
||||||
)
|
)
|
||||||
spinner.adapter = adapter
|
spinner.adapter = adapter
|
||||||
for (i in 0 until spinner.count) {
|
for (i in 0 until spinner.count) {
|
||||||
@ -111,7 +105,6 @@ class StreamItemAdapterTest {
|
|||||||
@Test
|
@Test
|
||||||
fun audioStreams_noIcon() {
|
fun audioStreams_noIcon() {
|
||||||
val adapter = StreamItemAdapter<AudioStream, Stream>(
|
val adapter = StreamItemAdapter<AudioStream, Stream>(
|
||||||
context,
|
|
||||||
StreamItemAdapter.StreamSizeWrapper(
|
StreamItemAdapter.StreamSizeWrapper(
|
||||||
(0 until 5).map {
|
(0 until 5).map {
|
||||||
AudioStream.Builder()
|
AudioStream.Builder()
|
||||||
@ -122,8 +115,7 @@ class StreamItemAdapterTest {
|
|||||||
.build()
|
.build()
|
||||||
},
|
},
|
||||||
context
|
context
|
||||||
),
|
)
|
||||||
null
|
|
||||||
)
|
)
|
||||||
spinner.adapter = adapter
|
spinner.adapter = adapter
|
||||||
for (i in 0 until spinner.count) {
|
for (i in 0 until spinner.count) {
|
||||||
@ -200,7 +192,7 @@ class StreamItemAdapterTest {
|
|||||||
* Helper function that builds a secondary stream list.
|
* Helper function that builds a secondary stream list.
|
||||||
*/
|
*/
|
||||||
private fun <T : Stream> getSecondaryStreamsFromList(streams: List<T?>) =
|
private fun <T : Stream> getSecondaryStreamsFromList(streams: List<T?>) =
|
||||||
SparseArray<SecondaryStreamHelper<T>?>(streams.size).apply {
|
SparseArrayCompat<SecondaryStreamHelper<T>?>(streams.size).apply {
|
||||||
streams.forEachIndexed { index, stream ->
|
streams.forEachIndexed { index, stream ->
|
||||||
val secondaryStreamHelper: SecondaryStreamHelper<T>? = stream?.let {
|
val secondaryStreamHelper: SecondaryStreamHelper<T>? = stream?.let {
|
||||||
SecondaryStreamHelper(
|
SecondaryStreamHelper(
|
||||||
|
@ -9,6 +9,15 @@
|
|||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
|
||||||
|
<!-- We need to be able to open links in the browser on API 30+ -->
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<data android:scheme="http|https|market" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
|
|
||||||
<uses-feature
|
<uses-feature
|
||||||
android:name="android.hardware.touchscreen"
|
android:name="android.hardware.touchscreen"
|
||||||
@ -24,11 +33,12 @@
|
|||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:logo="@mipmap/ic_launcher"
|
android:logo="@mipmap/ic_launcher"
|
||||||
android:theme="@style/OpeningTheme"
|
|
||||||
android:resizeableActivity="true"
|
android:resizeableActivity="true"
|
||||||
|
android:theme="@style/OpeningTheme"
|
||||||
tools:ignore="AllowBackup">
|
tools:ignore="AllowBackup">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:launchMode="singleTask">
|
android:launchMode="singleTask">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@ -39,7 +49,9 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<receiver android:name="androidx.media.session.MediaButtonReceiver">
|
<receiver
|
||||||
|
android:name="androidx.media.session.MediaButtonReceiver"
|
||||||
|
android:exported="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
@ -47,7 +59,7 @@
|
|||||||
|
|
||||||
<service
|
<service
|
||||||
android:name=".player.PlayerService"
|
android:name=".player.PlayerService"
|
||||||
android:exported="false"
|
android:exported="true"
|
||||||
android:foregroundServiceType="mediaPlayback">
|
android:foregroundServiceType="mediaPlayback">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
@ -56,15 +68,18 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".player.PlayQueueActivity"
|
android:name=".player.PlayQueueActivity"
|
||||||
|
android:exported="false"
|
||||||
android:label="@string/title_activity_play_queue"
|
android:label="@string/title_activity_play_queue"
|
||||||
android:launchMode="singleTask" />
|
android:launchMode="singleTask" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".settings.SettingsActivity"
|
android:name=".settings.SettingsActivity"
|
||||||
|
android:exported="false"
|
||||||
android:label="@string/settings" />
|
android:label="@string/settings" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".about.AboutActivity"
|
android:name=".about.AboutActivity"
|
||||||
|
android:exported="false"
|
||||||
android:label="@string/title_activity_about" />
|
android:label="@string/title_activity_about" />
|
||||||
|
|
||||||
<service android:name=".local.subscription.services.SubscriptionsImportService" />
|
<service android:name=".local.subscription.services.SubscriptionsImportService" />
|
||||||
@ -73,6 +88,7 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".PanicResponderActivity"
|
android:name=".PanicResponderActivity"
|
||||||
|
android:exported="true"
|
||||||
android:launchMode="singleInstance"
|
android:launchMode="singleInstance"
|
||||||
android:noHistory="true"
|
android:noHistory="true"
|
||||||
android:theme="@android:style/Theme.NoDisplay">
|
android:theme="@android:style/Theme.NoDisplay">
|
||||||
@ -85,13 +101,18 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".ExitActivity"
|
android:name=".ExitActivity"
|
||||||
|
android:exported="false"
|
||||||
android:label="@string/general_error"
|
android:label="@string/general_error"
|
||||||
android:theme="@android:style/Theme.NoDisplay" />
|
android:theme="@android:style/Theme.NoDisplay" />
|
||||||
<activity android:name=".error.ErrorActivity" />
|
|
||||||
|
<activity
|
||||||
|
android:name=".error.ErrorActivity"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
<!-- giga get related -->
|
<!-- giga get related -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".download.DownloadActivity"
|
android:name=".download.DownloadActivity"
|
||||||
|
android:exported="false"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:launchMode="singleTask" />
|
android:launchMode="singleTask" />
|
||||||
|
|
||||||
@ -99,6 +120,7 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".util.FilePickerActivityHelper"
|
android:name=".util.FilePickerActivityHelper"
|
||||||
|
android:exported="true"
|
||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:theme="@style/FilePickerThemeDark">
|
android:theme="@style/FilePickerThemeDark">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
@ -109,6 +131,7 @@
|
|||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".error.ReCaptchaActivity"
|
android:name=".error.ReCaptchaActivity"
|
||||||
|
android:exported="false"
|
||||||
android:label="@string/recaptcha" />
|
android:label="@string/recaptcha" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
@ -124,6 +147,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".RouterActivity"
|
android:name=".RouterActivity"
|
||||||
android:excludeFromRecents="true"
|
android:excludeFromRecents="true"
|
||||||
|
android:exported="true"
|
||||||
android:label="@string/preferred_open_action_share_menu_title"
|
android:label="@string/preferred_open_action_share_menu_title"
|
||||||
android:taskAffinity=""
|
android:taskAffinity=""
|
||||||
android:theme="@style/RouterActivityThemeDark">
|
android:theme="@style/RouterActivityThemeDark">
|
||||||
@ -149,6 +173,7 @@
|
|||||||
<data android:pathPrefix="/watch" />
|
<data android:pathPrefix="/watch" />
|
||||||
<data android:pathPrefix="/attribution_link" />
|
<data android:pathPrefix="/attribution_link" />
|
||||||
<data android:pathPrefix="/shorts/" />
|
<data android:pathPrefix="/shorts/" />
|
||||||
|
<data android:pathPrefix="/live/" />
|
||||||
<!-- channel prefix -->
|
<!-- channel prefix -->
|
||||||
<data android:pathPrefix="/channel/" />
|
<data android:pathPrefix="/channel/" />
|
||||||
<data android:pathPrefix="/user/" />
|
<data android:pathPrefix="/user/" />
|
||||||
@ -337,7 +362,6 @@
|
|||||||
<data android:host="peertube.mastodon.host" />
|
<data android:host="peertube.mastodon.host" />
|
||||||
<data android:host="peertube.fr" />
|
<data android:host="peertube.fr" />
|
||||||
<data android:host="tilvids.com" />
|
<data android:host="tilvids.com" />
|
||||||
<data android:host="tube.privacytools.io" />
|
|
||||||
<data android:host="video.ploud.fr" />
|
<data android:host="video.ploud.fr" />
|
||||||
<data android:host="video.lqdn.fr" />
|
<data android:host="video.lqdn.fr" />
|
||||||
<data android:host="skeptikon.fr" />
|
<data android:host="skeptikon.fr" />
|
||||||
@ -354,30 +378,30 @@
|
|||||||
|
|
||||||
<!-- Bandcamp filter for tracks, albums and playlists -->
|
<!-- Bandcamp filter for tracks, albums and playlists -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW"/>
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH"/>
|
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
|
||||||
<action android:name="android.nfc.action.NDEF_DISCOVERED"/>
|
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT"/>
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE"/>
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data android:scheme="http"/>
|
<data android:scheme="http" />
|
||||||
<data android:scheme="https"/>
|
<data android:scheme="https" />
|
||||||
<data android:host="*.bandcamp.com"/>
|
<data android:host="*.bandcamp.com" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
<!-- Bandcamp filter for radio -->
|
<!-- Bandcamp filter for radio -->
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.VIEW"/>
|
<action android:name="android.intent.action.VIEW" />
|
||||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH"/>
|
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
|
||||||
<action android:name="android.nfc.action.NDEF_DISCOVERED"/>
|
<action android:name="android.nfc.action.NDEF_DISCOVERED" />
|
||||||
|
|
||||||
<category android:name="android.intent.category.DEFAULT"/>
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<category android:name="android.intent.category.BROWSABLE"/>
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
|
||||||
<data android:scheme="http"/>
|
<data android:scheme="http" />
|
||||||
<data android:scheme="https"/>
|
<data android:scheme="https" />
|
||||||
<data android:sspPattern="bandcamp.com/?show=*"/>
|
<data android:sspPattern="bandcamp.com/?show=*" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|
||||||
</activity>
|
</activity>
|
||||||
@ -386,11 +410,17 @@
|
|||||||
android:exported="false" />
|
android:exported="false" />
|
||||||
|
|
||||||
<!-- opting out of sending metrics to Google in Android System WebView -->
|
<!-- opting out of sending metrics to Google in Android System WebView -->
|
||||||
<meta-data android:name="android.webkit.WebView.MetricsOptOut" android:value="true" />
|
<meta-data
|
||||||
|
android:name="android.webkit.WebView.MetricsOptOut"
|
||||||
|
android:value="true" />
|
||||||
<!-- see https://github.com/TeamNewPipe/NewPipe/issues/3947 -->
|
<!-- see https://github.com/TeamNewPipe/NewPipe/issues/3947 -->
|
||||||
<!-- Version < 3.0. DeX Mode and Screen Mirroring support -->
|
<!-- Version < 3.0. DeX Mode and Screen Mirroring support -->
|
||||||
<meta-data android:name="com.samsung.android.keepalive.density" android:value="true"/>
|
<meta-data
|
||||||
|
android:name="com.samsung.android.keepalive.density"
|
||||||
|
android:value="true" />
|
||||||
<!-- Version >= 3.0. DeX Dual Mode support -->
|
<!-- Version >= 3.0. DeX Dual Mode support -->
|
||||||
<meta-data android:name="com.samsung.android.multidisplay.keep_process_alive" android:value="true"/>
|
<meta-data
|
||||||
|
android:name="com.samsung.android.multidisplay.keep_process_alive"
|
||||||
|
android:value="true" />
|
||||||
</application>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
@ -157,10 +157,13 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
openMiniPlayerUponPlayerStarted();
|
openMiniPlayerUponPlayerStarted();
|
||||||
|
|
||||||
|
if (PermissionHelper.checkPostNotificationsPermission(this,
|
||||||
|
PermissionHelper.POST_NOTIFICATIONS_REQUEST_CODE)) {
|
||||||
// Schedule worker for checking for new streams and creating corresponding notifications
|
// Schedule worker for checking for new streams and creating corresponding notifications
|
||||||
// if this is enabled by the user.
|
// if this is enabled by the user.
|
||||||
NotificationWorker.initialize(this);
|
NotificationWorker.initialize(this);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onPostCreate(final Bundle savedInstanceState) {
|
protected void onPostCreate(final Bundle savedInstanceState) {
|
||||||
@ -172,7 +175,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
if (prefs.getBoolean(app.getString(R.string.update_app_key), true)) {
|
if (prefs.getBoolean(app.getString(R.string.update_app_key), true)) {
|
||||||
// Start the worker which is checking all conditions
|
// Start the worker which is checking all conditions
|
||||||
// and eventually searching for a new version.
|
// and eventually searching for a new version.
|
||||||
NewVersionWorker.enqueueNewVersionCheckingWork(app);
|
NewVersionWorker.enqueueNewVersionCheckingWork(app, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -232,7 +235,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
.setIcon(R.drawable.ic_tv);
|
.setIcon(R.drawable.ic_tv);
|
||||||
drawerLayoutBinding.navigation.getMenu()
|
drawerLayoutBinding.navigation.getMenu()
|
||||||
.add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title)
|
.add(R.id.menu_tabs_group, ITEM_ID_FEED, ORDER, R.string.fragment_feed_title)
|
||||||
.setIcon(R.drawable.ic_rss_feed);
|
.setIcon(R.drawable.ic_subscriptions);
|
||||||
drawerLayoutBinding.navigation.getMenu()
|
drawerLayoutBinding.navigation.getMenu()
|
||||||
.add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks)
|
.add(R.id.menu_tabs_group, ITEM_ID_BOOKMARKS, ORDER, R.string.tab_bookmarks)
|
||||||
.setIcon(R.drawable.ic_bookmark);
|
.setIcon(R.drawable.ic_bookmark);
|
||||||
@ -599,6 +602,9 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
((VideoDetailFragment) fragment).openDownloadDialog();
|
((VideoDetailFragment) fragment).openDownloadDialog();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case PermissionHelper.POST_NOTIFICATIONS_REQUEST_CODE:
|
||||||
|
NotificationWorker.initialize(this);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -5,6 +5,7 @@ import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
|
|||||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
|
import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
|
||||||
import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
|
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_4_5;
|
||||||
|
import static org.schabi.newpipe.database.Migrations.MIGRATION_5_6;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.database.Cursor;
|
import android.database.Cursor;
|
||||||
@ -24,7 +25,8 @@ public final class NewPipeDatabase {
|
|||||||
private static AppDatabase getDatabase(final Context context) {
|
private static AppDatabase getDatabase(final Context context) {
|
||||||
return Room
|
return Room
|
||||||
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
|
.databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
|
||||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
|
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
|
||||||
|
MIGRATION_5_6)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,23 +1,25 @@
|
|||||||
package org.schabi.newpipe
|
package org.schabi.newpipe
|
||||||
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.preference.PreferenceManager
|
import androidx.preference.PreferenceManager
|
||||||
import androidx.work.OneTimeWorkRequest
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import androidx.work.WorkRequest
|
|
||||||
import androidx.work.Worker
|
import androidx.work.Worker
|
||||||
import androidx.work.WorkerParameters
|
import androidx.work.WorkerParameters
|
||||||
|
import androidx.work.workDataOf
|
||||||
import com.grack.nanojson.JsonParser
|
import com.grack.nanojson.JsonParser
|
||||||
import com.grack.nanojson.JsonParserException
|
import com.grack.nanojson.JsonParserException
|
||||||
import org.schabi.newpipe.extractor.downloader.Response
|
import org.schabi.newpipe.extractor.downloader.Response
|
||||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
||||||
|
import org.schabi.newpipe.util.PendingIntentCompat
|
||||||
import org.schabi.newpipe.util.ReleaseVersionUtil.coerceUpdateCheckExpiry
|
import org.schabi.newpipe.util.ReleaseVersionUtil.coerceUpdateCheckExpiry
|
||||||
import org.schabi.newpipe.util.ReleaseVersionUtil.isLastUpdateCheckExpired
|
import org.schabi.newpipe.util.ReleaseVersionUtil.isLastUpdateCheckExpired
|
||||||
import org.schabi.newpipe.util.ReleaseVersionUtil.isReleaseApk
|
import org.schabi.newpipe.util.ReleaseVersionUtil.isReleaseApk
|
||||||
@ -42,26 +44,40 @@ class NewVersionWorker(
|
|||||||
versionCode: Int
|
versionCode: Int
|
||||||
) {
|
) {
|
||||||
if (BuildConfig.VERSION_CODE >= versionCode) {
|
if (BuildConfig.VERSION_CODE >= versionCode) {
|
||||||
|
if (inputData.getBoolean(IS_MANUAL, false)) {
|
||||||
|
// Show toast stating that the app is up-to-date if the update check was manual.
|
||||||
|
ContextCompat.getMainExecutor(applicationContext).execute {
|
||||||
|
Toast.makeText(
|
||||||
|
applicationContext, R.string.app_update_unavailable_toast,
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
val app = App.getApp()
|
|
||||||
|
|
||||||
// A pending intent to open the apk location url in the browser.
|
// A pending intent to open the apk location url in the browser.
|
||||||
val intent = Intent(Intent.ACTION_VIEW, apkLocationUrl?.toUri())
|
val intent = Intent(Intent.ACTION_VIEW, apkLocationUrl?.toUri())
|
||||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
val pendingIntent = PendingIntent.getActivity(app, 0, intent, 0)
|
val pendingIntent = PendingIntentCompat.getActivity(
|
||||||
val channelId = app.getString(R.string.app_update_notification_channel_id)
|
applicationContext, 0, intent, 0
|
||||||
val notificationBuilder = NotificationCompat.Builder(app, channelId)
|
)
|
||||||
|
val channelId = applicationContext.getString(R.string.app_update_notification_channel_id)
|
||||||
|
val notificationBuilder = NotificationCompat.Builder(applicationContext, channelId)
|
||||||
.setSmallIcon(R.drawable.ic_newpipe_update)
|
.setSmallIcon(R.drawable.ic_newpipe_update)
|
||||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
.setContentIntent(pendingIntent)
|
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setContentTitle(app.getString(R.string.app_update_notification_content_title))
|
.setContentIntent(pendingIntent)
|
||||||
.setContentText(
|
.setContentTitle(
|
||||||
app.getString(R.string.app_update_notification_content_text) +
|
applicationContext.getString(R.string.app_update_available_notification_title)
|
||||||
" " + versionName
|
|
||||||
)
|
)
|
||||||
val notificationManager = NotificationManagerCompat.from(app)
|
.setContentText(
|
||||||
|
applicationContext.getString(
|
||||||
|
R.string.app_update_available_notification_text, versionName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
val notificationManager = NotificationManagerCompat.from(applicationContext)
|
||||||
notificationManager.notify(2000, notificationBuilder.build())
|
notificationManager.notify(2000, notificationBuilder.build())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -72,6 +88,7 @@ class NewVersionWorker(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!inputData.getBoolean(IS_MANUAL, false)) {
|
||||||
val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
|
||||||
// Check if the last request has happened a certain time ago
|
// Check if the last request has happened a certain time ago
|
||||||
// to reduce the number of API requests.
|
// to reduce the number of API requests.
|
||||||
@ -79,6 +96,7 @@ class NewVersionWorker(
|
|||||||
if (!isLastUpdateCheckExpired(expiry)) {
|
if (!isLastUpdateCheckExpired(expiry)) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Make a network request to get latest NewPipe data.
|
// Make a network request to get latest NewPipe data.
|
||||||
val response = DownloaderImpl.getInstance().get(NEWPIPE_API_URL)
|
val response = DownloaderImpl.getInstance().get(NEWPIPE_API_URL)
|
||||||
@ -120,43 +138,42 @@ class NewVersionWorker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun doWork(): Result {
|
override fun doWork(): Result {
|
||||||
try {
|
return try {
|
||||||
checkNewVersion()
|
checkNewVersion()
|
||||||
|
Result.success()
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e)
|
Log.w(TAG, "Could not fetch NewPipe API: probably network problem", e)
|
||||||
return Result.failure()
|
Result.failure()
|
||||||
} catch (e: ReCaptchaException) {
|
} catch (e: ReCaptchaException) {
|
||||||
Log.e(TAG, "ReCaptchaException should never happen here.", e)
|
Log.e(TAG, "ReCaptchaException should never happen here.", e)
|
||||||
return Result.failure()
|
Result.failure()
|
||||||
}
|
}
|
||||||
return Result.success()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val DEBUG = MainActivity.DEBUG
|
private val DEBUG = MainActivity.DEBUG
|
||||||
private val TAG = NewVersionWorker::class.java.simpleName
|
private val TAG = NewVersionWorker::class.java.simpleName
|
||||||
private const val NEWPIPE_API_URL = "https://newpipe.net/api/data.json"
|
private const val NEWPIPE_API_URL = "https://newpipe.net/api/data.json"
|
||||||
|
private const val IS_MANUAL = "isManual"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a new worker which
|
* Start a new worker which checks if all conditions for performing a version check are met,
|
||||||
* checks if all conditions for performing a version check are met,
|
* fetches the API endpoint [.NEWPIPE_API_URL] containing info about the latest NewPipe
|
||||||
* fetches the API endpoint [.NEWPIPE_API_URL] containing info
|
* version and displays a notification about an available update if one is available.
|
||||||
* about the latest NewPipe version
|
|
||||||
* and displays a notification about ana available update.
|
|
||||||
* <br></br>
|
* <br></br>
|
||||||
* Following conditions need to be met, before data is request from the server:
|
* Following conditions need to be met, before data is requested from the server:
|
||||||
*
|
*
|
||||||
* * The app is signed with the correct signing key (by TeamNewPipe / schabi).
|
* * The app is signed with the correct signing key (by TeamNewPipe / schabi).
|
||||||
* If the signing key differs from the one used upstream, the update cannot be installed.
|
* If the signing key differs from the one used upstream, the update cannot be installed.
|
||||||
* * The user enabled searching for and notifying about updates in the settings.
|
* * The user enabled searching for and notifying about updates in the settings.
|
||||||
* * The app did not recently check for updates.
|
* * The app did not recently check for updates.
|
||||||
* We do not want to make unnecessary connections and DOS our servers.
|
* We do not want to make unnecessary connections and DOS our servers.
|
||||||
*
|
|
||||||
*/
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun enqueueNewVersionCheckingWork(context: Context) {
|
fun enqueueNewVersionCheckingWork(context: Context, isManual: Boolean) {
|
||||||
val workRequest: WorkRequest =
|
val workRequest = OneTimeWorkRequestBuilder<NewVersionWorker>()
|
||||||
OneTimeWorkRequest.Builder(NewVersionWorker::class.java).build()
|
.setInputData(workDataOf(IS_MANUAL to isManual))
|
||||||
|
.build()
|
||||||
WorkManager.getInstance(context).enqueue(workRequest)
|
WorkManager.getInstance(context).enqueue(workRequest)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package org.schabi.newpipe;
|
package org.schabi.newpipe;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.util.SparseItemUtil.fetchStreamInfoAndSaveToDatabase;
|
||||||
import static org.schabi.newpipe.util.external_communication.ShareUtils.shareText;
|
import static org.schabi.newpipe.util.external_communication.ShareUtils.shareText;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
@ -10,6 +11,7 @@ import android.widget.PopupMenu;
|
|||||||
import androidx.fragment.app.FragmentManager;
|
import androidx.fragment.app.FragmentManager;
|
||||||
|
|
||||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||||
|
import org.schabi.newpipe.download.DownloadDialog;
|
||||||
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
import org.schabi.newpipe.local.dialog.PlaylistDialog;
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||||
@ -75,6 +77,14 @@ public final class QueueItemMenuUtil {
|
|||||||
shareText(context, item.getTitle(), item.getUrl(),
|
shareText(context, item.getTitle(), item.getUrl(),
|
||||||
item.getThumbnailUrl());
|
item.getThumbnailUrl());
|
||||||
return true;
|
return true;
|
||||||
|
case R.id.menu_item_download:
|
||||||
|
fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(),
|
||||||
|
info -> {
|
||||||
|
final DownloadDialog downloadDialog = new DownloadDialog(context,
|
||||||
|
info);
|
||||||
|
downloadDialog.show(fragmentManager, "downloadDialog");
|
||||||
|
});
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
@ -10,12 +10,14 @@ import android.content.DialogInterface;
|
|||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.view.ContextThemeWrapper;
|
import android.view.ContextThemeWrapper;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
|
import android.view.WindowManager;
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
import android.widget.RadioButton;
|
import android.widget.RadioButton;
|
||||||
import android.widget.RadioGroup;
|
import android.widget.RadioGroup;
|
||||||
@ -31,7 +33,12 @@ import androidx.appcompat.content.res.AppCompatResources;
|
|||||||
import androidx.core.app.NotificationCompat;
|
import androidx.core.app.NotificationCompat;
|
||||||
import androidx.core.app.ServiceCompat;
|
import androidx.core.app.ServiceCompat;
|
||||||
import androidx.core.math.MathUtils;
|
import androidx.core.math.MathUtils;
|
||||||
|
import androidx.fragment.app.DialogFragment;
|
||||||
|
import androidx.fragment.app.Fragment;
|
||||||
import androidx.fragment.app.FragmentManager;
|
import androidx.fragment.app.FragmentManager;
|
||||||
|
import androidx.lifecycle.DefaultLifecycleObserver;
|
||||||
|
import androidx.lifecycle.Lifecycle;
|
||||||
|
import androidx.lifecycle.LifecycleOwner;
|
||||||
import androidx.preference.PreferenceManager;
|
import androidx.preference.PreferenceManager;
|
||||||
|
|
||||||
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
import org.schabi.newpipe.database.stream.model.StreamEntity;
|
||||||
@ -80,9 +87,13 @@ import org.schabi.newpipe.util.urlfinder.UrlFinder;
|
|||||||
import org.schabi.newpipe.views.FocusOverlayView;
|
import org.schabi.newpipe.views.FocusOverlayView;
|
||||||
|
|
||||||
import java.io.Serializable;
|
import java.io.Serializable;
|
||||||
|
import java.lang.ref.Reference;
|
||||||
|
import java.lang.ref.WeakReference;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
import icepick.Icepick;
|
import icepick.Icepick;
|
||||||
import icepick.State;
|
import icepick.State;
|
||||||
@ -91,7 +102,6 @@ import io.reactivex.rxjava3.core.Observable;
|
|||||||
import io.reactivex.rxjava3.core.Single;
|
import io.reactivex.rxjava3.core.Single;
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||||
import io.reactivex.rxjava3.disposables.Disposable;
|
import io.reactivex.rxjava3.disposables.Disposable;
|
||||||
import io.reactivex.rxjava3.functions.Consumer;
|
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -111,12 +121,57 @@ public class RouterActivity extends AppCompatActivity {
|
|||||||
private boolean selectionIsDownload = false;
|
private boolean selectionIsDownload = false;
|
||||||
private boolean selectionIsAddToPlaylist = false;
|
private boolean selectionIsAddToPlaylist = false;
|
||||||
private AlertDialog alertDialogChoice = null;
|
private AlertDialog alertDialogChoice = null;
|
||||||
|
private FragmentManager.FragmentLifecycleCallbacks dismissListener = null;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(final Bundle savedInstanceState) {
|
protected void onCreate(final Bundle savedInstanceState) {
|
||||||
|
ThemeHelper.setDayNightMode(this);
|
||||||
|
setTheme(ThemeHelper.isLightThemeSelected(this)
|
||||||
|
? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark);
|
||||||
|
Localization.assureCorrectAppLanguage(this);
|
||||||
|
|
||||||
|
// Pass-through touch events to background activities
|
||||||
|
// so that our transparent window won't lock UI in the mean time
|
||||||
|
// network request is underway before showing PlaylistDialog or DownloadDialog
|
||||||
|
// (ref: https://stackoverflow.com/a/10606141)
|
||||||
|
getWindow().addFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
|
||||||
|
| WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL
|
||||||
|
| WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE);
|
||||||
|
|
||||||
|
// Android never fails to impress us with a list of new restrictions per API.
|
||||||
|
// Starting with S (Android 12) one of the prerequisite conditions has to be met
|
||||||
|
// before the FLAG_NOT_TOUCHABLE flag is allowed to kick in:
|
||||||
|
// @see WindowManager.LayoutParams#FLAG_NOT_TOUCHABLE
|
||||||
|
// For our present purpose it seems we can just set LayoutParams.alpha to 0
|
||||||
|
// on the strength of "4. Fully transparent windows" without affecting the scrim of dialogs
|
||||||
|
final WindowManager.LayoutParams params = getWindow().getAttributes();
|
||||||
|
params.alpha = 0f;
|
||||||
|
getWindow().setAttributes(params);
|
||||||
|
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
Icepick.restoreInstanceState(this, savedInstanceState);
|
Icepick.restoreInstanceState(this, savedInstanceState);
|
||||||
|
|
||||||
|
// FragmentManager will take care to recreate (Playlist|Download)Dialog when screen rotates
|
||||||
|
// We used to .setOnDismissListener(dialog -> finish()); when creating these DialogFragments
|
||||||
|
// but those callbacks won't survive a config change
|
||||||
|
// Try an alternate approach to hook into FragmentManager instead, to that effect
|
||||||
|
// (ref: https://stackoverflow.com/a/44028453)
|
||||||
|
final FragmentManager fm = getSupportFragmentManager();
|
||||||
|
if (dismissListener == null) {
|
||||||
|
dismissListener = new FragmentManager.FragmentLifecycleCallbacks() {
|
||||||
|
@Override
|
||||||
|
public void onFragmentDestroyed(@NonNull final FragmentManager fm,
|
||||||
|
@NonNull final Fragment f) {
|
||||||
|
super.onFragmentDestroyed(fm, f);
|
||||||
|
if (f instanceof DialogFragment && fm.getFragments().isEmpty()) {
|
||||||
|
// No more DialogFragments, we're done
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
fm.registerFragmentLifecycleCallbacks(dismissListener, false);
|
||||||
|
|
||||||
if (TextUtils.isEmpty(currentUrl)) {
|
if (TextUtils.isEmpty(currentUrl)) {
|
||||||
currentUrl = getUrl(getIntent());
|
currentUrl = getUrl(getIntent());
|
||||||
|
|
||||||
@ -125,11 +180,6 @@ public class RouterActivity extends AppCompatActivity {
|
|||||||
finish();
|
finish();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ThemeHelper.setDayNightMode(this);
|
|
||||||
setTheme(ThemeHelper.isLightThemeSelected(this)
|
|
||||||
? R.style.RouterActivityThemeLight : R.style.RouterActivityThemeDark);
|
|
||||||
Localization.assureCorrectAppLanguage(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -151,16 +201,34 @@ public class RouterActivity extends AppCompatActivity {
|
|||||||
protected void onStart() {
|
protected void onStart() {
|
||||||
super.onStart();
|
super.onStart();
|
||||||
|
|
||||||
|
// Don't overlap the DialogFragment after rotating the screen
|
||||||
|
// If there's no DialogFragment, we're either starting afresh
|
||||||
|
// or we didn't make it to PlaylistDialog or DownloadDialog before the orientation change
|
||||||
|
if (getSupportFragmentManager().getFragments().isEmpty()) {
|
||||||
|
// Start over from scratch
|
||||||
handleUrl(currentUrl);
|
handleUrl(currentUrl);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onDestroy() {
|
protected void onDestroy() {
|
||||||
super.onDestroy();
|
super.onDestroy();
|
||||||
|
|
||||||
|
if (dismissListener != null) {
|
||||||
|
getSupportFragmentManager().unregisterFragmentLifecycleCallbacks(dismissListener);
|
||||||
|
}
|
||||||
|
|
||||||
disposables.clear();
|
disposables.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void finish() {
|
||||||
|
// allow the activity to recreate in case orientation changes
|
||||||
|
if (!isChangingConfigurations()) {
|
||||||
|
super.finish();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void handleUrl(final String url) {
|
private void handleUrl(final String url) {
|
||||||
disposables.add(Observable
|
disposables.add(Observable
|
||||||
.fromCallable(() -> {
|
.fromCallable(() -> {
|
||||||
@ -240,7 +308,7 @@ public class RouterActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showUnsupportedUrlDialog(final String url) {
|
protected void showUnsupportedUrlDialog(final String url) {
|
||||||
final Context context = getThemeWrapperContext();
|
final Context context = getThemeWrapperContext();
|
||||||
new AlertDialog.Builder(context)
|
new AlertDialog.Builder(context)
|
||||||
.setTitle(R.string.unsupported_url)
|
.setTitle(R.string.unsupported_url)
|
||||||
@ -527,7 +595,7 @@ public class RouterActivity extends AppCompatActivity {
|
|||||||
return returnedItems;
|
return returnedItems;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Context getThemeWrapperContext() {
|
protected Context getThemeWrapperContext() {
|
||||||
return new ContextThemeWrapper(this, ThemeHelper.isLightThemeSelected(this)
|
return new ContextThemeWrapper(this, ThemeHelper.isLightThemeSelected(this)
|
||||||
? R.style.LightTheme : R.style.DarkTheme);
|
? R.style.LightTheme : R.style.DarkTheme);
|
||||||
}
|
}
|
||||||
@ -563,8 +631,7 @@ public class RouterActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (selectedChoiceKey.equals(getString(R.string.popup_player_key))
|
if (selectedChoiceKey.equals(getString(R.string.popup_player_key))
|
||||||
&& !PermissionHelper.isPopupEnabled(this)) {
|
&& !PermissionHelper.isPopupEnabledElseAsk(this)) {
|
||||||
PermissionHelper.showPopupEnablementToast(this);
|
|
||||||
finish();
|
finish();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -634,54 +701,179 @@ public class RouterActivity extends AppCompatActivity {
|
|||||||
return playerType == null || playerType == PlayerType.MAIN;
|
return playerType == null || playerType == PlayerType.MAIN;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void openAddToPlaylistDialog() {
|
public static class PersistentFragment extends Fragment {
|
||||||
// Getting the stream info usually takes a moment
|
private WeakReference<AppCompatActivity> weakContext;
|
||||||
// Notifying the user here to ensure that no confusion arises
|
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||||
Toast.makeText(
|
private int running = 0;
|
||||||
getApplicationContext(),
|
|
||||||
getString(R.string.processing_may_take_a_moment),
|
|
||||||
Toast.LENGTH_SHORT)
|
|
||||||
.show();
|
|
||||||
|
|
||||||
disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, false)
|
private synchronized void inFlight(final boolean started) {
|
||||||
.subscribeOn(Schedulers.io())
|
if (started) {
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
running++;
|
||||||
.subscribe(
|
} else {
|
||||||
info -> PlaylistDialog.createCorrespondingDialog(
|
running--;
|
||||||
getThemeWrapperContext(),
|
if (running <= 0) {
|
||||||
List.of(new StreamEntity(info)),
|
getActivityContext().ifPresent(context -> context.getSupportFragmentManager()
|
||||||
playlistDialog -> {
|
.beginTransaction().remove(this).commit());
|
||||||
playlistDialog.setOnDismissListener(dialog -> finish());
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
playlistDialog.show(
|
@Override
|
||||||
this.getSupportFragmentManager(),
|
public void onAttach(@NonNull final Context activityContext) {
|
||||||
"addToPlaylistDialog"
|
super.onAttach(activityContext);
|
||||||
|
weakContext = new WeakReference<>((AppCompatActivity) activityContext);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDetach() {
|
||||||
|
super.onDetach();
|
||||||
|
weakContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("deprecation")
|
||||||
|
@Override
|
||||||
|
public void onCreate(final Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
setRetainInstance(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
super.onDestroy();
|
||||||
|
disposables.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the activity context, if there is one and the activity is not finishing
|
||||||
|
*/
|
||||||
|
private Optional<AppCompatActivity> getActivityContext() {
|
||||||
|
return Optional.ofNullable(weakContext)
|
||||||
|
.map(Reference::get)
|
||||||
|
.filter(context -> !context.isFinishing());
|
||||||
|
}
|
||||||
|
|
||||||
|
// guard against IllegalStateException in calling DialogFragment.show() whilst in background
|
||||||
|
// (which could happen, say, when the user pressed the home button while waiting for
|
||||||
|
// the network request to return) when it internally calls FragmentTransaction.commit()
|
||||||
|
// after the FragmentManager has saved its states (isStateSaved() == true)
|
||||||
|
// (ref: https://stackoverflow.com/a/39813506)
|
||||||
|
private void runOnVisible(final Consumer<AppCompatActivity> runnable) {
|
||||||
|
getActivityContext().ifPresentOrElse(context -> {
|
||||||
|
if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) {
|
||||||
|
context.runOnUiThread(() -> {
|
||||||
|
runnable.accept(context);
|
||||||
|
inFlight(false);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
getLifecycle().addObserver(new DefaultLifecycleObserver() {
|
||||||
|
@Override
|
||||||
|
public void onResume(@NonNull final LifecycleOwner owner) {
|
||||||
|
getLifecycle().removeObserver(this);
|
||||||
|
getActivityContext().ifPresentOrElse(context ->
|
||||||
|
context.runOnUiThread(() -> {
|
||||||
|
runnable.accept(context);
|
||||||
|
inFlight(false);
|
||||||
|
}),
|
||||||
|
() -> inFlight(false)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
),
|
});
|
||||||
throwable -> handleError(this, new ErrorInfo(
|
// this trick doesn't seem to work on Android 10+ (API 29)
|
||||||
throwable,
|
// which places restrictions on starting activities from the background
|
||||||
UserAction.REQUESTED_STREAM,
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
|
||||||
"Tried to add " + currentUrl + " to a playlist",
|
&& !context.isChangingConfigurations()) {
|
||||||
currentService.getServiceId())
|
// try to bring the activity back to front if minimised
|
||||||
)
|
final Intent i = new Intent(context, RouterActivity.class);
|
||||||
)
|
i.setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT);
|
||||||
);
|
startActivity(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}, () -> {
|
||||||
|
// this branch is executed if there is no activity context
|
||||||
|
inFlight(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
<T> Single<T> pleaseWait(final Single<T> single) {
|
||||||
|
// 'abuse' ambWith() here to cancel the toast for us when the wait is over
|
||||||
|
return single.ambWith(Single.create(emitter -> getActivityContext().ifPresent(context ->
|
||||||
|
context.runOnUiThread(() -> {
|
||||||
|
// Getting the stream info usually takes a moment
|
||||||
|
// Notifying the user here to ensure that no confusion arises
|
||||||
|
final Toast toast = Toast.makeText(context,
|
||||||
|
getString(R.string.processing_may_take_a_moment),
|
||||||
|
Toast.LENGTH_LONG);
|
||||||
|
toast.show();
|
||||||
|
emitter.setCancellable(toast::cancel);
|
||||||
|
}))));
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressLint("CheckResult")
|
@SuppressLint("CheckResult")
|
||||||
private void openDownloadDialog() {
|
private void openDownloadDialog(final int currentServiceId, final String currentUrl) {
|
||||||
|
inFlight(true);
|
||||||
disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true)
|
disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true)
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(result -> {
|
.compose(this::pleaseWait)
|
||||||
final DownloadDialog downloadDialog = new DownloadDialog(this, result);
|
.subscribe(result ->
|
||||||
downloadDialog.setOnDismissListener(dialog -> finish());
|
runOnVisible(ctx -> {
|
||||||
|
final FragmentManager fm = ctx.getSupportFragmentManager();
|
||||||
final FragmentManager fm = getSupportFragmentManager();
|
final DownloadDialog downloadDialog = new DownloadDialog(ctx, result);
|
||||||
|
// dismiss listener to be handled by FragmentManager
|
||||||
downloadDialog.show(fm, "downloadDialog");
|
downloadDialog.show(fm, "downloadDialog");
|
||||||
fm.executePendingTransactions();
|
}
|
||||||
}, throwable -> showUnsupportedUrlDialog(currentUrl)));
|
), throwable -> runOnVisible(ctx ->
|
||||||
|
((RouterActivity) ctx).showUnsupportedUrlDialog(currentUrl))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openAddToPlaylistDialog(final int currentServiceId, final String currentUrl) {
|
||||||
|
inFlight(true);
|
||||||
|
disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, false)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.compose(this::pleaseWait)
|
||||||
|
.subscribe(
|
||||||
|
info -> getActivityContext().ifPresent(context ->
|
||||||
|
PlaylistDialog.createCorrespondingDialog(context,
|
||||||
|
List.of(new StreamEntity(info)),
|
||||||
|
playlistDialog -> runOnVisible(ctx -> {
|
||||||
|
// dismiss listener to be handled by FragmentManager
|
||||||
|
final FragmentManager fm =
|
||||||
|
ctx.getSupportFragmentManager();
|
||||||
|
playlistDialog.show(fm, "addToPlaylistDialog");
|
||||||
|
})
|
||||||
|
)),
|
||||||
|
throwable -> runOnVisible(ctx -> handleError(ctx, new ErrorInfo(
|
||||||
|
throwable,
|
||||||
|
UserAction.REQUESTED_STREAM,
|
||||||
|
"Tried to add " + currentUrl + " to a playlist",
|
||||||
|
((RouterActivity) ctx).currentService.getServiceId())
|
||||||
|
))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openAddToPlaylistDialog() {
|
||||||
|
getPersistFragment().openAddToPlaylistDialog(currentServiceId, currentUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openDownloadDialog() {
|
||||||
|
getPersistFragment().openDownloadDialog(currentServiceId, currentUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private PersistentFragment getPersistFragment() {
|
||||||
|
final FragmentManager fm = getSupportFragmentManager();
|
||||||
|
PersistentFragment persistFragment =
|
||||||
|
(PersistentFragment) fm.findFragmentByTag("PERSIST_FRAGMENT");
|
||||||
|
if (persistFragment == null) {
|
||||||
|
persistFragment = new PersistentFragment();
|
||||||
|
fm.beginTransaction()
|
||||||
|
.add(persistFragment, "PERSIST_FRAGMENT")
|
||||||
|
.commitNow();
|
||||||
|
}
|
||||||
|
return persistFragment;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
package org.schabi.newpipe.database;
|
package org.schabi.newpipe.database;
|
||||||
|
|
||||||
import static org.schabi.newpipe.database.Migrations.DB_VER_5;
|
import static org.schabi.newpipe.database.Migrations.DB_VER_6;
|
||||||
|
|
||||||
import androidx.room.Database;
|
import androidx.room.Database;
|
||||||
import androidx.room.RoomDatabase;
|
import androidx.room.RoomDatabase;
|
||||||
@ -38,7 +38,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
|||||||
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
|
FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
|
||||||
FeedLastUpdatedEntity.class
|
FeedLastUpdatedEntity.class
|
||||||
},
|
},
|
||||||
version = DB_VER_5
|
version = DB_VER_6
|
||||||
)
|
)
|
||||||
public abstract class AppDatabase extends RoomDatabase {
|
public abstract class AppDatabase extends RoomDatabase {
|
||||||
public static final String DATABASE_NAME = "newpipe.db";
|
public static final String DATABASE_NAME = "newpipe.db";
|
||||||
|
@ -23,6 +23,7 @@ public final class Migrations {
|
|||||||
public static final int DB_VER_3 = 3;
|
public static final int DB_VER_3 = 3;
|
||||||
public static final int DB_VER_4 = 4;
|
public static final int DB_VER_4 = 4;
|
||||||
public static final int DB_VER_5 = 5;
|
public static final int DB_VER_5 = 5;
|
||||||
|
public static final int DB_VER_6 = 6;
|
||||||
|
|
||||||
private static final String TAG = Migrations.class.getName();
|
private static final String TAG = Migrations.class.getName();
|
||||||
public static final boolean DEBUG = MainActivity.DEBUG;
|
public static final boolean DEBUG = MainActivity.DEBUG;
|
||||||
@ -188,6 +189,14 @@ public final class Migrations {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public static final Migration MIGRATION_5_6 = new Migration(DB_VER_5, DB_VER_6) {
|
||||||
|
@Override
|
||||||
|
public void migrate(@NonNull final SupportSQLiteDatabase database) {
|
||||||
|
database.execSQL("ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` "
|
||||||
|
+ "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
private Migrations() {
|
private Migrations() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,6 +25,7 @@ import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JO
|
|||||||
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE;
|
import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE;
|
||||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID;
|
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID;
|
||||||
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE;
|
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE;
|
||||||
|
import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_THUMBNAIL_URL;
|
||||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS;
|
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS;
|
||||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS;
|
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS;
|
||||||
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
|
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
|
||||||
@ -53,6 +54,15 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
|
|||||||
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
|
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
|
||||||
Flowable<Integer> getMaximumIndexOf(long playlistId);
|
Flowable<Integer> getMaximumIndexOf(long playlistId);
|
||||||
|
|
||||||
|
@Query("SELECT CASE WHEN COUNT(*) != 0 then " + STREAM_THUMBNAIL_URL + " ELSE :defaultUrl END"
|
||||||
|
+ " FROM " + STREAM_TABLE
|
||||||
|
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
|
||||||
|
+ " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
|
||||||
|
+ " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId "
|
||||||
|
+ " LIMIT 1"
|
||||||
|
)
|
||||||
|
Flowable<String> getAutomaticThumbnailUrl(long playlistId, String defaultUrl);
|
||||||
|
|
||||||
@RewriteQueriesToDropUnusedColumns
|
@RewriteQueriesToDropUnusedColumns
|
||||||
@Transaction
|
@Transaction
|
||||||
@Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN "
|
@Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN "
|
||||||
@ -80,7 +90,7 @@ public interface PlaylistStreamDAO extends BasicDAO<PlaylistStreamEntity> {
|
|||||||
+ " FROM " + PLAYLIST_TABLE
|
+ " FROM " + PLAYLIST_TABLE
|
||||||
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
|
+ " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
|
||||||
+ " ON " + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID
|
+ " ON " + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID
|
||||||
+ " GROUP BY " + JOIN_PLAYLIST_ID
|
+ " GROUP BY " + PLAYLIST_ID
|
||||||
+ " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC")
|
+ " ORDER BY " + PLAYLIST_NAME + " COLLATE NOCASE ASC")
|
||||||
Flowable<List<PlaylistMetadataEntry>> getPlaylistMetadata();
|
Flowable<List<PlaylistMetadataEntry>> getPlaylistMetadata();
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,7 @@ public class PlaylistEntity {
|
|||||||
public static final String PLAYLIST_ID = "uid";
|
public static final String PLAYLIST_ID = "uid";
|
||||||
public static final String PLAYLIST_NAME = "name";
|
public static final String PLAYLIST_NAME = "name";
|
||||||
public static final String PLAYLIST_THUMBNAIL_URL = "thumbnail_url";
|
public static final String PLAYLIST_THUMBNAIL_URL = "thumbnail_url";
|
||||||
|
public static final String PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent";
|
||||||
|
|
||||||
@PrimaryKey(autoGenerate = true)
|
@PrimaryKey(autoGenerate = true)
|
||||||
@ColumnInfo(name = PLAYLIST_ID)
|
@ColumnInfo(name = PLAYLIST_ID)
|
||||||
@ -26,9 +27,14 @@ public class PlaylistEntity {
|
|||||||
@ColumnInfo(name = PLAYLIST_THUMBNAIL_URL)
|
@ColumnInfo(name = PLAYLIST_THUMBNAIL_URL)
|
||||||
private String thumbnailUrl;
|
private String thumbnailUrl;
|
||||||
|
|
||||||
public PlaylistEntity(final String name, final String thumbnailUrl) {
|
@ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT)
|
||||||
|
private boolean isThumbnailPermanent;
|
||||||
|
|
||||||
|
public PlaylistEntity(final String name, final String thumbnailUrl,
|
||||||
|
final boolean isThumbnailPermanent) {
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.thumbnailUrl = thumbnailUrl;
|
this.thumbnailUrl = thumbnailUrl;
|
||||||
|
this.isThumbnailPermanent = isThumbnailPermanent;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long getUid() {
|
public long getUid() {
|
||||||
@ -54,4 +60,13 @@ public class PlaylistEntity {
|
|||||||
public void setThumbnailUrl(final String thumbnailUrl) {
|
public void setThumbnailUrl(final String thumbnailUrl) {
|
||||||
this.thumbnailUrl = thumbnailUrl;
|
this.thumbnailUrl = thumbnailUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean getIsThumbnailPermanent() {
|
||||||
|
return isThumbnailPermanent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIsThumbnailPermanent(final boolean isThumbnailSet) {
|
||||||
|
this.isThumbnailPermanent = isThumbnailSet;
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -17,7 +17,6 @@ import android.os.Bundle;
|
|||||||
import android.os.Environment;
|
import android.os.Environment;
|
||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.util.SparseArray;
|
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
@ -36,6 +35,7 @@ import androidx.annotation.StringRes;
|
|||||||
import androidx.appcompat.app.AlertDialog;
|
import androidx.appcompat.app.AlertDialog;
|
||||||
import androidx.appcompat.view.menu.ActionMenuItemView;
|
import androidx.appcompat.view.menu.ActionMenuItemView;
|
||||||
import androidx.appcompat.widget.Toolbar;
|
import androidx.appcompat.widget.Toolbar;
|
||||||
|
import androidx.collection.SparseArrayCompat;
|
||||||
import androidx.documentfile.provider.DocumentFile;
|
import androidx.documentfile.provider.DocumentFile;
|
||||||
import androidx.fragment.app.DialogFragment;
|
import androidx.fragment.app.DialogFragment;
|
||||||
import androidx.preference.PreferenceManager;
|
import androidx.preference.PreferenceManager;
|
||||||
@ -75,6 +75,7 @@ import java.io.IOException;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
import icepick.Icepick;
|
import icepick.Icepick;
|
||||||
import icepick.State;
|
import icepick.State;
|
||||||
@ -211,8 +212,7 @@ public class DownloadDialog extends DialogFragment
|
|||||||
setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(context));
|
setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(context));
|
||||||
Icepick.restoreInstanceState(this, savedInstanceState);
|
Icepick.restoreInstanceState(this, savedInstanceState);
|
||||||
|
|
||||||
final SparseArray<SecondaryStreamHelper<AudioStream>> secondaryStreams =
|
final var secondaryStreams = new SparseArrayCompat<SecondaryStreamHelper<AudioStream>>(4);
|
||||||
new SparseArray<>(4);
|
|
||||||
final List<VideoStream> videoStreams = wrappedVideoStreams.getStreamsList();
|
final List<VideoStream> videoStreams = wrappedVideoStreams.getStreamsList();
|
||||||
|
|
||||||
for (int i = 0; i < videoStreams.size(); i++) {
|
for (int i = 0; i < videoStreams.size(); i++) {
|
||||||
@ -236,10 +236,9 @@ public class DownloadDialog extends DialogFragment
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.videoStreamsAdapter = new StreamItemAdapter<>(context, wrappedVideoStreams,
|
this.videoStreamsAdapter = new StreamItemAdapter<>(wrappedVideoStreams, secondaryStreams);
|
||||||
secondaryStreams);
|
this.audioStreamsAdapter = new StreamItemAdapter<>(wrappedAudioStreams);
|
||||||
this.audioStreamsAdapter = new StreamItemAdapter<>(context, wrappedAudioStreams);
|
this.subtitleStreamsAdapter = new StreamItemAdapter<>(wrappedSubtitleStreams);
|
||||||
this.subtitleStreamsAdapter = new StreamItemAdapter<>(context, wrappedSubtitleStreams);
|
|
||||||
|
|
||||||
final Intent intent = new Intent(context, DownloadManagerService.class);
|
final Intent intent = new Intent(context, DownloadManagerService.class);
|
||||||
context.startService(intent);
|
context.startService(intent);
|
||||||
@ -562,6 +561,39 @@ public class DownloadDialog extends DialogFragment
|
|||||||
selectedSubtitleIndex = position;
|
selectedSubtitleIndex = position;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
onItemSelectedSetFileName();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void onItemSelectedSetFileName() {
|
||||||
|
final String fileName = FilenameUtils.createFilename(getContext(), currentInfo.getName());
|
||||||
|
final String prevFileName = Optional.ofNullable(dialogBinding.fileName.getText())
|
||||||
|
.map(Object::toString)
|
||||||
|
.orElse("");
|
||||||
|
|
||||||
|
if (prevFileName.isEmpty()
|
||||||
|
|| prevFileName.equals(fileName)
|
||||||
|
|| prevFileName.startsWith(getString(R.string.caption_file_name, fileName, ""))) {
|
||||||
|
// only update the file name field if it was not edited by the user
|
||||||
|
|
||||||
|
switch (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()) {
|
||||||
|
case R.id.audio_button:
|
||||||
|
case R.id.video_button:
|
||||||
|
if (!prevFileName.equals(fileName)) {
|
||||||
|
// since the user might have switched between audio and video, the correct
|
||||||
|
// text might already be in place, so avoid resetting the cursor position
|
||||||
|
dialogBinding.fileName.setText(fileName);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case R.id.subtitle_button:
|
||||||
|
final String setSubtitleLanguageCode = subtitleStreamsAdapter
|
||||||
|
.getItem(selectedSubtitleIndex).getLanguageTag();
|
||||||
|
// this will reset the cursor position, which is bad UX, but it can't be avoided
|
||||||
|
dialogBinding.fileName.setText(getString(
|
||||||
|
R.string.caption_file_name, fileName, setSubtitleLanguageCode));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -5,7 +5,6 @@ import android.app.PendingIntent
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
import android.os.Build
|
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
@ -13,6 +12,7 @@ import androidx.core.app.NotificationManagerCompat
|
|||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import com.google.android.material.snackbar.Snackbar
|
import com.google.android.material.snackbar.Snackbar
|
||||||
import org.schabi.newpipe.R
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.util.PendingIntentCompat
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This class contains all of the methods that should be used to let the user know that an error has
|
* This class contains all of the methods that should be used to let the user know that an error has
|
||||||
@ -104,11 +104,6 @@ class ErrorUtil {
|
|||||||
*/
|
*/
|
||||||
@JvmStatic
|
@JvmStatic
|
||||||
fun createNotification(context: Context, errorInfo: ErrorInfo) {
|
fun createNotification(context: Context, errorInfo: ErrorInfo) {
|
||||||
var pendingIntentFlags = PendingIntent.FLAG_UPDATE_CURRENT
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
||||||
pendingIntentFlags = pendingIntentFlags or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
}
|
|
||||||
|
|
||||||
val notificationBuilder: NotificationCompat.Builder =
|
val notificationBuilder: NotificationCompat.Builder =
|
||||||
NotificationCompat.Builder(
|
NotificationCompat.Builder(
|
||||||
context,
|
context,
|
||||||
@ -119,11 +114,11 @@ class ErrorUtil {
|
|||||||
.setContentText(context.getString(errorInfo.messageStringId))
|
.setContentText(context.getString(errorInfo.messageStringId))
|
||||||
.setAutoCancel(true)
|
.setAutoCancel(true)
|
||||||
.setContentIntent(
|
.setContentIntent(
|
||||||
PendingIntent.getActivity(
|
PendingIntentCompat.getActivity(
|
||||||
context,
|
context,
|
||||||
0,
|
0,
|
||||||
getErrorActivityIntent(context, errorInfo),
|
getErrorActivityIntent(context, errorInfo),
|
||||||
pendingIntentFlags
|
PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -20,14 +20,14 @@ import androidx.appcompat.app.AppCompatActivity;
|
|||||||
import androidx.core.app.NavUtils;
|
import androidx.core.app.NavUtils;
|
||||||
import androidx.preference.PreferenceManager;
|
import androidx.preference.PreferenceManager;
|
||||||
|
|
||||||
import org.schabi.newpipe.databinding.ActivityRecaptchaBinding;
|
|
||||||
import org.schabi.newpipe.DownloaderImpl;
|
import org.schabi.newpipe.DownloaderImpl;
|
||||||
import org.schabi.newpipe.MainActivity;
|
import org.schabi.newpipe.MainActivity;
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.databinding.ActivityRecaptchaBinding;
|
||||||
|
import org.schabi.newpipe.extractor.utils.Utils;
|
||||||
import org.schabi.newpipe.util.ThemeHelper;
|
import org.schabi.newpipe.util.ThemeHelper;
|
||||||
|
|
||||||
import java.io.UnsupportedEncodingException;
|
import java.io.UnsupportedEncodingException;
|
||||||
import java.net.URLDecoder;
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Created by beneth <bmauduit@beneth.fr> on 06.12.16.
|
* Created by beneth <bmauduit@beneth.fr> on 06.12.16.
|
||||||
@ -188,7 +188,7 @@ public class ReCaptchaActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
String abuseCookie = url.substring(abuseStart + 13, abuseEnd);
|
String abuseCookie = url.substring(abuseStart + 13, abuseEnd);
|
||||||
abuseCookie = URLDecoder.decode(abuseCookie, "UTF-8");
|
abuseCookie = Utils.decodeUrlUtf8(abuseCookie);
|
||||||
handleCookies(abuseCookie);
|
handleCookies(abuseCookie);
|
||||||
} catch (UnsupportedEncodingException | StringIndexOutOfBoundsException e) {
|
} catch (UnsupportedEncodingException | StringIndexOutOfBoundsException e) {
|
||||||
if (MainActivity.DEBUG) {
|
if (MainActivity.DEBUG) {
|
||||||
|
@ -3,6 +3,8 @@ package org.schabi.newpipe.fragments.detail;
|
|||||||
import static android.text.TextUtils.isEmpty;
|
import static android.text.TextUtils.isEmpty;
|
||||||
import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT;
|
import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT;
|
||||||
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
|
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
|
||||||
|
import static org.schabi.newpipe.util.Localization.getAppLocale;
|
||||||
|
import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD;
|
||||||
|
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
@ -28,7 +30,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfo;
|
|||||||
import org.schabi.newpipe.util.Localization;
|
import org.schabi.newpipe.util.Localization;
|
||||||
import org.schabi.newpipe.util.NavigationHelper;
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||||
import org.schabi.newpipe.util.external_communication.TextLinkifier;
|
import org.schabi.newpipe.util.text.TextLinkifier;
|
||||||
|
|
||||||
import icepick.State;
|
import icepick.State;
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||||
@ -111,7 +113,10 @@ public class DescriptionFragment extends BaseFragment {
|
|||||||
|
|
||||||
private void disableDescriptionSelection() {
|
private void disableDescriptionSelection() {
|
||||||
// show description content again, otherwise some links are not clickable
|
// show description content again, otherwise some links are not clickable
|
||||||
loadDescriptionContent();
|
TextLinkifier.fromDescription(binding.detailDescriptionView,
|
||||||
|
streamInfo.getDescription(), HtmlCompat.FROM_HTML_MODE_LEGACY,
|
||||||
|
streamInfo.getService(), streamInfo.getUrl(),
|
||||||
|
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
|
||||||
|
|
||||||
binding.detailDescriptionNoteView.setVisibility(View.GONE);
|
binding.detailDescriptionNoteView.setVisibility(View.GONE);
|
||||||
binding.detailDescriptionView.setTextIsSelectable(false);
|
binding.detailDescriptionView.setTextIsSelectable(false);
|
||||||
@ -122,52 +127,32 @@ public class DescriptionFragment extends BaseFragment {
|
|||||||
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all);
|
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void loadDescriptionContent() {
|
|
||||||
final Description description = streamInfo.getDescription();
|
|
||||||
switch (description.getType()) {
|
|
||||||
case Description.HTML:
|
|
||||||
TextLinkifier.createLinksFromHtmlBlock(binding.detailDescriptionView,
|
|
||||||
description.getContent(), HtmlCompat.FROM_HTML_MODE_LEGACY, streamInfo,
|
|
||||||
descriptionDisposables);
|
|
||||||
break;
|
|
||||||
case Description.MARKDOWN:
|
|
||||||
TextLinkifier.createLinksFromMarkdownText(binding.detailDescriptionView,
|
|
||||||
description.getContent(), streamInfo, descriptionDisposables);
|
|
||||||
break;
|
|
||||||
case Description.PLAIN_TEXT: default:
|
|
||||||
TextLinkifier.createLinksFromPlainText(binding.detailDescriptionView,
|
|
||||||
description.getContent(), streamInfo, descriptionDisposables);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
private void setupMetadata(final LayoutInflater inflater,
|
private void setupMetadata(final LayoutInflater inflater,
|
||||||
final LinearLayout layout) {
|
final LinearLayout layout) {
|
||||||
addMetadataItem(inflater, layout, false,
|
addMetadataItem(inflater, layout, false, R.string.metadata_category,
|
||||||
R.string.metadata_category, streamInfo.getCategory());
|
streamInfo.getCategory());
|
||||||
|
|
||||||
addMetadataItem(inflater, layout, false,
|
addMetadataItem(inflater, layout, false, R.string.metadata_licence,
|
||||||
R.string.metadata_licence, streamInfo.getLicence());
|
streamInfo.getLicence());
|
||||||
|
|
||||||
addPrivacyMetadataItem(inflater, layout);
|
addPrivacyMetadataItem(inflater, layout);
|
||||||
|
|
||||||
if (streamInfo.getAgeLimit() != NO_AGE_LIMIT) {
|
if (streamInfo.getAgeLimit() != NO_AGE_LIMIT) {
|
||||||
addMetadataItem(inflater, layout, false,
|
addMetadataItem(inflater, layout, false, R.string.metadata_age_limit,
|
||||||
R.string.metadata_age_limit, String.valueOf(streamInfo.getAgeLimit()));
|
String.valueOf(streamInfo.getAgeLimit()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (streamInfo.getLanguageInfo() != null) {
|
if (streamInfo.getLanguageInfo() != null) {
|
||||||
addMetadataItem(inflater, layout, false,
|
addMetadataItem(inflater, layout, false, R.string.metadata_language,
|
||||||
R.string.metadata_language, streamInfo.getLanguageInfo().getDisplayLanguage());
|
streamInfo.getLanguageInfo().getDisplayLanguage(getAppLocale(getContext())));
|
||||||
}
|
}
|
||||||
|
|
||||||
addMetadataItem(inflater, layout, true,
|
addMetadataItem(inflater, layout, true, R.string.metadata_support,
|
||||||
R.string.metadata_support, streamInfo.getSupportInfo());
|
streamInfo.getSupportInfo());
|
||||||
addMetadataItem(inflater, layout, true,
|
addMetadataItem(inflater, layout, true, R.string.metadata_host,
|
||||||
R.string.metadata_host, streamInfo.getHost());
|
streamInfo.getHost());
|
||||||
addMetadataItem(inflater, layout, true,
|
addMetadataItem(inflater, layout, true, R.string.metadata_thumbnail_url,
|
||||||
R.string.metadata_thumbnail_url, streamInfo.getThumbnailUrl());
|
streamInfo.getThumbnailUrl());
|
||||||
|
|
||||||
addTagsMetadataItem(inflater, layout);
|
addTagsMetadataItem(inflater, layout);
|
||||||
}
|
}
|
||||||
@ -191,12 +176,14 @@ public class DescriptionFragment extends BaseFragment {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (linkifyContent) {
|
if (linkifyContent) {
|
||||||
TextLinkifier.createLinksFromPlainText(itemBinding.metadataContentView, content, null,
|
TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null,
|
||||||
descriptionDisposables);
|
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
|
||||||
} else {
|
} else {
|
||||||
itemBinding.metadataContentView.setText(content);
|
itemBinding.metadataContentView.setText(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
itemBinding.metadataContentView.setClickable(true);
|
||||||
|
|
||||||
layout.addView(itemBinding.getRoot());
|
layout.addView(itemBinding.getRoot());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -245,14 +232,15 @@ public class DescriptionFragment extends BaseFragment {
|
|||||||
case INTERNAL:
|
case INTERNAL:
|
||||||
contentRes = R.string.metadata_privacy_internal;
|
contentRes = R.string.metadata_privacy_internal;
|
||||||
break;
|
break;
|
||||||
case OTHER: default:
|
case OTHER:
|
||||||
|
default:
|
||||||
contentRes = 0;
|
contentRes = 0;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (contentRes != 0) {
|
if (contentRes != 0) {
|
||||||
addMetadataItem(inflater, layout, false,
|
addMetadataItem(inflater, layout, false, R.string.metadata_privacy,
|
||||||
R.string.metadata_privacy, getString(contentRes));
|
getString(contentRes));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -10,8 +10,11 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.isClearingQueueConfi
|
|||||||
import static org.schabi.newpipe.player.playqueue.PlayQueueItem.RECOVERY_UNSET;
|
import static org.schabi.newpipe.player.playqueue.PlayQueueItem.RECOVERY_UNSET;
|
||||||
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
|
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
|
||||||
import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams;
|
import static org.schabi.newpipe.util.ListHelper.getUrlAndNonTorrentStreams;
|
||||||
|
import static org.schabi.newpipe.util.NavigationHelper.openPlayQueue;
|
||||||
|
import static org.schabi.newpipe.util.NavigationHelper.playWithKore;
|
||||||
|
|
||||||
import android.animation.ValueAnimator;
|
import android.animation.ValueAnimator;
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
import android.content.BroadcastReceiver;
|
import android.content.BroadcastReceiver;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
@ -24,7 +27,6 @@ import android.graphics.Color;
|
|||||||
import android.graphics.Rect;
|
import android.graphics.Rect;
|
||||||
import android.graphics.drawable.Drawable;
|
import android.graphics.drawable.Drawable;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
@ -52,6 +54,9 @@ import androidx.appcompat.content.res.AppCompatResources;
|
|||||||
import androidx.appcompat.widget.Toolbar;
|
import androidx.appcompat.widget.Toolbar;
|
||||||
import androidx.coordinatorlayout.widget.CoordinatorLayout;
|
import androidx.coordinatorlayout.widget.CoordinatorLayout;
|
||||||
import androidx.core.content.ContextCompat;
|
import androidx.core.content.ContextCompat;
|
||||||
|
import androidx.core.view.WindowCompat;
|
||||||
|
import androidx.core.view.WindowInsetsCompat;
|
||||||
|
import androidx.core.view.WindowInsetsControllerCompat;
|
||||||
import androidx.preference.PreferenceManager;
|
import androidx.preference.PreferenceManager;
|
||||||
|
|
||||||
import com.google.android.exoplayer2.PlaybackException;
|
import com.google.android.exoplayer2.PlaybackException;
|
||||||
@ -119,6 +124,7 @@ import java.util.List;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
import icepick.State;
|
import icepick.State;
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||||
@ -129,9 +135,6 @@ import io.reactivex.rxjava3.schedulers.Schedulers;
|
|||||||
public final class VideoDetailFragment
|
public final class VideoDetailFragment
|
||||||
extends BaseStateFragment<StreamInfo>
|
extends BaseStateFragment<StreamInfo>
|
||||||
implements BackPressable,
|
implements BackPressable,
|
||||||
SharedPreferences.OnSharedPreferenceChangeListener,
|
|
||||||
View.OnClickListener,
|
|
||||||
View.OnLongClickListener,
|
|
||||||
PlayerServiceExtendedEventListener,
|
PlayerServiceExtendedEventListener,
|
||||||
OnKeyDownListener {
|
OnKeyDownListener {
|
||||||
public static final String KEY_SWITCHING_PLAYERS = "switching_players";
|
public static final String KEY_SWITCHING_PLAYERS = "switching_players";
|
||||||
@ -167,6 +170,20 @@ public final class VideoDetailFragment
|
|||||||
private boolean tabSettingsChanged = false;
|
private boolean tabSettingsChanged = false;
|
||||||
private int lastAppBarVerticalOffset = Integer.MAX_VALUE; // prevents useless updates
|
private int lastAppBarVerticalOffset = Integer.MAX_VALUE; // prevents useless updates
|
||||||
|
|
||||||
|
private final SharedPreferences.OnSharedPreferenceChangeListener preferenceChangeListener =
|
||||||
|
(sharedPreferences, key) -> {
|
||||||
|
if (getString(R.string.show_comments_key).equals(key)) {
|
||||||
|
showComments = sharedPreferences.getBoolean(key, true);
|
||||||
|
tabSettingsChanged = true;
|
||||||
|
} else if (getString(R.string.show_next_video_key).equals(key)) {
|
||||||
|
showRelatedItems = sharedPreferences.getBoolean(key, true);
|
||||||
|
tabSettingsChanged = true;
|
||||||
|
} else if (getString(R.string.show_description_key).equals(key)) {
|
||||||
|
showDescription = sharedPreferences.getBoolean(key, true);
|
||||||
|
tabSettingsChanged = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
@State
|
@State
|
||||||
protected int serviceId = Constants.NO_SERVICE_ID;
|
protected int serviceId = Constants.NO_SERVICE_ID;
|
||||||
@State
|
@State
|
||||||
@ -240,11 +257,10 @@ public final class VideoDetailFragment
|
|||||||
playerUi.ifPresent(MainPlayerUi::toggleFullscreen);
|
playerUi.ifPresent(MainPlayerUi::toggleFullscreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
//noinspection SimplifyOptionalCallChains
|
|
||||||
if (playAfterConnect
|
if (playAfterConnect
|
||||||
|| (currentInfo != null
|
|| (currentInfo != null
|
||||||
&& isAutoplayEnabled()
|
&& isAutoplayEnabled()
|
||||||
&& !playerUi.isPresent())) {
|
&& playerUi.isEmpty())) {
|
||||||
autoPlayEnabled = true; // forcefully start playing
|
autoPlayEnabled = true; // forcefully start playing
|
||||||
openVideoPlayerAutoFullscreen();
|
openVideoPlayerAutoFullscreen();
|
||||||
}
|
}
|
||||||
@ -291,7 +307,7 @@ public final class VideoDetailFragment
|
|||||||
showDescription = prefs.getBoolean(getString(R.string.show_description_key), true);
|
showDescription = prefs.getBoolean(getString(R.string.show_description_key), true);
|
||||||
selectedTabTag = prefs.getString(
|
selectedTabTag = prefs.getString(
|
||||||
getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG);
|
getString(R.string.stream_info_selected_tab_key), COMMENTS_TAB_TAG);
|
||||||
prefs.registerOnSharedPreferenceChangeListener(this);
|
prefs.registerOnSharedPreferenceChangeListener(preferenceChangeListener);
|
||||||
|
|
||||||
setupBroadcastReceiver();
|
setupBroadcastReceiver();
|
||||||
|
|
||||||
@ -378,7 +394,7 @@ public final class VideoDetailFragment
|
|||||||
}
|
}
|
||||||
|
|
||||||
PreferenceManager.getDefaultSharedPreferences(activity)
|
PreferenceManager.getDefaultSharedPreferences(activity)
|
||||||
.unregisterOnSharedPreferenceChangeListener(this);
|
.unregisterOnSharedPreferenceChangeListener(preferenceChangeListener);
|
||||||
activity.unregisterReceiver(broadcastReceiver);
|
activity.unregisterReceiver(broadcastReceiver);
|
||||||
activity.getContentResolver().unregisterContentObserver(settingsContentObserver);
|
activity.getContentResolver().unregisterContentObserver(settingsContentObserver);
|
||||||
|
|
||||||
@ -424,115 +440,75 @@ public final class VideoDetailFragment
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
|
|
||||||
final String key) {
|
|
||||||
if (key.equals(getString(R.string.show_comments_key))) {
|
|
||||||
showComments = sharedPreferences.getBoolean(key, true);
|
|
||||||
tabSettingsChanged = true;
|
|
||||||
} else if (key.equals(getString(R.string.show_next_video_key))) {
|
|
||||||
showRelatedItems = sharedPreferences.getBoolean(key, true);
|
|
||||||
tabSettingsChanged = true;
|
|
||||||
} else if (key.equals(getString(R.string.show_description_key))) {
|
|
||||||
showDescription = sharedPreferences.getBoolean(key, true);
|
|
||||||
tabSettingsChanged = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
// OnClick
|
// OnClick
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
@Override
|
private void setOnClickListeners() {
|
||||||
public void onClick(final View v) {
|
binding.detailTitleRootLayout.setOnClickListener(v -> toggleTitleAndSecondaryControls());
|
||||||
switch (v.getId()) {
|
binding.detailUploaderRootLayout.setOnClickListener(makeOnClickListener(info -> {
|
||||||
case R.id.detail_controls_background:
|
if (isEmpty(info.getSubChannelUrl())) {
|
||||||
openBackgroundPlayer(false);
|
if (!isEmpty(info.getUploaderUrl())) {
|
||||||
break;
|
openChannel(info.getUploaderUrl(), info.getUploaderName());
|
||||||
case R.id.detail_controls_popup:
|
|
||||||
openPopupPlayer(false);
|
|
||||||
break;
|
|
||||||
case R.id.detail_controls_playlist_append:
|
|
||||||
if (getFM() != null && currentInfo != null) {
|
|
||||||
disposables.add(
|
|
||||||
PlaylistDialog.createCorrespondingDialog(
|
|
||||||
getContext(),
|
|
||||||
List.of(new StreamEntity(currentInfo)),
|
|
||||||
dialog -> dialog.show(getFM(), TAG)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case R.id.detail_controls_download:
|
|
||||||
if (PermissionHelper.checkStoragePermissions(activity,
|
|
||||||
PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) {
|
|
||||||
this.openDownloadDialog();
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case R.id.detail_controls_share:
|
|
||||||
if (currentInfo != null) {
|
|
||||||
ShareUtils.shareText(requireContext(), currentInfo.getName(),
|
|
||||||
currentInfo.getUrl(), currentInfo.getThumbnailUrl());
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case R.id.detail_controls_open_in_browser:
|
|
||||||
if (currentInfo != null) {
|
|
||||||
ShareUtils.openUrlInBrowser(requireContext(), currentInfo.getUrl());
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case R.id.detail_controls_play_with_kodi:
|
|
||||||
if (currentInfo != null) {
|
|
||||||
try {
|
|
||||||
NavigationHelper.playWithKore(
|
|
||||||
requireContext(), Uri.parse(currentInfo.getUrl()));
|
|
||||||
} catch (final Exception e) {
|
|
||||||
if (DEBUG) {
|
|
||||||
Log.i(TAG, "Failed to start kore", e);
|
|
||||||
}
|
|
||||||
KoreUtils.showInstallKoreDialog(requireContext());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case R.id.detail_uploader_root_layout:
|
|
||||||
if (isEmpty(currentInfo.getSubChannelUrl())) {
|
|
||||||
if (!isEmpty(currentInfo.getUploaderUrl())) {
|
|
||||||
openChannel(currentInfo.getUploaderUrl(), currentInfo.getUploaderName());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
Log.i(TAG, "Can't open sub-channel because we got no channel URL");
|
Log.i(TAG, "Can't open sub-channel because we got no channel URL");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
openChannel(currentInfo.getSubChannelUrl(),
|
openChannel(info.getSubChannelUrl(), info.getSubChannelName());
|
||||||
currentInfo.getSubChannelName());
|
|
||||||
}
|
}
|
||||||
break;
|
}));
|
||||||
case R.id.detail_thumbnail_root_layout:
|
binding.detailThumbnailRootLayout.setOnClickListener(v -> {
|
||||||
// make sure not to open any player if there is nothing currently loaded!
|
|
||||||
// FIXME removing this `if` causes the player service to start correctly, then stop,
|
|
||||||
// then restart badly without calling `startForeground()`, causing a crash when
|
|
||||||
// later closing the detail fragment
|
|
||||||
if (currentInfo != null) {
|
|
||||||
autoPlayEnabled = true; // forcefully start playing
|
autoPlayEnabled = true; // forcefully start playing
|
||||||
// FIXME Workaround #7427
|
// FIXME Workaround #7427
|
||||||
if (isPlayerAvailable()) {
|
if (isPlayerAvailable()) {
|
||||||
player.setRecovery();
|
player.setRecovery();
|
||||||
}
|
}
|
||||||
openVideoPlayerAutoFullscreen();
|
openVideoPlayerAutoFullscreen();
|
||||||
|
});
|
||||||
|
|
||||||
|
binding.detailControlsBackground.setOnClickListener(v -> openBackgroundPlayer(false));
|
||||||
|
binding.detailControlsPopup.setOnClickListener(v -> openPopupPlayer(false));
|
||||||
|
binding.detailControlsPlaylistAppend.setOnClickListener(makeOnClickListener(info ->
|
||||||
|
disposables.add(PlaylistDialog.createCorrespondingDialog(requireContext(),
|
||||||
|
List.of(new StreamEntity(info)),
|
||||||
|
dialog -> dialog.show(getParentFragmentManager(), TAG)))));
|
||||||
|
binding.detailControlsDownload.setOnClickListener(v -> {
|
||||||
|
if (PermissionHelper.checkStoragePermissions(activity,
|
||||||
|
PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) {
|
||||||
|
openDownloadDialog();
|
||||||
}
|
}
|
||||||
break;
|
});
|
||||||
case R.id.detail_title_root_layout:
|
binding.detailControlsShare.setOnClickListener(makeOnClickListener(info ->
|
||||||
toggleTitleAndSecondaryControls();
|
ShareUtils.shareText(requireContext(), info.getName(), info.getUrl(),
|
||||||
break;
|
info.getThumbnailUrl())));
|
||||||
case R.id.overlay_thumbnail:
|
binding.detailControlsOpenInBrowser.setOnClickListener(makeOnClickListener(info ->
|
||||||
case R.id.overlay_metadata_layout:
|
ShareUtils.openUrlInBrowser(requireContext(), info.getUrl())));
|
||||||
case R.id.overlay_buttons_layout:
|
binding.detailControlsPlayWithKodi.setOnClickListener(makeOnClickListener(info -> {
|
||||||
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_EXPANDED);
|
try {
|
||||||
break;
|
playWithKore(requireContext(), Uri.parse(info.getUrl()));
|
||||||
case R.id.overlay_play_queue_button:
|
} catch (final Exception e) {
|
||||||
NavigationHelper.openPlayQueue(getContext());
|
if (DEBUG) {
|
||||||
break;
|
Log.i(TAG, "Failed to start kore", e);
|
||||||
case R.id.overlay_play_pause_button:
|
}
|
||||||
|
KoreUtils.showInstallKoreDialog(requireContext());
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
if (DEBUG) {
|
||||||
|
binding.detailControlsCrashThePlayer.setOnClickListener(v ->
|
||||||
|
VideoDetailPlayerCrasher.onCrashThePlayer(requireContext(), player));
|
||||||
|
}
|
||||||
|
|
||||||
|
final View.OnClickListener overlayListener = v -> bottomSheetBehavior
|
||||||
|
.setState(BottomSheetBehavior.STATE_EXPANDED);
|
||||||
|
binding.overlayThumbnail.setOnClickListener(overlayListener);
|
||||||
|
binding.overlayMetadataLayout.setOnClickListener(overlayListener);
|
||||||
|
binding.overlayButtonsLayout.setOnClickListener(overlayListener);
|
||||||
|
binding.overlayCloseButton.setOnClickListener(v -> bottomSheetBehavior
|
||||||
|
.setState(BottomSheetBehavior.STATE_HIDDEN));
|
||||||
|
binding.overlayPlayQueueButton.setOnClickListener(v -> openPlayQueue(requireContext()));
|
||||||
|
binding.overlayPlayPauseButton.setOnClickListener(v -> {
|
||||||
if (playerIsNotStopped()) {
|
if (playerIsNotStopped()) {
|
||||||
player.playPause();
|
player.playPause();
|
||||||
player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0));
|
player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0));
|
||||||
@ -543,11 +519,50 @@ public final class VideoDetailFragment
|
|||||||
}
|
}
|
||||||
|
|
||||||
setOverlayPlayPauseImage(isPlayerAvailable() && player.isPlaying());
|
setOverlayPlayPauseImage(isPlayerAvailable() && player.isPlaying());
|
||||||
break;
|
});
|
||||||
case R.id.overlay_close_button:
|
|
||||||
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_HIDDEN);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private View.OnClickListener makeOnClickListener(final Consumer<StreamInfo> consumer) {
|
||||||
|
return v -> {
|
||||||
|
if (!isLoading.get() && currentInfo != null) {
|
||||||
|
consumer.accept(currentInfo);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setOnLongClickListeners() {
|
||||||
|
binding.detailTitleRootLayout.setOnLongClickListener(makeOnLongClickListener(info ->
|
||||||
|
ShareUtils.copyToClipboard(requireContext(),
|
||||||
|
binding.detailVideoTitleView.getText().toString())));
|
||||||
|
binding.detailUploaderRootLayout.setOnLongClickListener(makeOnLongClickListener(info -> {
|
||||||
|
if (isEmpty(info.getSubChannelUrl())) {
|
||||||
|
Log.w(TAG, "Can't open parent channel because we got no parent channel URL");
|
||||||
|
} else {
|
||||||
|
openChannel(info.getUploaderUrl(), info.getUploaderName());
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
binding.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(info ->
|
||||||
|
openBackgroundPlayer(true)));
|
||||||
|
binding.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(info ->
|
||||||
|
openPopupPlayer(true)));
|
||||||
|
binding.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(info ->
|
||||||
|
NavigationHelper.openDownloads(activity)));
|
||||||
|
|
||||||
|
final View.OnLongClickListener overlayListener = makeOnLongClickListener(info ->
|
||||||
|
openChannel(info.getUploaderUrl(), info.getUploaderName()));
|
||||||
|
binding.overlayThumbnail.setOnLongClickListener(overlayListener);
|
||||||
|
binding.overlayMetadataLayout.setOnLongClickListener(overlayListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private View.OnLongClickListener makeOnLongClickListener(final Consumer<StreamInfo> consumer) {
|
||||||
|
return v -> {
|
||||||
|
if (isLoading.get() || currentInfo == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
consumer.accept(currentInfo);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private void openChannel(final String subChannelUrl, final String subChannelName) {
|
private void openChannel(final String subChannelUrl, final String subChannelName) {
|
||||||
@ -559,43 +574,6 @@ public final class VideoDetailFragment
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onLongClick(final View v) {
|
|
||||||
if (isLoading.get() || currentInfo == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (v.getId()) {
|
|
||||||
case R.id.detail_controls_background:
|
|
||||||
openBackgroundPlayer(true);
|
|
||||||
break;
|
|
||||||
case R.id.detail_controls_popup:
|
|
||||||
openPopupPlayer(true);
|
|
||||||
break;
|
|
||||||
case R.id.detail_controls_download:
|
|
||||||
NavigationHelper.openDownloads(activity);
|
|
||||||
break;
|
|
||||||
case R.id.overlay_thumbnail:
|
|
||||||
case R.id.overlay_metadata_layout:
|
|
||||||
openChannel(currentInfo.getUploaderUrl(), currentInfo.getUploaderName());
|
|
||||||
break;
|
|
||||||
case R.id.detail_uploader_root_layout:
|
|
||||||
if (isEmpty(currentInfo.getSubChannelUrl())) {
|
|
||||||
Log.w(TAG,
|
|
||||||
"Can't open parent channel because we got no parent channel URL");
|
|
||||||
} else {
|
|
||||||
openChannel(currentInfo.getUploaderUrl(), currentInfo.getUploaderName());
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case R.id.detail_title_root_layout:
|
|
||||||
ShareUtils.copyToClipboard(requireContext(),
|
|
||||||
binding.detailVideoTitleView.getText().toString());
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void toggleTitleAndSecondaryControls() {
|
private void toggleTitleAndSecondaryControls() {
|
||||||
if (binding.detailSecondaryControlPanel.getVisibility() == View.GONE) {
|
if (binding.detailSecondaryControlPanel.getVisibility() == View.GONE) {
|
||||||
binding.detailVideoTitleView.setMaxLines(10);
|
binding.detailVideoTitleView.setMaxLines(10);
|
||||||
@ -616,11 +594,6 @@ public final class VideoDetailFragment
|
|||||||
// Init
|
// Init
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
|
|
||||||
super.onViewCreated(rootView, savedInstanceState);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override // called from onViewCreated in {@link BaseFragment#onViewCreated}
|
@Override // called from onViewCreated in {@link BaseFragment#onViewCreated}
|
||||||
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
||||||
super.initViews(rootView, savedInstanceState);
|
super.initViews(rootView, savedInstanceState);
|
||||||
@ -642,60 +615,29 @@ public final class VideoDetailFragment
|
|||||||
? View.VISIBLE
|
? View.VISIBLE
|
||||||
: View.GONE
|
: View.GONE
|
||||||
);
|
);
|
||||||
|
accommodateForTvAndDesktopMode();
|
||||||
if (DeviceUtils.isTv(getContext())) {
|
|
||||||
// remove ripple effects from detail controls
|
|
||||||
final int transparent = ContextCompat.getColor(requireContext(),
|
|
||||||
R.color.transparent_background_color);
|
|
||||||
binding.detailControlsPlaylistAppend.setBackgroundColor(transparent);
|
|
||||||
binding.detailControlsBackground.setBackgroundColor(transparent);
|
|
||||||
binding.detailControlsPopup.setBackgroundColor(transparent);
|
|
||||||
binding.detailControlsDownload.setBackgroundColor(transparent);
|
|
||||||
binding.detailControlsShare.setBackgroundColor(transparent);
|
|
||||||
binding.detailControlsOpenInBrowser.setBackgroundColor(transparent);
|
|
||||||
binding.detailControlsPlayWithKodi.setBackgroundColor(transparent);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
protected void initListeners() {
|
protected void initListeners() {
|
||||||
super.initListeners();
|
super.initListeners();
|
||||||
|
|
||||||
binding.detailTitleRootLayout.setOnClickListener(this);
|
setOnClickListeners();
|
||||||
binding.detailTitleRootLayout.setOnLongClickListener(this);
|
setOnLongClickListeners();
|
||||||
binding.detailUploaderRootLayout.setOnClickListener(this);
|
|
||||||
binding.detailUploaderRootLayout.setOnLongClickListener(this);
|
|
||||||
binding.detailThumbnailRootLayout.setOnClickListener(this);
|
|
||||||
|
|
||||||
binding.detailControlsBackground.setOnClickListener(this);
|
final View.OnTouchListener controlsTouchListener = (view, motionEvent) -> {
|
||||||
binding.detailControlsBackground.setOnLongClickListener(this);
|
if (motionEvent.getAction() == MotionEvent.ACTION_DOWN
|
||||||
binding.detailControlsPopup.setOnClickListener(this);
|
&& PreferenceManager.getDefaultSharedPreferences(activity)
|
||||||
binding.detailControlsPopup.setOnLongClickListener(this);
|
.getBoolean(getString(R.string.show_hold_to_append_key), true)) {
|
||||||
binding.detailControlsPlaylistAppend.setOnClickListener(this);
|
|
||||||
binding.detailControlsDownload.setOnClickListener(this);
|
animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, 0, () ->
|
||||||
binding.detailControlsDownload.setOnLongClickListener(this);
|
animate(binding.touchAppendDetail, false, 1500, AnimationType.ALPHA, 1000));
|
||||||
binding.detailControlsShare.setOnClickListener(this);
|
|
||||||
binding.detailControlsOpenInBrowser.setOnClickListener(this);
|
|
||||||
binding.detailControlsPlayWithKodi.setOnClickListener(this);
|
|
||||||
if (DEBUG) {
|
|
||||||
binding.detailControlsCrashThePlayer.setOnClickListener(
|
|
||||||
v -> VideoDetailPlayerCrasher.onCrashThePlayer(
|
|
||||||
this.getContext(),
|
|
||||||
this.player)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return false;
|
||||||
binding.overlayThumbnail.setOnClickListener(this);
|
};
|
||||||
binding.overlayThumbnail.setOnLongClickListener(this);
|
binding.detailControlsBackground.setOnTouchListener(controlsTouchListener);
|
||||||
binding.overlayMetadataLayout.setOnClickListener(this);
|
binding.detailControlsPopup.setOnTouchListener(controlsTouchListener);
|
||||||
binding.overlayMetadataLayout.setOnLongClickListener(this);
|
|
||||||
binding.overlayButtonsLayout.setOnClickListener(this);
|
|
||||||
binding.overlayPlayQueueButton.setOnClickListener(this);
|
|
||||||
binding.overlayCloseButton.setOnClickListener(this);
|
|
||||||
binding.overlayPlayPauseButton.setOnClickListener(this);
|
|
||||||
|
|
||||||
binding.detailControlsBackground.setOnTouchListener(getOnControlsTouchListener());
|
|
||||||
binding.detailControlsPopup.setOnTouchListener(getOnControlsTouchListener());
|
|
||||||
|
|
||||||
binding.appBarLayout.addOnOffsetChangedListener((layout, verticalOffset) -> {
|
binding.appBarLayout.addOnOffsetChangedListener((layout, verticalOffset) -> {
|
||||||
// prevent useless updates to tab layout visibility if nothing changed
|
// prevent useless updates to tab layout visibility if nothing changed
|
||||||
@ -714,23 +656,6 @@ public final class VideoDetailFragment
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private View.OnTouchListener getOnControlsTouchListener() {
|
|
||||||
return (view, motionEvent) -> {
|
|
||||||
if (!PreferenceManager.getDefaultSharedPreferences(activity)
|
|
||||||
.getBoolean(getString(R.string.show_hold_to_append_key), true)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (motionEvent.getAction() == MotionEvent.ACTION_DOWN) {
|
|
||||||
animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA,
|
|
||||||
0, () ->
|
|
||||||
animate(binding.touchAppendDetail, false, 1500,
|
|
||||||
AnimationType.ALPHA, 1000));
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private void initThumbnailViews(@NonNull final StreamInfo info) {
|
private void initThumbnailViews(@NonNull final StreamInfo info) {
|
||||||
PicassoHelper.loadDetailsThumbnail(info.getThumbnailUrl()).tag(PICASSO_VIDEO_DETAILS_TAG)
|
PicassoHelper.loadDetailsThumbnail(info.getThumbnailUrl()).tag(PICASSO_VIDEO_DETAILS_TAG)
|
||||||
.into(binding.detailThumbnailImageView, new Callback() {
|
.into(binding.detailThumbnailImageView, new Callback() {
|
||||||
@ -940,7 +865,8 @@ public final class VideoDetailFragment
|
|||||||
if (playQueue == null) {
|
if (playQueue == null) {
|
||||||
playQueue = new SinglePlayQueue(result);
|
playQueue = new SinglePlayQueue(result);
|
||||||
}
|
}
|
||||||
if (stack.isEmpty() || !stack.peek().getPlayQueue().equals(playQueue)) {
|
if (stack.isEmpty() || !stack.peek().getPlayQueue()
|
||||||
|
.equalStreams(playQueue)) {
|
||||||
stack.push(new StackItem(serviceId, url, title, playQueue));
|
stack.push(new StackItem(serviceId, url, title, playQueue));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1143,8 +1069,7 @@ public final class VideoDetailFragment
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void openPopupPlayer(final boolean append) {
|
private void openPopupPlayer(final boolean append) {
|
||||||
if (!PermissionHelper.isPopupEnabled(activity)) {
|
if (!PermissionHelper.isPopupEnabledElseAsk(activity)) {
|
||||||
PermissionHelper.showPopupEnablementToast(activity);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1250,16 +1175,15 @@ public final class VideoDetailFragment
|
|||||||
* be reused in a few milliseconds and the flickering would be annoying.
|
* be reused in a few milliseconds and the flickering would be annoying.
|
||||||
*/
|
*/
|
||||||
private void hideMainPlayerOnLoadingNewStream() {
|
private void hideMainPlayerOnLoadingNewStream() {
|
||||||
//noinspection SimplifyOptionalCallChains
|
final var root = getRoot();
|
||||||
if (!isPlayerServiceAvailable() || !getRoot().isPresent()
|
if (!isPlayerServiceAvailable() || root.isEmpty() || !player.videoPlayerSelected()) {
|
||||||
|| !player.videoPlayerSelected()) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
removeVideoPlayerView();
|
removeVideoPlayerView();
|
||||||
if (isAutoplayEnabled()) {
|
if (isAutoplayEnabled()) {
|
||||||
playerService.stopForImmediateReusing();
|
playerService.stopForImmediateReusing();
|
||||||
getRoot().ifPresent(view -> view.setVisibility(View.GONE));
|
root.ifPresent(view -> view.setVisibility(View.GONE));
|
||||||
} else {
|
} else {
|
||||||
playerHolder.stopService();
|
playerHolder.stopService();
|
||||||
}
|
}
|
||||||
@ -1573,9 +1497,9 @@ public final class VideoDetailFragment
|
|||||||
binding.detailSubChannelThumbnailView.setVisibility(View.GONE);
|
binding.detailSubChannelThumbnailView.setVisibility(View.GONE);
|
||||||
|
|
||||||
if (!isEmpty(info.getSubChannelName())) {
|
if (!isEmpty(info.getSubChannelName())) {
|
||||||
displayBothUploaderAndSubChannel(info);
|
displayBothUploaderAndSubChannel(info, activity);
|
||||||
} else if (!isEmpty(info.getUploaderName())) {
|
} else if (!isEmpty(info.getUploaderName())) {
|
||||||
displayUploaderAsSubChannel(info);
|
displayUploaderAsSubChannel(info, activity);
|
||||||
} else {
|
} else {
|
||||||
binding.detailUploaderTextView.setVisibility(View.GONE);
|
binding.detailUploaderTextView.setVisibility(View.GONE);
|
||||||
binding.detailUploaderThumbnailView.setVisibility(View.GONE);
|
binding.detailUploaderThumbnailView.setVisibility(View.GONE);
|
||||||
@ -1678,7 +1602,8 @@ public final class VideoDetailFragment
|
|||||||
|
|
||||||
binding.detailControlsDownload.setVisibility(
|
binding.detailControlsDownload.setVisibility(
|
||||||
StreamTypeUtil.isLiveStream(info.getStreamType()) ? View.GONE : View.VISIBLE);
|
StreamTypeUtil.isLiveStream(info.getStreamType()) ? View.GONE : View.VISIBLE);
|
||||||
binding.detailControlsBackground.setVisibility(info.getAudioStreams().isEmpty()
|
binding.detailControlsBackground.setVisibility(
|
||||||
|
info.getAudioStreams().isEmpty() && info.getVideoStreams().isEmpty()
|
||||||
? View.GONE : View.VISIBLE);
|
? View.GONE : View.VISIBLE);
|
||||||
|
|
||||||
final boolean noVideoStreams =
|
final boolean noVideoStreams =
|
||||||
@ -1688,23 +1613,42 @@ public final class VideoDetailFragment
|
|||||||
noVideoStreams ? R.drawable.ic_headset_shadow : R.drawable.ic_play_arrow_shadow);
|
noVideoStreams ? R.drawable.ic_headset_shadow : R.drawable.ic_play_arrow_shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void displayUploaderAsSubChannel(final StreamInfo info) {
|
private void displayUploaderAsSubChannel(final StreamInfo info, final Context context) {
|
||||||
binding.detailSubChannelTextView.setText(info.getUploaderName());
|
binding.detailSubChannelTextView.setText(info.getUploaderName());
|
||||||
binding.detailSubChannelTextView.setVisibility(View.VISIBLE);
|
binding.detailSubChannelTextView.setVisibility(View.VISIBLE);
|
||||||
binding.detailSubChannelTextView.setSelected(true);
|
binding.detailSubChannelTextView.setSelected(true);
|
||||||
|
|
||||||
|
if (info.getUploaderSubscriberCount() > -1) {
|
||||||
|
binding.detailUploaderTextView.setText(
|
||||||
|
Localization.shortSubscriberCount(context, info.getUploaderSubscriberCount()));
|
||||||
|
binding.detailUploaderTextView.setVisibility(View.VISIBLE);
|
||||||
|
} else {
|
||||||
binding.detailUploaderTextView.setVisibility(View.GONE);
|
binding.detailUploaderTextView.setVisibility(View.GONE);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void displayBothUploaderAndSubChannel(final StreamInfo info) {
|
private void displayBothUploaderAndSubChannel(final StreamInfo info, final Context context) {
|
||||||
binding.detailSubChannelTextView.setText(info.getSubChannelName());
|
binding.detailSubChannelTextView.setText(info.getSubChannelName());
|
||||||
binding.detailSubChannelTextView.setVisibility(View.VISIBLE);
|
binding.detailSubChannelTextView.setVisibility(View.VISIBLE);
|
||||||
binding.detailSubChannelTextView.setSelected(true);
|
binding.detailSubChannelTextView.setSelected(true);
|
||||||
|
|
||||||
binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE);
|
binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE);
|
||||||
|
|
||||||
|
final StringBuilder subText = new StringBuilder();
|
||||||
if (!isEmpty(info.getUploaderName())) {
|
if (!isEmpty(info.getUploaderName())) {
|
||||||
binding.detailUploaderTextView.setText(
|
subText.append(
|
||||||
String.format(getString(R.string.video_detail_by), info.getUploaderName()));
|
String.format(getString(R.string.video_detail_by), info.getUploaderName()));
|
||||||
|
}
|
||||||
|
if (info.getUploaderSubscriberCount() > -1) {
|
||||||
|
if (subText.length() > 0) {
|
||||||
|
subText.append(Localization.DOT_SEPARATOR);
|
||||||
|
}
|
||||||
|
subText.append(
|
||||||
|
Localization.shortSubscriberCount(context, info.getUploaderSubscriberCount()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (subText.length() > 0) {
|
||||||
|
binding.detailUploaderTextView.setText(subText);
|
||||||
binding.detailUploaderTextView.setVisibility(View.VISIBLE);
|
binding.detailUploaderTextView.setVisibility(View.VISIBLE);
|
||||||
binding.detailUploaderTextView.setSelected(true);
|
binding.detailUploaderTextView.setSelected(true);
|
||||||
} else {
|
} else {
|
||||||
@ -1836,7 +1780,7 @@ public final class VideoDetailFragment
|
|||||||
// deleted/added items inside Channel/Playlist queue and makes possible to have
|
// deleted/added items inside Channel/Playlist queue and makes possible to have
|
||||||
// a history of played items
|
// a history of played items
|
||||||
@Nullable final StackItem stackPeek = stack.peek();
|
@Nullable final StackItem stackPeek = stack.peek();
|
||||||
if (stackPeek != null && !stackPeek.getPlayQueue().equals(queue)) {
|
if (stackPeek != null && !stackPeek.getPlayQueue().equalStreams(queue)) {
|
||||||
@Nullable final PlayQueueItem playQueueItem = queue.getItem();
|
@Nullable final PlayQueueItem playQueueItem = queue.getItem();
|
||||||
if (playQueueItem != null) {
|
if (playQueueItem != null) {
|
||||||
stack.push(new StackItem(playQueueItem.getServiceId(), playQueueItem.getUrl(),
|
stack.push(new StackItem(playQueueItem.getServiceId(), playQueueItem.getUrl(),
|
||||||
@ -1902,7 +1846,7 @@ public final class VideoDetailFragment
|
|||||||
// They are not equal when user watches something in popup while browsing in fragment and
|
// They are not equal when user watches something in popup while browsing in fragment and
|
||||||
// then changes screen orientation. In that case the fragment will set itself as
|
// then changes screen orientation. In that case the fragment will set itself as
|
||||||
// a service listener and will receive initial call to onMetadataUpdate()
|
// a service listener and will receive initial call to onMetadataUpdate()
|
||||||
if (!queue.equals(playQueue)) {
|
if (!queue.equalStreams(playQueue)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1943,10 +1887,9 @@ public final class VideoDetailFragment
|
|||||||
@Override
|
@Override
|
||||||
public void onFullscreenStateChanged(final boolean fullscreen) {
|
public void onFullscreenStateChanged(final boolean fullscreen) {
|
||||||
setupBrightness();
|
setupBrightness();
|
||||||
//noinspection SimplifyOptionalCallChains
|
|
||||||
if (!isPlayerAndPlayerServiceAvailable()
|
if (!isPlayerAndPlayerServiceAvailable()
|
||||||
|| !player.UIs().get(MainPlayerUi.class).isPresent()
|
|| player.UIs().get(MainPlayerUi.class).isEmpty()
|
||||||
|| getRoot().map(View::getParent).orElse(null) == null) {
|
|| getRoot().map(View::getParent).isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -2018,15 +1961,17 @@ public final class VideoDetailFragment
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent jumping of the player on devices with cutout
|
final var window = activity.getWindow();
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
final var windowInsetsController = WindowCompat.getInsetsController(window,
|
||||||
activity.getWindow().getAttributes().layoutInDisplayCutoutMode =
|
window.getDecorView());
|
||||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_DEFAULT;
|
|
||||||
}
|
WindowCompat.setDecorFitsSystemWindows(window, true);
|
||||||
activity.getWindow().getDecorView().setSystemUiVisibility(0);
|
windowInsetsController.setSystemBarsBehavior(WindowInsetsControllerCompat
|
||||||
activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
|
.BEHAVIOR_SHOW_BARS_BY_TOUCH);
|
||||||
activity.getWindow().setStatusBarColor(ThemeHelper.resolveColorFromAttr(
|
windowInsetsController.show(WindowInsetsCompat.Type.systemBars());
|
||||||
requireContext(), android.R.attr.colorPrimary));
|
|
||||||
|
window.setStatusBarColor(ThemeHelper.resolveColorFromAttr(requireContext(),
|
||||||
|
android.R.attr.colorPrimary));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void hideSystemUi() {
|
private void hideSystemUi() {
|
||||||
@ -2038,30 +1983,19 @@ public final class VideoDetailFragment
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prevent jumping of the player on devices with cutout
|
final var window = activity.getWindow();
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
final var windowInsetsController = WindowCompat.getInsetsController(window,
|
||||||
activity.getWindow().getAttributes().layoutInDisplayCutoutMode =
|
window.getDecorView());
|
||||||
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES;
|
|
||||||
}
|
|
||||||
int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
|
||||||
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
|
||||||
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION
|
|
||||||
| View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
|
|
||||||
| View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
|
|
||||||
|
|
||||||
// In multiWindow mode status bar is not transparent for devices with cutout
|
WindowCompat.setDecorFitsSystemWindows(window, false);
|
||||||
// if I include this flag. So without it is better in this case
|
windowInsetsController.setSystemBarsBehavior(WindowInsetsControllerCompat
|
||||||
final boolean isInMultiWindow = DeviceUtils.isInMultiWindow(activity);
|
.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE);
|
||||||
if (!isInMultiWindow) {
|
windowInsetsController.hide(WindowInsetsCompat.Type.systemBars());
|
||||||
visibility |= View.SYSTEM_UI_FLAG_FULLSCREEN;
|
|
||||||
}
|
|
||||||
activity.getWindow().getDecorView().setSystemUiVisibility(visibility);
|
|
||||||
|
|
||||||
if (isInMultiWindow || isFullscreen()) {
|
if (DeviceUtils.isInMultiWindow(activity) || isFullscreen()) {
|
||||||
activity.getWindow().setStatusBarColor(Color.TRANSPARENT);
|
window.setStatusBarColor(Color.TRANSPARENT);
|
||||||
activity.getWindow().setNavigationBarColor(Color.TRANSPARENT);
|
window.setNavigationBarColor(Color.TRANSPARENT);
|
||||||
}
|
}
|
||||||
activity.getWindow().clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listener implementation
|
// Listener implementation
|
||||||
@ -2118,6 +2052,30 @@ public final class VideoDetailFragment
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make changes to the UI to accommodate for better usability on bigger screens such as TVs
|
||||||
|
* or in Android's desktop mode (DeX etc).
|
||||||
|
*/
|
||||||
|
private void accommodateForTvAndDesktopMode() {
|
||||||
|
if (DeviceUtils.isTv(getContext())) {
|
||||||
|
// remove ripple effects from detail controls
|
||||||
|
final int transparent = ContextCompat.getColor(requireContext(),
|
||||||
|
R.color.transparent_background_color);
|
||||||
|
binding.detailControlsPlaylistAppend.setBackgroundColor(transparent);
|
||||||
|
binding.detailControlsBackground.setBackgroundColor(transparent);
|
||||||
|
binding.detailControlsPopup.setBackgroundColor(transparent);
|
||||||
|
binding.detailControlsDownload.setBackgroundColor(transparent);
|
||||||
|
binding.detailControlsShare.setBackgroundColor(transparent);
|
||||||
|
binding.detailControlsOpenInBrowser.setBackgroundColor(transparent);
|
||||||
|
binding.detailControlsPlayWithKodi.setBackgroundColor(transparent);
|
||||||
|
}
|
||||||
|
if (DeviceUtils.isDesktopMode(getContext())) {
|
||||||
|
// Remove the "hover" overlay (since it is visible on all mouse events and interferes
|
||||||
|
// with the video content being played)
|
||||||
|
binding.detailThumbnailRootLayout.setForeground(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void checkLandscape() {
|
private void checkLandscape() {
|
||||||
if ((!player.isPlaying() && player.getPlayQueue() != playQueue)
|
if ((!player.isPlaying() && player.getPlayQueue() != playQueue)
|
||||||
|| player.getPlayQueue() == null) {
|
|| player.getPlayQueue() == null) {
|
||||||
@ -2145,7 +2103,7 @@ public final class VideoDetailFragment
|
|||||||
final Iterator<StackItem> iterator = stack.descendingIterator();
|
final Iterator<StackItem> iterator = stack.descendingIterator();
|
||||||
while (iterator.hasNext()) {
|
while (iterator.hasNext()) {
|
||||||
final StackItem next = iterator.next();
|
final StackItem next = iterator.next();
|
||||||
if (next.getPlayQueue().equals(queue)) {
|
if (next.getPlayQueue().equalStreams(queue)) {
|
||||||
item = next;
|
item = next;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -2160,7 +2118,7 @@ public final class VideoDetailFragment
|
|||||||
if (isClearingQueueConfirmationRequired(activity)
|
if (isClearingQueueConfirmationRequired(activity)
|
||||||
&& playerIsNotStopped()
|
&& playerIsNotStopped()
|
||||||
&& activeQueue != null
|
&& activeQueue != null
|
||||||
&& !activeQueue.equals(playQueue)) {
|
&& !activeQueue.equalStreams(playQueue)) {
|
||||||
showClearingQueueConfirmation(onAllow);
|
showClearingQueueConfirmation(onAllow);
|
||||||
} else {
|
} else {
|
||||||
onAllow.run();
|
onAllow.run();
|
||||||
@ -2461,23 +2419,20 @@ public final class VideoDetailFragment
|
|||||||
|
|
||||||
// helpers to check the state of player and playerService
|
// helpers to check the state of player and playerService
|
||||||
boolean isPlayerAvailable() {
|
boolean isPlayerAvailable() {
|
||||||
return (player != null);
|
return player != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isPlayerServiceAvailable() {
|
boolean isPlayerServiceAvailable() {
|
||||||
return (playerService != null);
|
return playerService != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
boolean isPlayerAndPlayerServiceAvailable() {
|
boolean isPlayerAndPlayerServiceAvailable() {
|
||||||
return (player != null && playerService != null);
|
return player != null && playerService != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<View> getRoot() {
|
public Optional<View> getRoot() {
|
||||||
if (player == null) {
|
return Optional.ofNullable(player)
|
||||||
return Optional.empty();
|
.flatMap(player1 -> player1.UIs().get(VideoPlayerUi.class))
|
||||||
}
|
|
||||||
|
|
||||||
return player.UIs().get(VideoPlayerUi.class)
|
|
||||||
.map(playerUi -> playerUi.getBinding().getRoot());
|
.map(playerUi -> playerUi.getBinding().getRoot());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,6 +26,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
|||||||
import org.schabi.newpipe.fragments.BaseStateFragment;
|
import org.schabi.newpipe.fragments.BaseStateFragment;
|
||||||
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
|
import org.schabi.newpipe.fragments.OnScrollBelowItemsListener;
|
||||||
import org.schabi.newpipe.info_list.InfoListAdapter;
|
import org.schabi.newpipe.info_list.InfoListAdapter;
|
||||||
|
import org.schabi.newpipe.info_list.ItemViewMode;
|
||||||
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
|
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
|
||||||
import org.schabi.newpipe.util.NavigationHelper;
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
import org.schabi.newpipe.util.OnClickGesture;
|
import org.schabi.newpipe.util.OnClickGesture;
|
||||||
@ -91,11 +92,7 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
|||||||
|
|
||||||
if (updateFlags != 0) {
|
if (updateFlags != 0) {
|
||||||
if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) {
|
if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) {
|
||||||
final boolean useGrid = isGridLayout();
|
refreshItemViewMode();
|
||||||
itemsList.setLayoutManager(useGrid
|
|
||||||
? getGridLayoutManager() : getListLayoutManager());
|
|
||||||
infoListAdapter.setUseGridVariant(useGrid);
|
|
||||||
infoListAdapter.notifyDataSetChanged();
|
|
||||||
}
|
}
|
||||||
updateFlags = 0;
|
updateFlags = 0;
|
||||||
}
|
}
|
||||||
@ -215,22 +212,29 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
|||||||
final Resources resources = activity.getResources();
|
final Resources resources = activity.getResources();
|
||||||
int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width);
|
int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width);
|
||||||
width += (24 * resources.getDisplayMetrics().density);
|
width += (24 * resources.getDisplayMetrics().density);
|
||||||
final int spanCount = (int) Math.floor(resources.getDisplayMetrics().widthPixels
|
final int spanCount = Math.floorDiv(resources.getDisplayMetrics().widthPixels, width);
|
||||||
/ (double) width);
|
|
||||||
final GridLayoutManager lm = new GridLayoutManager(activity, spanCount);
|
final GridLayoutManager lm = new GridLayoutManager(activity, spanCount);
|
||||||
lm.setSpanSizeLookup(infoListAdapter.getSpanSizeLookup(spanCount));
|
lm.setSpanSizeLookup(infoListAdapter.getSpanSizeLookup(spanCount));
|
||||||
return lm;
|
return lm;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the item view mode based on user preference.
|
||||||
|
*/
|
||||||
|
private void refreshItemViewMode() {
|
||||||
|
final ItemViewMode itemViewMode = getItemViewMode();
|
||||||
|
itemsList.setLayoutManager((itemViewMode == ItemViewMode.GRID)
|
||||||
|
? getGridLayoutManager() : getListLayoutManager());
|
||||||
|
infoListAdapter.setItemViewMode(itemViewMode);
|
||||||
|
infoListAdapter.notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
||||||
super.initViews(rootView, savedInstanceState);
|
super.initViews(rootView, savedInstanceState);
|
||||||
|
|
||||||
final boolean useGrid = isGridLayout();
|
|
||||||
itemsList = rootView.findViewById(R.id.items_list);
|
itemsList = rootView.findViewById(R.id.items_list);
|
||||||
itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
|
refreshItemViewMode();
|
||||||
|
|
||||||
infoListAdapter.setUseGridVariant(useGrid);
|
|
||||||
|
|
||||||
final Supplier<View> listHeaderSupplier = getListHeaderSupplier();
|
final Supplier<View> listHeaderSupplier = getListHeaderSupplier();
|
||||||
if (listHeaderSupplier != null) {
|
if (listHeaderSupplier != null) {
|
||||||
@ -470,12 +474,16 @@ public abstract class BaseListFragment<I, N> extends BaseStateFragment<I>
|
|||||||
@Override
|
@Override
|
||||||
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
|
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
|
||||||
final String key) {
|
final String key) {
|
||||||
if (key.equals(getString(R.string.list_view_mode_key))) {
|
if (getString(R.string.list_view_mode_key).equals(key)) {
|
||||||
updateFlags |= LIST_MODE_UPDATE_FLAG;
|
updateFlags |= LIST_MODE_UPDATE_FLAG;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected boolean isGridLayout() {
|
/**
|
||||||
return ThemeHelper.shouldUseGridLayout(activity);
|
* Returns preferred item view mode.
|
||||||
|
* @return ItemViewMode
|
||||||
|
*/
|
||||||
|
protected ItemViewMode getItemViewMode() {
|
||||||
|
return ThemeHelper.getItemViewMode(requireContext());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -17,6 +17,7 @@ import org.schabi.newpipe.extractor.ListExtractor;
|
|||||||
import org.schabi.newpipe.extractor.comments.CommentsInfo;
|
import org.schabi.newpipe.extractor.comments.CommentsInfo;
|
||||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||||
|
import org.schabi.newpipe.info_list.ItemViewMode;
|
||||||
import org.schabi.newpipe.ktx.ViewUtils;
|
import org.schabi.newpipe.ktx.ViewUtils;
|
||||||
import org.schabi.newpipe.util.ExtractorHelper;
|
import org.schabi.newpipe.util.ExtractorHelper;
|
||||||
|
|
||||||
@ -106,7 +107,7 @@ public class CommentsFragment extends BaseListInfoFragment<CommentsInfoItem, Com
|
|||||||
@NonNull final MenuInflater inflater) { }
|
@NonNull final MenuInflater inflater) { }
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected boolean isGridLayout() {
|
protected ItemViewMode getItemViewMode() {
|
||||||
return false;
|
return ItemViewMode.LIST;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -132,6 +132,8 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
|
|||||||
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
||||||
super.initViews(rootView, savedInstanceState);
|
super.initViews(rootView, savedInstanceState);
|
||||||
|
|
||||||
|
// Is mini variant still relevant?
|
||||||
|
// Only the remote playlist screen uses it now
|
||||||
infoListAdapter.setUseMiniVariant(true);
|
infoListAdapter.setUseMiniVariant(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -230,15 +232,14 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
|
|||||||
ShareUtils.openUrlInBrowser(requireContext(), url);
|
ShareUtils.openUrlInBrowser(requireContext(), url);
|
||||||
break;
|
break;
|
||||||
case R.id.menu_item_share:
|
case R.id.menu_item_share:
|
||||||
if (currentInfo != null) {
|
|
||||||
ShareUtils.shareText(requireContext(), name, url,
|
ShareUtils.shareText(requireContext(), name, url,
|
||||||
currentInfo.getThumbnailUrl());
|
currentInfo == null ? null : currentInfo.getThumbnailUrl());
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
case R.id.menu_item_bookmark:
|
case R.id.menu_item_bookmark:
|
||||||
onBookmarkClicked();
|
onBookmarkClicked();
|
||||||
break;
|
break;
|
||||||
case R.id.menu_item_append_playlist:
|
case R.id.menu_item_append_playlist:
|
||||||
|
if (currentInfo != null) {
|
||||||
disposables.add(PlaylistDialog.createCorrespondingDialog(
|
disposables.add(PlaylistDialog.createCorrespondingDialog(
|
||||||
getContext(),
|
getContext(),
|
||||||
getPlayQueue()
|
getPlayQueue()
|
||||||
@ -248,6 +249,7 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
|
|||||||
.collect(Collectors.toList()),
|
.collect(Collectors.toList()),
|
||||||
dialog -> dialog.show(getFM(), TAG)
|
dialog -> dialog.show(getFM(), TAG)
|
||||||
));
|
));
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return super.onOptionsItemSelected(item);
|
return super.onOptionsItemSelected(item);
|
||||||
|
@ -33,6 +33,7 @@ import androidx.annotation.Nullable;
|
|||||||
import androidx.appcompat.app.ActionBar;
|
import androidx.appcompat.app.ActionBar;
|
||||||
import androidx.appcompat.app.AlertDialog;
|
import androidx.appcompat.app.AlertDialog;
|
||||||
import androidx.appcompat.widget.TooltipCompat;
|
import androidx.appcompat.widget.TooltipCompat;
|
||||||
|
import androidx.collection.SparseArrayCompat;
|
||||||
import androidx.core.text.HtmlCompat;
|
import androidx.core.text.HtmlCompat;
|
||||||
import androidx.preference.PreferenceManager;
|
import androidx.preference.PreferenceManager;
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||||
@ -70,9 +71,7 @@ import org.schabi.newpipe.util.ServiceHelper;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Queue;
|
import java.util.Queue;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
@ -141,7 +140,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
|||||||
@State
|
@State
|
||||||
boolean wasSearchFocused = false;
|
boolean wasSearchFocused = false;
|
||||||
|
|
||||||
@Nullable private Map<Integer, String> menuItemToFilterName = null;
|
private final SparseArrayCompat<String> menuItemToFilterName = new SparseArrayCompat<>();
|
||||||
private StreamingService service;
|
private StreamingService service;
|
||||||
private Page nextPage;
|
private Page nextPage;
|
||||||
private boolean showLocalSuggestions = true;
|
private boolean showLocalSuggestions = true;
|
||||||
@ -426,8 +425,6 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
|||||||
supportActionBar.setDisplayHomeAsUpEnabled(true);
|
supportActionBar.setDisplayHomeAsUpEnabled(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
menuItemToFilterName = new HashMap<>();
|
|
||||||
|
|
||||||
int itemId = 0;
|
int itemId = 0;
|
||||||
boolean isFirstItem = true;
|
boolean isFirstItem = true;
|
||||||
final Context c = getContext();
|
final Context c = getContext();
|
||||||
@ -468,11 +465,8 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
|
public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
|
||||||
if (menuItemToFilterName != null) {
|
final var filter = Collections.singletonList(menuItemToFilterName.get(item.getItemId()));
|
||||||
final List<String> cf = new ArrayList<>(1);
|
changeContentFilter(item, filter);
|
||||||
cf.add(menuItemToFilterName.get(item.getItemId()));
|
|
||||||
changeContentFilter(item, cf);
|
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,6 +19,7 @@ import org.schabi.newpipe.extractor.InfoItem;
|
|||||||
import org.schabi.newpipe.extractor.ListExtractor;
|
import org.schabi.newpipe.extractor.ListExtractor;
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||||
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||||
|
import org.schabi.newpipe.info_list.ItemViewMode;
|
||||||
import org.schabi.newpipe.ktx.ViewUtils;
|
import org.schabi.newpipe.ktx.ViewUtils;
|
||||||
import org.schabi.newpipe.util.RelatedItemInfo;
|
import org.schabi.newpipe.util.RelatedItemInfo;
|
||||||
|
|
||||||
@ -158,16 +159,19 @@ public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, Related
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
|
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
|
||||||
final String s) {
|
final String key) {
|
||||||
if (headerBinding != null) {
|
if (headerBinding != null && getString(R.string.auto_queue_key).equals(key)) {
|
||||||
headerBinding.autoplaySwitch.setChecked(
|
headerBinding.autoplaySwitch.setChecked(sharedPreferences.getBoolean(key, false));
|
||||||
sharedPreferences.getBoolean(
|
|
||||||
getString(R.string.auto_queue_key), false));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected boolean isGridLayout() {
|
protected ItemViewMode getItemViewMode() {
|
||||||
return false;
|
ItemViewMode mode = super.getItemViewMode();
|
||||||
|
// Only list mode is supported. Either List or card will be used.
|
||||||
|
if (mode != ItemViewMode.LIST && mode != ItemViewMode.CARD) {
|
||||||
|
mode = ItemViewMode.LIST;
|
||||||
|
}
|
||||||
|
return mode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -23,9 +23,11 @@ import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder;
|
|||||||
import org.schabi.newpipe.info_list.holder.CommentsInfoItemHolder;
|
import org.schabi.newpipe.info_list.holder.CommentsInfoItemHolder;
|
||||||
import org.schabi.newpipe.info_list.holder.CommentsMiniInfoItemHolder;
|
import org.schabi.newpipe.info_list.holder.CommentsMiniInfoItemHolder;
|
||||||
import org.schabi.newpipe.info_list.holder.InfoItemHolder;
|
import org.schabi.newpipe.info_list.holder.InfoItemHolder;
|
||||||
|
import org.schabi.newpipe.info_list.holder.PlaylistCardInfoItemHolder;
|
||||||
import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder;
|
import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder;
|
||||||
import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder;
|
import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder;
|
||||||
import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder;
|
import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder;
|
||||||
|
import org.schabi.newpipe.info_list.holder.StreamCardInfoItemHolder;
|
||||||
import org.schabi.newpipe.info_list.holder.StreamGridInfoItemHolder;
|
import org.schabi.newpipe.info_list.holder.StreamGridInfoItemHolder;
|
||||||
import org.schabi.newpipe.info_list.holder.StreamInfoItemHolder;
|
import org.schabi.newpipe.info_list.holder.StreamInfoItemHolder;
|
||||||
import org.schabi.newpipe.info_list.holder.StreamMiniInfoItemHolder;
|
import org.schabi.newpipe.info_list.holder.StreamMiniInfoItemHolder;
|
||||||
@ -67,12 +69,14 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
|||||||
private static final int MINI_STREAM_HOLDER_TYPE = 0x100;
|
private static final int MINI_STREAM_HOLDER_TYPE = 0x100;
|
||||||
private static final int STREAM_HOLDER_TYPE = 0x101;
|
private static final int STREAM_HOLDER_TYPE = 0x101;
|
||||||
private static final int GRID_STREAM_HOLDER_TYPE = 0x102;
|
private static final int GRID_STREAM_HOLDER_TYPE = 0x102;
|
||||||
|
private static final int CARD_STREAM_HOLDER_TYPE = 0x103;
|
||||||
private static final int MINI_CHANNEL_HOLDER_TYPE = 0x200;
|
private static final int MINI_CHANNEL_HOLDER_TYPE = 0x200;
|
||||||
private static final int CHANNEL_HOLDER_TYPE = 0x201;
|
private static final int CHANNEL_HOLDER_TYPE = 0x201;
|
||||||
private static final int GRID_CHANNEL_HOLDER_TYPE = 0x202;
|
private static final int GRID_CHANNEL_HOLDER_TYPE = 0x202;
|
||||||
private static final int MINI_PLAYLIST_HOLDER_TYPE = 0x300;
|
private static final int MINI_PLAYLIST_HOLDER_TYPE = 0x300;
|
||||||
private static final int PLAYLIST_HOLDER_TYPE = 0x301;
|
private static final int PLAYLIST_HOLDER_TYPE = 0x301;
|
||||||
private static final int GRID_PLAYLIST_HOLDER_TYPE = 0x302;
|
private static final int GRID_PLAYLIST_HOLDER_TYPE = 0x302;
|
||||||
|
private static final int CARD_PLAYLIST_HOLDER_TYPE = 0x303;
|
||||||
private static final int MINI_COMMENT_HOLDER_TYPE = 0x400;
|
private static final int MINI_COMMENT_HOLDER_TYPE = 0x400;
|
||||||
private static final int COMMENT_HOLDER_TYPE = 0x401;
|
private static final int COMMENT_HOLDER_TYPE = 0x401;
|
||||||
|
|
||||||
@ -82,9 +86,10 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
|||||||
private final HistoryRecordManager recordManager;
|
private final HistoryRecordManager recordManager;
|
||||||
|
|
||||||
private boolean useMiniVariant = false;
|
private boolean useMiniVariant = false;
|
||||||
private boolean useGridVariant = false;
|
|
||||||
private boolean showFooter = false;
|
private boolean showFooter = false;
|
||||||
|
|
||||||
|
private ItemViewMode itemMode = ItemViewMode.LIST;
|
||||||
|
|
||||||
private Supplier<View> headerSupplier = null;
|
private Supplier<View> headerSupplier = null;
|
||||||
|
|
||||||
public InfoListAdapter(final Context context) {
|
public InfoListAdapter(final Context context) {
|
||||||
@ -114,8 +119,8 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
|||||||
this.useMiniVariant = useMiniVariant;
|
this.useMiniVariant = useMiniVariant;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setUseGridVariant(final boolean useGridVariant) {
|
public void setItemViewMode(final ItemViewMode itemViewMode) {
|
||||||
this.useGridVariant = useGridVariant;
|
this.itemMode = itemViewMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void addInfoItemList(@Nullable final List<? extends InfoItem> data) {
|
public void addInfoItemList(@Nullable final List<? extends InfoItem> data) {
|
||||||
@ -234,14 +239,33 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
|||||||
final InfoItem item = infoItemList.get(position);
|
final InfoItem item = infoItemList.get(position);
|
||||||
switch (item.getInfoType()) {
|
switch (item.getInfoType()) {
|
||||||
case STREAM:
|
case STREAM:
|
||||||
return useGridVariant ? GRID_STREAM_HOLDER_TYPE : useMiniVariant
|
if (itemMode == ItemViewMode.CARD) {
|
||||||
? MINI_STREAM_HOLDER_TYPE : STREAM_HOLDER_TYPE;
|
return CARD_STREAM_HOLDER_TYPE;
|
||||||
|
} else if (itemMode == ItemViewMode.GRID) {
|
||||||
|
return GRID_STREAM_HOLDER_TYPE;
|
||||||
|
} else if (useMiniVariant) {
|
||||||
|
return MINI_STREAM_HOLDER_TYPE;
|
||||||
|
} else {
|
||||||
|
return STREAM_HOLDER_TYPE;
|
||||||
|
}
|
||||||
case CHANNEL:
|
case CHANNEL:
|
||||||
return useGridVariant ? GRID_CHANNEL_HOLDER_TYPE : useMiniVariant
|
if (itemMode == ItemViewMode.GRID) {
|
||||||
? MINI_CHANNEL_HOLDER_TYPE : CHANNEL_HOLDER_TYPE;
|
return GRID_CHANNEL_HOLDER_TYPE;
|
||||||
|
} else if (useMiniVariant) {
|
||||||
|
return MINI_CHANNEL_HOLDER_TYPE;
|
||||||
|
} else {
|
||||||
|
return CHANNEL_HOLDER_TYPE;
|
||||||
|
}
|
||||||
case PLAYLIST:
|
case PLAYLIST:
|
||||||
return useGridVariant ? GRID_PLAYLIST_HOLDER_TYPE : useMiniVariant
|
if (itemMode == ItemViewMode.CARD) {
|
||||||
? MINI_PLAYLIST_HOLDER_TYPE : PLAYLIST_HOLDER_TYPE;
|
return CARD_PLAYLIST_HOLDER_TYPE;
|
||||||
|
} else if (itemMode == ItemViewMode.GRID) {
|
||||||
|
return GRID_PLAYLIST_HOLDER_TYPE;
|
||||||
|
} else if (useMiniVariant) {
|
||||||
|
return MINI_PLAYLIST_HOLDER_TYPE;
|
||||||
|
} else {
|
||||||
|
return PLAYLIST_HOLDER_TYPE;
|
||||||
|
}
|
||||||
case COMMENT:
|
case COMMENT:
|
||||||
return useMiniVariant ? MINI_COMMENT_HOLDER_TYPE : COMMENT_HOLDER_TYPE;
|
return useMiniVariant ? MINI_COMMENT_HOLDER_TYPE : COMMENT_HOLDER_TYPE;
|
||||||
default:
|
default:
|
||||||
@ -274,6 +298,8 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
|||||||
return new StreamInfoItemHolder(infoItemBuilder, parent);
|
return new StreamInfoItemHolder(infoItemBuilder, parent);
|
||||||
case GRID_STREAM_HOLDER_TYPE:
|
case GRID_STREAM_HOLDER_TYPE:
|
||||||
return new StreamGridInfoItemHolder(infoItemBuilder, parent);
|
return new StreamGridInfoItemHolder(infoItemBuilder, parent);
|
||||||
|
case CARD_STREAM_HOLDER_TYPE:
|
||||||
|
return new StreamCardInfoItemHolder(infoItemBuilder, parent);
|
||||||
case MINI_CHANNEL_HOLDER_TYPE:
|
case MINI_CHANNEL_HOLDER_TYPE:
|
||||||
return new ChannelMiniInfoItemHolder(infoItemBuilder, parent);
|
return new ChannelMiniInfoItemHolder(infoItemBuilder, parent);
|
||||||
case CHANNEL_HOLDER_TYPE:
|
case CHANNEL_HOLDER_TYPE:
|
||||||
@ -286,6 +312,8 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
|||||||
return new PlaylistInfoItemHolder(infoItemBuilder, parent);
|
return new PlaylistInfoItemHolder(infoItemBuilder, parent);
|
||||||
case GRID_PLAYLIST_HOLDER_TYPE:
|
case GRID_PLAYLIST_HOLDER_TYPE:
|
||||||
return new PlaylistGridInfoItemHolder(infoItemBuilder, parent);
|
return new PlaylistGridInfoItemHolder(infoItemBuilder, parent);
|
||||||
|
case CARD_PLAYLIST_HOLDER_TYPE:
|
||||||
|
return new PlaylistCardInfoItemHolder(infoItemBuilder, parent);
|
||||||
case MINI_COMMENT_HOLDER_TYPE:
|
case MINI_COMMENT_HOLDER_TYPE:
|
||||||
return new CommentsMiniInfoItemHolder(infoItemBuilder, parent);
|
return new CommentsMiniInfoItemHolder(infoItemBuilder, parent);
|
||||||
case COMMENT_HOLDER_TYPE:
|
case COMMENT_HOLDER_TYPE:
|
||||||
|
@ -0,0 +1,23 @@
|
|||||||
|
package org.schabi.newpipe.info_list;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Item view mode for streams & playlist listing screens.
|
||||||
|
*/
|
||||||
|
public enum ItemViewMode {
|
||||||
|
/**
|
||||||
|
* Default mode.
|
||||||
|
*/
|
||||||
|
AUTO,
|
||||||
|
/**
|
||||||
|
* Full width list item with thumb on the left and two line title & uploader in right.
|
||||||
|
*/
|
||||||
|
LIST,
|
||||||
|
/**
|
||||||
|
* Grid mode places two cards per row.
|
||||||
|
*/
|
||||||
|
GRID,
|
||||||
|
/**
|
||||||
|
* A full width card in phone - portrait.
|
||||||
|
*/
|
||||||
|
CARD
|
||||||
|
}
|
@ -252,10 +252,11 @@ public final class InfoItemDialog {
|
|||||||
* @return the current {@link Builder} instance
|
* @return the current {@link Builder} instance
|
||||||
*/
|
*/
|
||||||
public Builder addEnqueueEntriesIfNeeded() {
|
public Builder addEnqueueEntriesIfNeeded() {
|
||||||
if (PlayerHolder.getInstance().isPlayQueueReady()) {
|
final PlayerHolder holder = PlayerHolder.getInstance();
|
||||||
|
if (holder.isPlayQueueReady()) {
|
||||||
addEntry(StreamDialogDefaultEntry.ENQUEUE);
|
addEntry(StreamDialogDefaultEntry.ENQUEUE);
|
||||||
|
|
||||||
if (PlayerHolder.getInstance().getQueueSize() > 1) {
|
if (holder.getQueuePosition() < holder.getQueueSize() - 1) {
|
||||||
addEntry(StreamDialogDefaultEntry.ENQUEUE_NEXT);
|
addEntry(StreamDialogDefaultEntry.ENQUEUE_NEXT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,9 @@
|
|||||||
package org.schabi.newpipe.info_list.holder;
|
package org.schabi.newpipe.info_list.holder;
|
||||||
|
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
import android.widget.TextView;
|
|
||||||
|
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.extractor.InfoItem;
|
|
||||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
|
||||||
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
|
||||||
import org.schabi.newpipe.util.Localization;
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Created by Christian Schabesberger on 12.02.17.
|
* Created by Christian Schabesberger on 12.02.17.
|
||||||
@ -31,40 +26,7 @@ import org.schabi.newpipe.util.Localization;
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
public class ChannelInfoItemHolder extends ChannelMiniInfoItemHolder {
|
public class ChannelInfoItemHolder extends ChannelMiniInfoItemHolder {
|
||||||
private final TextView itemChannelDescriptionView;
|
|
||||||
|
|
||||||
public ChannelInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) {
|
public ChannelInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) {
|
||||||
super(infoItemBuilder, R.layout.list_channel_item, parent);
|
super(infoItemBuilder, R.layout.list_channel_item, parent);
|
||||||
itemChannelDescriptionView = itemView.findViewById(R.id.itemChannelDescriptionView);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void updateFromItem(final InfoItem infoItem,
|
|
||||||
final HistoryRecordManager historyRecordManager) {
|
|
||||||
super.updateFromItem(infoItem, historyRecordManager);
|
|
||||||
|
|
||||||
if (!(infoItem instanceof ChannelInfoItem)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final ChannelInfoItem item = (ChannelInfoItem) infoItem;
|
|
||||||
|
|
||||||
itemChannelDescriptionView.setText(item.getDescription());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected String getDetailLine(final ChannelInfoItem item) {
|
|
||||||
String details = super.getDetailLine(item);
|
|
||||||
|
|
||||||
if (item.getStreamCount() >= 0) {
|
|
||||||
final String formattedVideoAmount = Localization.localizeStreamCount(
|
|
||||||
itemBuilder.getContext(), item.getStreamCount());
|
|
||||||
|
|
||||||
if (!details.isEmpty()) {
|
|
||||||
details += " • " + formattedVideoAmount;
|
|
||||||
} else {
|
|
||||||
details = formattedVideoAmount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return details;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,21 +1,26 @@
|
|||||||
package org.schabi.newpipe.info_list.holder;
|
package org.schabi.newpipe.info_list.holder;
|
||||||
|
|
||||||
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
import android.widget.ImageView;
|
import android.widget.ImageView;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.extractor.InfoItem;
|
import org.schabi.newpipe.extractor.InfoItem;
|
||||||
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
|
||||||
|
import org.schabi.newpipe.extractor.utils.Utils;
|
||||||
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||||
import org.schabi.newpipe.util.PicassoHelper;
|
import org.schabi.newpipe.util.PicassoHelper;
|
||||||
import org.schabi.newpipe.util.Localization;
|
import org.schabi.newpipe.util.Localization;
|
||||||
|
|
||||||
public class ChannelMiniInfoItemHolder extends InfoItemHolder {
|
public class ChannelMiniInfoItemHolder extends InfoItemHolder {
|
||||||
public final ImageView itemThumbnailView;
|
private final ImageView itemThumbnailView;
|
||||||
public final TextView itemTitleView;
|
private final TextView itemTitleView;
|
||||||
private final TextView itemAdditionalDetailView;
|
private final TextView itemAdditionalDetailView;
|
||||||
|
private final TextView itemChannelDescriptionView;
|
||||||
|
|
||||||
ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId,
|
ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId,
|
||||||
final ViewGroup parent) {
|
final ViewGroup parent) {
|
||||||
@ -24,6 +29,7 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder {
|
|||||||
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
|
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
|
||||||
itemTitleView = itemView.findViewById(R.id.itemTitleView);
|
itemTitleView = itemView.findViewById(R.id.itemTitleView);
|
||||||
itemAdditionalDetailView = itemView.findViewById(R.id.itemAdditionalDetails);
|
itemAdditionalDetailView = itemView.findViewById(R.id.itemAdditionalDetails);
|
||||||
|
itemChannelDescriptionView = itemView.findViewById(R.id.itemChannelDescriptionView);
|
||||||
}
|
}
|
||||||
|
|
||||||
public ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder,
|
public ChannelMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder,
|
||||||
@ -40,7 +46,14 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder {
|
|||||||
final ChannelInfoItem item = (ChannelInfoItem) infoItem;
|
final ChannelInfoItem item = (ChannelInfoItem) infoItem;
|
||||||
|
|
||||||
itemTitleView.setText(item.getName());
|
itemTitleView.setText(item.getName());
|
||||||
|
|
||||||
|
final String detailLine = getDetailLine(item);
|
||||||
|
if (detailLine == null) {
|
||||||
|
itemAdditionalDetailView.setVisibility(View.GONE);
|
||||||
|
} else {
|
||||||
|
itemAdditionalDetailView.setVisibility(View.VISIBLE);
|
||||||
itemAdditionalDetailView.setText(getDetailLine(item));
|
itemAdditionalDetailView.setText(getDetailLine(item));
|
||||||
|
}
|
||||||
|
|
||||||
PicassoHelper.loadAvatar(item.getThumbnailUrl()).into(itemThumbnailView);
|
PicassoHelper.loadAvatar(item.getThumbnailUrl()).into(itemThumbnailView);
|
||||||
|
|
||||||
@ -56,14 +69,35 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder {
|
|||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (itemChannelDescriptionView != null) {
|
||||||
|
// itemChannelDescriptionView will be null in the mini variant
|
||||||
|
if (Utils.isBlank(item.getDescription())) {
|
||||||
|
itemChannelDescriptionView.setVisibility(View.GONE);
|
||||||
|
} else {
|
||||||
|
itemChannelDescriptionView.setVisibility(View.VISIBLE);
|
||||||
|
itemChannelDescriptionView.setText(item.getDescription());
|
||||||
|
itemChannelDescriptionView.setMaxLines(detailLine == null ? 3 : 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected String getDetailLine(final ChannelInfoItem item) {
|
@Nullable
|
||||||
String details = "";
|
private String getDetailLine(final ChannelInfoItem item) {
|
||||||
if (item.getSubscriberCount() >= 0) {
|
if (item.getStreamCount() >= 0 && item.getSubscriberCount() >= 0) {
|
||||||
details += Localization.shortSubscriberCount(itemBuilder.getContext(),
|
return Localization.concatenateStrings(
|
||||||
|
Localization.shortSubscriberCount(itemBuilder.getContext(),
|
||||||
|
item.getSubscriberCount()),
|
||||||
|
Localization.localizeStreamCount(itemBuilder.getContext(),
|
||||||
|
item.getStreamCount()));
|
||||||
|
} else if (item.getStreamCount() >= 0) {
|
||||||
|
return Localization.localizeStreamCount(itemBuilder.getContext(),
|
||||||
|
item.getStreamCount());
|
||||||
|
} else if (item.getSubscriberCount() >= 0) {
|
||||||
|
return Localization.shortSubscriberCount(itemBuilder.getContext(),
|
||||||
item.getSubscriberCount());
|
item.getSubscriberCount());
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return details;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
package org.schabi.newpipe.info_list.holder;
|
package org.schabi.newpipe.info_list.holder;
|
||||||
|
|
||||||
|
import android.graphics.Paint;
|
||||||
|
import android.text.Layout;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.text.method.LinkMovementMethod;
|
import android.text.method.LinkMovementMethod;
|
||||||
import android.text.style.URLSpan;
|
import android.text.style.URLSpan;
|
||||||
import android.text.util.Linkify;
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
@ -11,27 +12,36 @@ import android.widget.ImageView;
|
|||||||
import android.widget.RelativeLayout;
|
import android.widget.RelativeLayout;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
import androidx.core.text.util.LinkifyCompat;
|
import androidx.core.text.HtmlCompat;
|
||||||
|
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.error.ErrorUtil;
|
import org.schabi.newpipe.error.ErrorUtil;
|
||||||
import org.schabi.newpipe.extractor.InfoItem;
|
import org.schabi.newpipe.extractor.InfoItem;
|
||||||
|
import org.schabi.newpipe.extractor.NewPipe;
|
||||||
|
import org.schabi.newpipe.extractor.ServiceList;
|
||||||
|
import org.schabi.newpipe.extractor.StreamingService;
|
||||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||||
|
import org.schabi.newpipe.extractor.stream.Description;
|
||||||
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||||
import org.schabi.newpipe.util.CommentTextOnTouchListener;
|
|
||||||
import org.schabi.newpipe.util.DeviceUtils;
|
import org.schabi.newpipe.util.DeviceUtils;
|
||||||
import org.schabi.newpipe.util.Localization;
|
import org.schabi.newpipe.util.Localization;
|
||||||
import org.schabi.newpipe.util.NavigationHelper;
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
import org.schabi.newpipe.util.PicassoHelper;
|
import org.schabi.newpipe.util.PicassoHelper;
|
||||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||||
import org.schabi.newpipe.util.external_communication.TimestampExtractor;
|
import org.schabi.newpipe.util.text.CommentTextOnTouchListener;
|
||||||
|
import org.schabi.newpipe.util.text.TextLinkifier;
|
||||||
|
|
||||||
import java.util.Objects;
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||||
|
|
||||||
public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
||||||
private static final String TAG = "CommentsMiniIIHolder";
|
private static final String TAG = "CommentsMiniIIHolder";
|
||||||
|
private static final String ELLIPSIS = "…";
|
||||||
|
|
||||||
private static final int COMMENT_DEFAULT_LINES = 2;
|
private static final int COMMENT_DEFAULT_LINES = 2;
|
||||||
private static final int COMMENT_EXPANDED_LINES = 1000;
|
private static final int COMMENT_EXPANDED_LINES = 1000;
|
||||||
@ -39,13 +49,18 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
|||||||
private final int commentHorizontalPadding;
|
private final int commentHorizontalPadding;
|
||||||
private final int commentVerticalPadding;
|
private final int commentVerticalPadding;
|
||||||
|
|
||||||
|
private final Paint paintAtContentSize;
|
||||||
|
private final float ellipsisWidthPx;
|
||||||
|
|
||||||
private final RelativeLayout itemRoot;
|
private final RelativeLayout itemRoot;
|
||||||
private final ImageView itemThumbnailView;
|
private final ImageView itemThumbnailView;
|
||||||
private final TextView itemContentView;
|
private final TextView itemContentView;
|
||||||
private final TextView itemLikesCountView;
|
private final TextView itemLikesCountView;
|
||||||
private final TextView itemPublishedTime;
|
private final TextView itemPublishedTime;
|
||||||
|
|
||||||
private String commentText;
|
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||||
|
private Description commentText;
|
||||||
|
private StreamingService streamService;
|
||||||
private String streamUrl;
|
private String streamUrl;
|
||||||
|
|
||||||
CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId,
|
CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId,
|
||||||
@ -62,6 +77,10 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
|||||||
.getResources().getDimension(R.dimen.comments_horizontal_padding);
|
.getResources().getDimension(R.dimen.comments_horizontal_padding);
|
||||||
commentVerticalPadding = (int) infoItemBuilder.getContext()
|
commentVerticalPadding = (int) infoItemBuilder.getContext()
|
||||||
.getResources().getDimension(R.dimen.comments_vertical_padding);
|
.getResources().getDimension(R.dimen.comments_vertical_padding);
|
||||||
|
|
||||||
|
paintAtContentSize = new Paint();
|
||||||
|
paintAtContentSize.setTextSize(itemContentView.getTextSize());
|
||||||
|
ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS);
|
||||||
}
|
}
|
||||||
|
|
||||||
public CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder,
|
public CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder,
|
||||||
@ -91,18 +110,20 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
|||||||
|
|
||||||
itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item));
|
itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item));
|
||||||
|
|
||||||
streamUrl = item.getUrl();
|
try {
|
||||||
|
streamService = NewPipe.getService(item.getServiceId());
|
||||||
itemContentView.setLines(COMMENT_DEFAULT_LINES);
|
} catch (final ExtractionException e) {
|
||||||
commentText = item.getCommentText();
|
// should never happen
|
||||||
itemContentView.setText(commentText, TextView.BufferType.SPANNABLE);
|
ErrorUtil.showUiErrorSnackbar(itemBuilder.getContext(), "Getting StreamingService", e);
|
||||||
itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE);
|
Log.w(TAG, "Cannot obtain service from comment service id, defaulting to YouTube", e);
|
||||||
|
streamService = ServiceList.YouTube;
|
||||||
if (itemContentView.getLineCount() == 0) {
|
|
||||||
itemContentView.post(this::ellipsize);
|
|
||||||
} else {
|
|
||||||
ellipsize();
|
|
||||||
}
|
}
|
||||||
|
streamUrl = item.getUrl();
|
||||||
|
commentText = item.getCommentText();
|
||||||
|
ellipsize();
|
||||||
|
|
||||||
|
//noinspection ClickableViewAccessibility
|
||||||
|
itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE);
|
||||||
|
|
||||||
if (item.getLikeCount() >= 0) {
|
if (item.getLikeCount() >= 0) {
|
||||||
itemLikesCountView.setText(
|
itemLikesCountView.setText(
|
||||||
@ -132,7 +153,8 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
|||||||
if (DeviceUtils.isTv(itemBuilder.getContext())) {
|
if (DeviceUtils.isTv(itemBuilder.getContext())) {
|
||||||
openCommentAuthor(item);
|
openCommentAuthor(item);
|
||||||
} else {
|
} else {
|
||||||
ShareUtils.copyToClipboard(itemBuilder.getContext(), commentText);
|
ShareUtils.copyToClipboard(itemBuilder.getContext(),
|
||||||
|
itemContentView.getText().toString());
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
@ -172,7 +194,7 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
|||||||
return urls != null && urls.length != 0;
|
return urls != null && urls.length != 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void determineLinkFocus() {
|
private void determineMovementMethod() {
|
||||||
if (shouldFocusLinks()) {
|
if (shouldFocusLinks()) {
|
||||||
allowLinkFocus();
|
allowLinkFocus();
|
||||||
} else {
|
} else {
|
||||||
@ -181,63 +203,73 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void ellipsize() {
|
private void ellipsize() {
|
||||||
|
itemContentView.setMaxLines(COMMENT_EXPANDED_LINES);
|
||||||
|
linkifyCommentContentView(v -> {
|
||||||
boolean hasEllipsis = false;
|
boolean hasEllipsis = false;
|
||||||
|
|
||||||
if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
|
if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
|
||||||
final int endOfLastLine = itemContentView
|
// Note that converting to String removes spans (i.e. links), but that's something
|
||||||
.getLayout()
|
// we actually want since when the text is ellipsized we want all clicks on the
|
||||||
.getLineEnd(COMMENT_DEFAULT_LINES - 1);
|
// comment to expand the comment, not to open links.
|
||||||
int end = itemContentView.getText().toString().lastIndexOf(' ', endOfLastLine - 2);
|
final String text = itemContentView.getText().toString();
|
||||||
if (end == -1) {
|
|
||||||
end = Math.max(endOfLastLine - 2, 0);
|
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));
|
||||||
}
|
}
|
||||||
final String newVal = itemContentView.getText().subSequence(0, end) + " …";
|
|
||||||
|
// 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);
|
itemContentView.setText(newVal);
|
||||||
hasEllipsis = true;
|
hasEllipsis = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
linkify();
|
itemContentView.setMaxLines(COMMENT_DEFAULT_LINES);
|
||||||
|
|
||||||
if (hasEllipsis) {
|
if (hasEllipsis) {
|
||||||
denyLinkFocus();
|
denyLinkFocus();
|
||||||
} else {
|
} else {
|
||||||
determineLinkFocus();
|
determineMovementMethod();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private void toggleEllipsize() {
|
private void toggleEllipsize() {
|
||||||
if (itemContentView.getText().toString().equals(commentText)) {
|
final CharSequence text = itemContentView.getText();
|
||||||
if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
|
if (text.charAt(text.length() - 1) == ELLIPSIS.charAt(0)) {
|
||||||
ellipsize();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
expand();
|
expand();
|
||||||
|
} else if (itemContentView.getLineCount() > COMMENT_DEFAULT_LINES) {
|
||||||
|
ellipsize();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void expand() {
|
private void expand() {
|
||||||
itemContentView.setMaxLines(COMMENT_EXPANDED_LINES);
|
itemContentView.setMaxLines(COMMENT_EXPANDED_LINES);
|
||||||
itemContentView.setText(commentText);
|
linkifyCommentContentView(v -> determineMovementMethod());
|
||||||
linkify();
|
|
||||||
determineLinkFocus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void linkify() {
|
private void linkifyCommentContentView(@Nullable final Consumer<TextView> onCompletion) {
|
||||||
LinkifyCompat.addLinks(itemContentView, Linkify.WEB_URLS);
|
disposables.clear();
|
||||||
LinkifyCompat.addLinks(itemContentView, TimestampExtractor.TIMESTAMPS_PATTERN, null, null,
|
if (commentText != null) {
|
||||||
(match, url) -> {
|
TextLinkifier.fromDescription(itemContentView, commentText,
|
||||||
try {
|
HtmlCompat.FROM_HTML_MODE_LEGACY, streamService, streamUrl, disposables,
|
||||||
final var timestampMatch = TimestampExtractor
|
onCompletion);
|
||||||
.getTimestampFromMatcher(match, commentText);
|
|
||||||
if (timestampMatch == null) {
|
|
||||||
return url;
|
|
||||||
}
|
}
|
||||||
return streamUrl + url.replace(Objects.requireNonNull(match.group(0)),
|
|
||||||
"#timestamp=" + timestampMatch.seconds());
|
|
||||||
} catch (final Exception ex) {
|
|
||||||
Log.e(TAG, "Unable to process url='" + url + "' as timestampLink", ex);
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,17 @@
|
|||||||
|
package org.schabi.newpipe.info_list.holder;
|
||||||
|
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playlist card layout.
|
||||||
|
*/
|
||||||
|
public class PlaylistCardInfoItemHolder extends PlaylistMiniInfoItemHolder {
|
||||||
|
|
||||||
|
public PlaylistCardInfoItemHolder(final InfoItemBuilder infoItemBuilder,
|
||||||
|
final ViewGroup parent) {
|
||||||
|
super(infoItemBuilder, R.layout.list_playlist_card_item, parent);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
package org.schabi.newpipe.info_list.holder;
|
||||||
|
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card layout for stream.
|
||||||
|
*/
|
||||||
|
public class StreamCardInfoItemHolder extends StreamInfoItemHolder {
|
||||||
|
|
||||||
|
public StreamCardInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) {
|
||||||
|
super(infoItemBuilder, R.layout.list_stream_card_item, parent);
|
||||||
|
}
|
||||||
|
}
|
@ -22,10 +22,11 @@ import org.schabi.newpipe.R;
|
|||||||
import org.schabi.newpipe.databinding.PignateFooterBinding;
|
import org.schabi.newpipe.databinding.PignateFooterBinding;
|
||||||
import org.schabi.newpipe.fragments.BaseStateFragment;
|
import org.schabi.newpipe.fragments.BaseStateFragment;
|
||||||
import org.schabi.newpipe.fragments.list.ListViewContract;
|
import org.schabi.newpipe.fragments.list.ListViewContract;
|
||||||
|
import org.schabi.newpipe.info_list.ItemViewMode;
|
||||||
|
|
||||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||||
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
|
import static org.schabi.newpipe.ktx.ViewUtils.animateHideRecyclerViewAllowingScrolling;
|
||||||
import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
|
import static org.schabi.newpipe.util.ThemeHelper.getItemViewMode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This fragment is design to be used with persistent data such as
|
* This fragment is design to be used with persistent data such as
|
||||||
@ -77,16 +78,23 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
|
|||||||
super.onResume();
|
super.onResume();
|
||||||
if (updateFlags != 0) {
|
if (updateFlags != 0) {
|
||||||
if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) {
|
if ((updateFlags & LIST_MODE_UPDATE_FLAG) != 0) {
|
||||||
final boolean useGrid = shouldUseGridLayout(requireContext());
|
refreshItemViewMode();
|
||||||
itemsList.setLayoutManager(
|
|
||||||
useGrid ? getGridLayoutManager() : getListLayoutManager());
|
|
||||||
itemListAdapter.setUseGridVariant(useGrid);
|
|
||||||
itemListAdapter.notifyDataSetChanged();
|
|
||||||
}
|
}
|
||||||
updateFlags = 0;
|
updateFlags = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the item view mode based on user preference.
|
||||||
|
*/
|
||||||
|
private void refreshItemViewMode() {
|
||||||
|
final ItemViewMode itemViewMode = getItemViewMode(requireContext());
|
||||||
|
itemsList.setLayoutManager((itemViewMode == ItemViewMode.GRID)
|
||||||
|
? getGridLayoutManager() : getListLayoutManager());
|
||||||
|
itemListAdapter.setItemViewMode(itemViewMode);
|
||||||
|
itemListAdapter.notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
// Lifecycle - View
|
// Lifecycle - View
|
||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
@ -104,8 +112,7 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
|
|||||||
final Resources resources = activity.getResources();
|
final Resources resources = activity.getResources();
|
||||||
int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width);
|
int width = resources.getDimensionPixelSize(R.dimen.video_item_grid_thumbnail_image_width);
|
||||||
width += (24 * resources.getDisplayMetrics().density);
|
width += (24 * resources.getDisplayMetrics().density);
|
||||||
final int spanCount = (int) Math.floor(resources.getDisplayMetrics().widthPixels
|
final int spanCount = Math.floorDiv(resources.getDisplayMetrics().widthPixels, width);
|
||||||
/ (double) width);
|
|
||||||
final GridLayoutManager lm = new GridLayoutManager(activity, spanCount);
|
final GridLayoutManager lm = new GridLayoutManager(activity, spanCount);
|
||||||
lm.setSpanSizeLookup(itemListAdapter.getSpanSizeLookup(spanCount));
|
lm.setSpanSizeLookup(itemListAdapter.getSpanSizeLookup(spanCount));
|
||||||
return lm;
|
return lm;
|
||||||
@ -121,11 +128,9 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
|
|||||||
|
|
||||||
itemListAdapter = new LocalItemListAdapter(activity);
|
itemListAdapter = new LocalItemListAdapter(activity);
|
||||||
|
|
||||||
final boolean useGrid = shouldUseGridLayout(requireContext());
|
|
||||||
itemsList = rootView.findViewById(R.id.items_list);
|
itemsList = rootView.findViewById(R.id.items_list);
|
||||||
itemsList.setLayoutManager(useGrid ? getGridLayoutManager() : getListLayoutManager());
|
refreshItemViewMode();
|
||||||
|
|
||||||
itemListAdapter.setUseGridVariant(useGrid);
|
|
||||||
headerRootBinding = getListHeader();
|
headerRootBinding = getListHeader();
|
||||||
if (headerRootBinding != null) {
|
if (headerRootBinding != null) {
|
||||||
itemListAdapter.setHeader(headerRootBinding.getRoot());
|
itemListAdapter.setHeader(headerRootBinding.getRoot());
|
||||||
@ -256,7 +261,7 @@ public abstract class BaseLocalListFragment<I, N> extends BaseStateFragment<I>
|
|||||||
@Override
|
@Override
|
||||||
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
|
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
|
||||||
final String key) {
|
final String key) {
|
||||||
if (key.equals(getString(R.string.list_view_mode_key))) {
|
if (getString(R.string.list_view_mode_key).equals(key)) {
|
||||||
updateFlags |= LIST_MODE_UPDATE_FLAG;
|
updateFlags |= LIST_MODE_UPDATE_FLAG;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -12,14 +12,19 @@ import androidx.recyclerview.widget.RecyclerView;
|
|||||||
|
|
||||||
import org.schabi.newpipe.database.LocalItem;
|
import org.schabi.newpipe.database.LocalItem;
|
||||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity;
|
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.history.HistoryRecordManager;
|
||||||
import org.schabi.newpipe.local.holder.LocalItemHolder;
|
import org.schabi.newpipe.local.holder.LocalItemHolder;
|
||||||
|
import org.schabi.newpipe.local.holder.LocalPlaylistCardItemHolder;
|
||||||
import org.schabi.newpipe.local.holder.LocalPlaylistGridItemHolder;
|
import org.schabi.newpipe.local.holder.LocalPlaylistGridItemHolder;
|
||||||
import org.schabi.newpipe.local.holder.LocalPlaylistItemHolder;
|
import org.schabi.newpipe.local.holder.LocalPlaylistItemHolder;
|
||||||
|
import org.schabi.newpipe.local.holder.LocalPlaylistStreamCardItemHolder;
|
||||||
import org.schabi.newpipe.local.holder.LocalPlaylistStreamGridItemHolder;
|
import org.schabi.newpipe.local.holder.LocalPlaylistStreamGridItemHolder;
|
||||||
import org.schabi.newpipe.local.holder.LocalPlaylistStreamItemHolder;
|
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.LocalStatisticStreamGridItemHolder;
|
||||||
import org.schabi.newpipe.local.holder.LocalStatisticStreamItemHolder;
|
import org.schabi.newpipe.local.holder.LocalStatisticStreamItemHolder;
|
||||||
|
import org.schabi.newpipe.local.holder.RemotePlaylistCardItemHolder;
|
||||||
import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder;
|
import org.schabi.newpipe.local.holder.RemotePlaylistGridItemHolder;
|
||||||
import org.schabi.newpipe.local.holder.RemotePlaylistItemHolder;
|
import org.schabi.newpipe.local.holder.RemotePlaylistItemHolder;
|
||||||
import org.schabi.newpipe.util.FallbackViewHolder;
|
import org.schabi.newpipe.util.FallbackViewHolder;
|
||||||
@ -61,11 +66,17 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
|||||||
private static final int STREAM_STATISTICS_HOLDER_TYPE = 0x1000;
|
private static final int STREAM_STATISTICS_HOLDER_TYPE = 0x1000;
|
||||||
private static final int STREAM_PLAYLIST_HOLDER_TYPE = 0x1001;
|
private static final int STREAM_PLAYLIST_HOLDER_TYPE = 0x1001;
|
||||||
private static final int STREAM_STATISTICS_GRID_HOLDER_TYPE = 0x1002;
|
private static final int STREAM_STATISTICS_GRID_HOLDER_TYPE = 0x1002;
|
||||||
|
private static final int STREAM_STATISTICS_CARD_HOLDER_TYPE = 0x1003;
|
||||||
private static final int STREAM_PLAYLIST_GRID_HOLDER_TYPE = 0x1004;
|
private static final int STREAM_PLAYLIST_GRID_HOLDER_TYPE = 0x1004;
|
||||||
|
private static final int STREAM_PLAYLIST_CARD_HOLDER_TYPE = 0x1005;
|
||||||
|
|
||||||
private static final int LOCAL_PLAYLIST_HOLDER_TYPE = 0x2000;
|
private static final int LOCAL_PLAYLIST_HOLDER_TYPE = 0x2000;
|
||||||
private static final int REMOTE_PLAYLIST_HOLDER_TYPE = 0x2001;
|
private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2001;
|
||||||
private static final int LOCAL_PLAYLIST_GRID_HOLDER_TYPE = 0x2002;
|
private static final int LOCAL_PLAYLIST_CARD_HOLDER_TYPE = 0x2002;
|
||||||
private static final int REMOTE_PLAYLIST_GRID_HOLDER_TYPE = 0x2004;
|
|
||||||
|
private static final int REMOTE_PLAYLIST_HOLDER_TYPE = 0x3000;
|
||||||
|
private static final int REMOTE_PLAYLIST_GRID_HOLDER_TYPE = 0x3001;
|
||||||
|
private static final int REMOTE_PLAYLIST_CARD_HOLDER_TYPE = 0x3002;
|
||||||
|
|
||||||
private final LocalItemBuilder localItemBuilder;
|
private final LocalItemBuilder localItemBuilder;
|
||||||
private final ArrayList<LocalItem> localItems;
|
private final ArrayList<LocalItem> localItems;
|
||||||
@ -73,9 +84,9 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
|||||||
private final DateTimeFormatter dateTimeFormatter;
|
private final DateTimeFormatter dateTimeFormatter;
|
||||||
|
|
||||||
private boolean showFooter = false;
|
private boolean showFooter = false;
|
||||||
private boolean useGridVariant = false;
|
|
||||||
private View header = null;
|
private View header = null;
|
||||||
private View footer = null;
|
private View footer = null;
|
||||||
|
private ItemViewMode itemViewMode = ItemViewMode.LIST;
|
||||||
|
|
||||||
public LocalItemListAdapter(final Context context) {
|
public LocalItemListAdapter(final Context context) {
|
||||||
recordManager = new HistoryRecordManager(context);
|
recordManager = new HistoryRecordManager(context);
|
||||||
@ -165,8 +176,8 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
|||||||
notifyDataSetChanged();
|
notifyDataSetChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setUseGridVariant(final boolean useGridVariant) {
|
public void setItemViewMode(final ItemViewMode itemViewMode) {
|
||||||
this.useGridVariant = useGridVariant;
|
this.itemViewMode = itemViewMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setHeader(final View header) {
|
public void setHeader(final View header) {
|
||||||
@ -244,21 +255,39 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
|||||||
return FOOTER_TYPE;
|
return FOOTER_TYPE;
|
||||||
}
|
}
|
||||||
final LocalItem item = localItems.get(position);
|
final LocalItem item = localItems.get(position);
|
||||||
|
|
||||||
switch (item.getLocalItemType()) {
|
switch (item.getLocalItemType()) {
|
||||||
case PLAYLIST_LOCAL_ITEM:
|
case PLAYLIST_LOCAL_ITEM:
|
||||||
return useGridVariant
|
if (itemViewMode == ItemViewMode.CARD) {
|
||||||
? LOCAL_PLAYLIST_GRID_HOLDER_TYPE : LOCAL_PLAYLIST_HOLDER_TYPE;
|
return LOCAL_PLAYLIST_CARD_HOLDER_TYPE;
|
||||||
|
} else if (itemViewMode == ItemViewMode.GRID) {
|
||||||
|
return LOCAL_PLAYLIST_GRID_HOLDER_TYPE;
|
||||||
|
} else {
|
||||||
|
return LOCAL_PLAYLIST_HOLDER_TYPE;
|
||||||
|
}
|
||||||
case PLAYLIST_REMOTE_ITEM:
|
case PLAYLIST_REMOTE_ITEM:
|
||||||
return useGridVariant
|
if (itemViewMode == ItemViewMode.CARD) {
|
||||||
? REMOTE_PLAYLIST_GRID_HOLDER_TYPE : REMOTE_PLAYLIST_HOLDER_TYPE;
|
return REMOTE_PLAYLIST_CARD_HOLDER_TYPE;
|
||||||
|
} else if (itemViewMode == ItemViewMode.GRID) {
|
||||||
|
return REMOTE_PLAYLIST_GRID_HOLDER_TYPE;
|
||||||
|
} else {
|
||||||
|
return REMOTE_PLAYLIST_HOLDER_TYPE;
|
||||||
|
}
|
||||||
case PLAYLIST_STREAM_ITEM:
|
case PLAYLIST_STREAM_ITEM:
|
||||||
return useGridVariant
|
if (itemViewMode == ItemViewMode.CARD) {
|
||||||
? STREAM_PLAYLIST_GRID_HOLDER_TYPE : STREAM_PLAYLIST_HOLDER_TYPE;
|
return STREAM_PLAYLIST_CARD_HOLDER_TYPE;
|
||||||
|
} else if (itemViewMode == ItemViewMode.GRID) {
|
||||||
|
return STREAM_PLAYLIST_GRID_HOLDER_TYPE;
|
||||||
|
} else {
|
||||||
|
return STREAM_PLAYLIST_HOLDER_TYPE;
|
||||||
|
}
|
||||||
case STATISTIC_STREAM_ITEM:
|
case STATISTIC_STREAM_ITEM:
|
||||||
return useGridVariant
|
if (itemViewMode == ItemViewMode.CARD) {
|
||||||
? STREAM_STATISTICS_GRID_HOLDER_TYPE : STREAM_STATISTICS_HOLDER_TYPE;
|
return STREAM_STATISTICS_CARD_HOLDER_TYPE;
|
||||||
|
} else if (itemViewMode == ItemViewMode.GRID) {
|
||||||
|
return STREAM_STATISTICS_GRID_HOLDER_TYPE;
|
||||||
|
} else {
|
||||||
|
return STREAM_STATISTICS_HOLDER_TYPE;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
Log.e(TAG, "No holder type has been considered for item: ["
|
Log.e(TAG, "No holder type has been considered for item: ["
|
||||||
+ item.getLocalItemType() + "]");
|
+ item.getLocalItemType() + "]");
|
||||||
@ -283,18 +312,26 @@ public class LocalItemListAdapter extends RecyclerView.Adapter<RecyclerView.View
|
|||||||
return new LocalPlaylistItemHolder(localItemBuilder, parent);
|
return new LocalPlaylistItemHolder(localItemBuilder, parent);
|
||||||
case LOCAL_PLAYLIST_GRID_HOLDER_TYPE:
|
case LOCAL_PLAYLIST_GRID_HOLDER_TYPE:
|
||||||
return new LocalPlaylistGridItemHolder(localItemBuilder, parent);
|
return new LocalPlaylistGridItemHolder(localItemBuilder, parent);
|
||||||
|
case LOCAL_PLAYLIST_CARD_HOLDER_TYPE:
|
||||||
|
return new LocalPlaylistCardItemHolder(localItemBuilder, parent);
|
||||||
case REMOTE_PLAYLIST_HOLDER_TYPE:
|
case REMOTE_PLAYLIST_HOLDER_TYPE:
|
||||||
return new RemotePlaylistItemHolder(localItemBuilder, parent);
|
return new RemotePlaylistItemHolder(localItemBuilder, parent);
|
||||||
case REMOTE_PLAYLIST_GRID_HOLDER_TYPE:
|
case REMOTE_PLAYLIST_GRID_HOLDER_TYPE:
|
||||||
return new RemotePlaylistGridItemHolder(localItemBuilder, parent);
|
return new RemotePlaylistGridItemHolder(localItemBuilder, parent);
|
||||||
|
case REMOTE_PLAYLIST_CARD_HOLDER_TYPE:
|
||||||
|
return new RemotePlaylistCardItemHolder(localItemBuilder, parent);
|
||||||
case STREAM_PLAYLIST_HOLDER_TYPE:
|
case STREAM_PLAYLIST_HOLDER_TYPE:
|
||||||
return new LocalPlaylistStreamItemHolder(localItemBuilder, parent);
|
return new LocalPlaylistStreamItemHolder(localItemBuilder, parent);
|
||||||
case STREAM_PLAYLIST_GRID_HOLDER_TYPE:
|
case STREAM_PLAYLIST_GRID_HOLDER_TYPE:
|
||||||
return new LocalPlaylistStreamGridItemHolder(localItemBuilder, parent);
|
return new LocalPlaylistStreamGridItemHolder(localItemBuilder, parent);
|
||||||
|
case STREAM_PLAYLIST_CARD_HOLDER_TYPE:
|
||||||
|
return new LocalPlaylistStreamCardItemHolder(localItemBuilder, parent);
|
||||||
case STREAM_STATISTICS_HOLDER_TYPE:
|
case STREAM_STATISTICS_HOLDER_TYPE:
|
||||||
return new LocalStatisticStreamItemHolder(localItemBuilder, parent);
|
return new LocalStatisticStreamItemHolder(localItemBuilder, parent);
|
||||||
case STREAM_STATISTICS_GRID_HOLDER_TYPE:
|
case STREAM_STATISTICS_GRID_HOLDER_TYPE:
|
||||||
return new LocalStatisticStreamGridItemHolder(localItemBuilder, parent);
|
return new LocalStatisticStreamGridItemHolder(localItemBuilder, parent);
|
||||||
|
case STREAM_STATISTICS_CARD_HOLDER_TYPE:
|
||||||
|
return new LocalStatisticStreamCardItemHolder(localItemBuilder, parent);
|
||||||
default:
|
default:
|
||||||
Log.e(TAG, "No view type has been considered for holder: [" + type + "]");
|
Log.e(TAG, "No view type has been considered for holder: [" + type + "]");
|
||||||
return new FallbackViewHolder(new View(parent.getContext()));
|
return new FallbackViewHolder(new View(parent.getContext()));
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package org.schabi.newpipe.local.bookmark;
|
package org.schabi.newpipe.local.bookmark;
|
||||||
|
|
||||||
|
import android.content.DialogInterface;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Parcelable;
|
import android.os.Parcelable;
|
||||||
import android.text.InputType;
|
import android.text.InputType;
|
||||||
@ -31,6 +32,7 @@ import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
|
|||||||
import org.schabi.newpipe.util.NavigationHelper;
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
import org.schabi.newpipe.util.OnClickGesture;
|
import org.schabi.newpipe.util.OnClickGesture;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import icepick.State;
|
import icepick.State;
|
||||||
@ -256,6 +258,41 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
|
private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
|
||||||
|
final String rename = getString(R.string.rename);
|
||||||
|
final String delete = getString(R.string.delete);
|
||||||
|
final String unsetThumbnail = getString(R.string.unset_playlist_thumbnail);
|
||||||
|
final boolean isThumbnailPermanent = localPlaylistManager
|
||||||
|
.getIsPlaylistThumbnailPermanent(selectedItem.uid);
|
||||||
|
|
||||||
|
final AlertDialog.Builder builder = new AlertDialog.Builder(activity);
|
||||||
|
|
||||||
|
final ArrayList<String> items = new ArrayList<>();
|
||||||
|
items.add(rename);
|
||||||
|
items.add(delete);
|
||||||
|
if (isThumbnailPermanent) {
|
||||||
|
items.add(unsetThumbnail);
|
||||||
|
}
|
||||||
|
|
||||||
|
final DialogInterface.OnClickListener action = (d, index) -> {
|
||||||
|
if (items.get(index).equals(rename)) {
|
||||||
|
showRenameDialog(selectedItem);
|
||||||
|
} else if (items.get(index).equals(delete)) {
|
||||||
|
showDeleteDialog(selectedItem.name,
|
||||||
|
localPlaylistManager.deletePlaylist(selectedItem.uid));
|
||||||
|
} else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) {
|
||||||
|
final String thumbnailUrl = localPlaylistManager
|
||||||
|
.getAutomaticPlaylistThumbnail(selectedItem.uid);
|
||||||
|
localPlaylistManager
|
||||||
|
.changePlaylistThumbnail(selectedItem.uid, thumbnailUrl, false)
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
builder.setItems(items.toArray(new String[0]), action).create().show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void showRenameDialog(final PlaylistMetadataEntry selectedItem) {
|
||||||
final DialogEditTextBinding dialogBinding =
|
final DialogEditTextBinding dialogBinding =
|
||||||
DialogEditTextBinding.inflate(getLayoutInflater());
|
DialogEditTextBinding.inflate(getLayoutInflater());
|
||||||
dialogBinding.dialogEditText.setHint(R.string.name);
|
dialogBinding.dialogEditText.setHint(R.string.name);
|
||||||
@ -269,11 +306,6 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
|||||||
selectedItem.uid,
|
selectedItem.uid,
|
||||||
dialogBinding.dialogEditText.getText().toString()))
|
dialogBinding.dialogEditText.getText().toString()))
|
||||||
.setNegativeButton(R.string.cancel, null)
|
.setNegativeButton(R.string.cancel, null)
|
||||||
.setNeutralButton(R.string.delete, (dialog, which) -> {
|
|
||||||
showDeleteDialog(selectedItem.name,
|
|
||||||
localPlaylistManager.deletePlaylist(selectedItem.uid));
|
|
||||||
dialog.dismiss();
|
|
||||||
})
|
|
||||||
.create()
|
.create()
|
||||||
.show();
|
.show();
|
||||||
}
|
}
|
||||||
|
@ -134,7 +134,7 @@ public final class PlaylistAppendDialog extends PlaylistDialog {
|
|||||||
if (playlist.thumbnailUrl
|
if (playlist.thumbnailUrl
|
||||||
.equals("drawable://" + R.drawable.placeholder_thumbnail_playlist)) {
|
.equals("drawable://" + R.drawable.placeholder_thumbnail_playlist)) {
|
||||||
playlistDisposables.add(manager
|
playlistDisposables.add(manager
|
||||||
.changePlaylistThumbnail(playlist.uid, streams.get(0).getThumbnailUrl())
|
.changePlaylistThumbnail(playlist.uid, streams.get(0).getThumbnailUrl(), false)
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(ignored -> successToast.show()));
|
.subscribe(ignored -> successToast.show()));
|
||||||
}
|
}
|
||||||
|
@ -36,7 +36,6 @@ import android.view.MenuItem
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.Button
|
import android.widget.Button
|
||||||
import androidx.annotation.Nullable
|
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.appcompat.app.AlertDialog
|
||||||
import androidx.appcompat.content.res.AppCompatResources
|
import androidx.appcompat.content.res.AppCompatResources
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
@ -69,6 +68,7 @@ import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
|
|||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
|
import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
|
||||||
import org.schabi.newpipe.fragments.BaseStateFragment
|
import org.schabi.newpipe.fragments.BaseStateFragment
|
||||||
|
import org.schabi.newpipe.info_list.ItemViewMode
|
||||||
import org.schabi.newpipe.info_list.dialog.InfoItemDialog
|
import org.schabi.newpipe.info_list.dialog.InfoItemDialog
|
||||||
import org.schabi.newpipe.ktx.animate
|
import org.schabi.newpipe.ktx.animate
|
||||||
import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling
|
import org.schabi.newpipe.ktx.animateHideRecyclerViewAllowingScrolling
|
||||||
@ -80,6 +80,7 @@ import org.schabi.newpipe.util.DeviceUtils
|
|||||||
import org.schabi.newpipe.util.Localization
|
import org.schabi.newpipe.util.Localization
|
||||||
import org.schabi.newpipe.util.NavigationHelper
|
import org.schabi.newpipe.util.NavigationHelper
|
||||||
import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams
|
import org.schabi.newpipe.util.ThemeHelper.getGridSpanCountStreams
|
||||||
|
import org.schabi.newpipe.util.ThemeHelper.getItemViewMode
|
||||||
import org.schabi.newpipe.util.ThemeHelper.resolveDrawable
|
import org.schabi.newpipe.util.ThemeHelper.resolveDrawable
|
||||||
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
|
import org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
@ -120,7 +121,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||||||
groupName = arguments?.getString(KEY_GROUP_NAME) ?: ""
|
groupName = arguments?.getString(KEY_GROUP_NAME) ?: ""
|
||||||
|
|
||||||
onSettingsChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
onSettingsChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
|
||||||
if (key.equals(getString(R.string.list_view_mode_key))) {
|
if (getString(R.string.list_view_mode_key).equals(key)) {
|
||||||
updateListViewModeOnResume = true
|
updateListViewModeOnResume = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -416,11 +417,10 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||||||
|
|
||||||
@SuppressLint("StringFormatMatches")
|
@SuppressLint("StringFormatMatches")
|
||||||
private fun handleLoadedState(loadedState: FeedState.LoadedState) {
|
private fun handleLoadedState(loadedState: FeedState.LoadedState) {
|
||||||
|
val itemVersion = when (getItemViewMode(requireContext())) {
|
||||||
val itemVersion = if (shouldUseGridLayout(context)) {
|
ItemViewMode.GRID -> StreamItem.ItemVersion.GRID
|
||||||
StreamItem.ItemVersion.GRID
|
ItemViewMode.CARD -> StreamItem.ItemVersion.CARD
|
||||||
} else {
|
else -> StreamItem.ItemVersion.NORMAL
|
||||||
StreamItem.ItemVersion.NORMAL
|
|
||||||
}
|
}
|
||||||
loadedState.items.forEach { it.itemVersion = itemVersion }
|
loadedState.items.forEach { it.itemVersion = itemVersion }
|
||||||
|
|
||||||
@ -499,7 +499,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||||||
|
|
||||||
private fun handleFeedNotAvailable(
|
private fun handleFeedNotAvailable(
|
||||||
subscriptionEntity: SubscriptionEntity,
|
subscriptionEntity: SubscriptionEntity,
|
||||||
@Nullable cause: Throwable?,
|
cause: Throwable?,
|
||||||
nextItemsErrors: List<Throwable>
|
nextItemsErrors: List<Throwable>
|
||||||
) {
|
) {
|
||||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||||
|
@ -42,12 +42,13 @@ data class StreamItem(
|
|||||||
|
|
||||||
override fun getId(): Long = stream.uid
|
override fun getId(): Long = stream.uid
|
||||||
|
|
||||||
enum class ItemVersion { NORMAL, MINI, GRID }
|
enum class ItemVersion { NORMAL, MINI, GRID, CARD }
|
||||||
|
|
||||||
override fun getLayout(): Int = when (itemVersion) {
|
override fun getLayout(): Int = when (itemVersion) {
|
||||||
ItemVersion.NORMAL -> R.layout.list_stream_item
|
ItemVersion.NORMAL -> R.layout.list_stream_item
|
||||||
ItemVersion.MINI -> R.layout.list_stream_mini_item
|
ItemVersion.MINI -> R.layout.list_stream_mini_item
|
||||||
ItemVersion.GRID -> R.layout.list_stream_grid_item
|
ItemVersion.GRID -> R.layout.list_stream_grid_item
|
||||||
|
ItemVersion.CARD -> R.layout.list_stream_card_item
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun initializeViewBinding(view: View) = ListStreamItemBinding.bind(view)
|
override fun initializeViewBinding(view: View) = ListStreamItemBinding.bind(view)
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package org.schabi.newpipe.local.feed.notifications
|
package org.schabi.newpipe.local.feed.notifications
|
||||||
|
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
@ -20,6 +19,7 @@ import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
|||||||
import org.schabi.newpipe.local.feed.service.FeedUpdateInfo
|
import org.schabi.newpipe.local.feed.service.FeedUpdateInfo
|
||||||
import org.schabi.newpipe.util.Localization
|
import org.schabi.newpipe.util.Localization
|
||||||
import org.schabi.newpipe.util.NavigationHelper
|
import org.schabi.newpipe.util.NavigationHelper
|
||||||
|
import org.schabi.newpipe.util.PendingIntentCompat
|
||||||
import org.schabi.newpipe.util.PicassoHelper
|
import org.schabi.newpipe.util.PicassoHelper
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -70,15 +70,12 @@ class NotificationHelper(val context: Context) {
|
|||||||
|
|
||||||
// open the channel page when clicking on the notification
|
// open the channel page when clicking on the notification
|
||||||
builder.setContentIntent(
|
builder.setContentIntent(
|
||||||
PendingIntent.getActivity(
|
PendingIntentCompat.getActivity(
|
||||||
context,
|
context,
|
||||||
data.pseudoId,
|
data.pseudoId,
|
||||||
NavigationHelper
|
NavigationHelper
|
||||||
.getChannelIntent(context, data.listInfo.serviceId, data.listInfo.url)
|
.getChannelIntent(context, data.listInfo.serviceId, data.listInfo.url)
|
||||||
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
|
|
||||||
PendingIntent.FLAG_IMMUTABLE
|
|
||||||
else
|
|
||||||
0
|
0
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -19,7 +19,6 @@
|
|||||||
|
|
||||||
package org.schabi.newpipe.local.feed.service
|
package org.schabi.newpipe.local.feed.service
|
||||||
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
@ -43,6 +42,7 @@ import org.schabi.newpipe.extractor.ListInfo
|
|||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
|
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
|
||||||
import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
|
import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
|
||||||
|
import org.schabi.newpipe.util.PendingIntentCompat
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class FeedLoadService : Service() {
|
class FeedLoadService : Service() {
|
||||||
@ -152,12 +152,8 @@ class FeedLoadService : Service() {
|
|||||||
private lateinit var notificationBuilder: NotificationCompat.Builder
|
private lateinit var notificationBuilder: NotificationCompat.Builder
|
||||||
|
|
||||||
private fun createNotification(): NotificationCompat.Builder {
|
private fun createNotification(): NotificationCompat.Builder {
|
||||||
val cancelActionIntent = PendingIntent.getBroadcast(
|
val cancelActionIntent =
|
||||||
this,
|
PendingIntentCompat.getBroadcast(this, NOTIFICATION_ID, Intent(ACTION_CANCEL), 0)
|
||||||
NOTIFICATION_ID,
|
|
||||||
Intent(ACTION_CANCEL),
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0
|
|
||||||
)
|
|
||||||
|
|
||||||
return NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
|
return NotificationCompat.Builder(this, getString(R.string.notification_channel_id))
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
|
@ -0,0 +1,17 @@
|
|||||||
|
package org.schabi.newpipe.local.holder;
|
||||||
|
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.local.LocalItemBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playlist card layout.
|
||||||
|
*/
|
||||||
|
public class LocalPlaylistCardItemHolder extends LocalPlaylistItemHolder {
|
||||||
|
|
||||||
|
public LocalPlaylistCardItemHolder(final LocalItemBuilder infoItemBuilder,
|
||||||
|
final ViewGroup parent) {
|
||||||
|
super(infoItemBuilder, R.layout.list_playlist_card_item, parent);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
package org.schabi.newpipe.local.holder;
|
||||||
|
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.local.LocalItemBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local playlist stream UI. This also includes a handle to rearrange the videos.
|
||||||
|
*/
|
||||||
|
public class LocalPlaylistStreamCardItemHolder extends LocalPlaylistStreamItemHolder {
|
||||||
|
|
||||||
|
public LocalPlaylistStreamCardItemHolder(final LocalItemBuilder infoItemBuilder,
|
||||||
|
final ViewGroup parent) {
|
||||||
|
super(infoItemBuilder, R.layout.list_stream_playlist_card_item, parent);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
package org.schabi.newpipe.local.holder;
|
||||||
|
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.local.LocalItemBuilder;
|
||||||
|
|
||||||
|
public class LocalStatisticStreamCardItemHolder extends LocalStatisticStreamItemHolder {
|
||||||
|
public LocalStatisticStreamCardItemHolder(final LocalItemBuilder infoItemBuilder,
|
||||||
|
final ViewGroup parent) {
|
||||||
|
super(infoItemBuilder, R.layout.list_stream_card_item, parent);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
package org.schabi.newpipe.local.holder;
|
||||||
|
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.local.LocalItemBuilder;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Playlist card UI for list item.
|
||||||
|
*/
|
||||||
|
public class RemotePlaylistCardItemHolder extends RemotePlaylistItemHolder {
|
||||||
|
|
||||||
|
public RemotePlaylistCardItemHolder(final LocalItemBuilder infoItemBuilder,
|
||||||
|
final ViewGroup parent) {
|
||||||
|
super(infoItemBuilder, R.layout.list_playlist_card_item, parent);
|
||||||
|
}
|
||||||
|
}
|
@ -404,7 +404,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||||||
.firstElement()
|
.firstElement()
|
||||||
.zipWith(historyIdsMaybe, (playlist, historyStreamIds) -> {
|
.zipWith(historyIdsMaybe, (playlist, historyStreamIds) -> {
|
||||||
// Remove Watched, Functionality data
|
// Remove Watched, Functionality data
|
||||||
final List<PlaylistStreamEntry> notWatchedItems = new ArrayList<>();
|
final List<PlaylistStreamEntry> itemsToKeep = new ArrayList<>();
|
||||||
|
final boolean isThumbnailPermanent = playlistManager
|
||||||
|
.getIsPlaylistThumbnailPermanent(playlistId);
|
||||||
boolean thumbnailVideoRemoved = false;
|
boolean thumbnailVideoRemoved = false;
|
||||||
|
|
||||||
if (removePartiallyWatched) {
|
if (removePartiallyWatched) {
|
||||||
@ -413,8 +415,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||||||
playlistItem.getStreamId());
|
playlistItem.getStreamId());
|
||||||
|
|
||||||
if (indexInHistory < 0) {
|
if (indexInHistory < 0) {
|
||||||
notWatchedItems.add(playlistItem);
|
itemsToKeep.add(playlistItem);
|
||||||
} else if (!thumbnailVideoRemoved
|
} else if (!isThumbnailPermanent && !thumbnailVideoRemoved
|
||||||
&& playlistManager.getPlaylistThumbnail(playlistId)
|
&& playlistManager.getPlaylistThumbnail(playlistId)
|
||||||
.equals(playlistItem.getStreamEntity().getThumbnailUrl())) {
|
.equals(playlistItem.getStreamEntity().getThumbnailUrl())) {
|
||||||
thumbnailVideoRemoved = true;
|
thumbnailVideoRemoved = true;
|
||||||
@ -434,8 +436,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||||||
|
|
||||||
if (indexInHistory < 0 || (streamStateEntity != null
|
if (indexInHistory < 0 || (streamStateEntity != null
|
||||||
&& !streamStateEntity.isFinished(duration))) {
|
&& !streamStateEntity.isFinished(duration))) {
|
||||||
notWatchedItems.add(playlistItem);
|
itemsToKeep.add(playlistItem);
|
||||||
} else if (!thumbnailVideoRemoved
|
} else if (!isThumbnailPermanent && !thumbnailVideoRemoved
|
||||||
&& playlistManager.getPlaylistThumbnail(playlistId)
|
&& playlistManager.getPlaylistThumbnail(playlistId)
|
||||||
.equals(playlistItem.getStreamEntity().getThumbnailUrl())) {
|
.equals(playlistItem.getStreamEntity().getThumbnailUrl())) {
|
||||||
thumbnailVideoRemoved = true;
|
thumbnailVideoRemoved = true;
|
||||||
@ -443,17 +445,17 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Pair<>(notWatchedItems, thumbnailVideoRemoved);
|
return new Pair<>(itemsToKeep, thumbnailVideoRemoved);
|
||||||
});
|
});
|
||||||
|
|
||||||
disposables.add(streamsMaybe.subscribeOn(Schedulers.io())
|
disposables.add(streamsMaybe.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(flow -> {
|
.subscribe(flow -> {
|
||||||
final List<PlaylistStreamEntry> notWatchedItems = flow.first;
|
final List<PlaylistStreamEntry> itemsToKeep = flow.first;
|
||||||
final boolean thumbnailVideoRemoved = flow.second;
|
final boolean thumbnailVideoRemoved = flow.second;
|
||||||
|
|
||||||
itemListAdapter.clearStreamItemList();
|
itemListAdapter.clearStreamItemList();
|
||||||
itemListAdapter.addItems(notWatchedItems);
|
itemListAdapter.addItems(itemsToKeep);
|
||||||
saveChanges();
|
saveChanges();
|
||||||
|
|
||||||
if (thumbnailVideoRemoved) {
|
if (thumbnailVideoRemoved) {
|
||||||
@ -585,8 +587,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||||||
disposables.add(disposable);
|
disposables.add(disposable);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void changeThumbnailUrl(final String thumbnailUrl) {
|
private void changeThumbnailUrl(final String thumbnailUrl, final boolean isPermanent) {
|
||||||
if (playlistManager == null) {
|
if (playlistManager == null || (!isPermanent && playlistManager
|
||||||
|
.getIsPlaylistThumbnailPermanent(playlistId))) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -600,7 +603,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
final Disposable disposable = playlistManager
|
final Disposable disposable = playlistManager
|
||||||
.changePlaylistThumbnail(playlistId, thumbnailUrl)
|
.changePlaylistThumbnail(playlistId, thumbnailUrl, isPermanent)
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(ignore -> successToast.show(), throwable ->
|
.subscribe(ignore -> successToast.show(), throwable ->
|
||||||
showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK,
|
showError(new ErrorInfo(throwable, UserAction.REQUESTED_BOOKMARK,
|
||||||
@ -609,6 +612,10 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void updateThumbnailUrl() {
|
private void updateThumbnailUrl() {
|
||||||
|
if (playlistManager.getIsPlaylistThumbnailPermanent(playlistId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final String newThumbnailUrl;
|
final String newThumbnailUrl;
|
||||||
|
|
||||||
if (!itemListAdapter.getItemsList().isEmpty()) {
|
if (!itemListAdapter.getItemsList().isEmpty()) {
|
||||||
@ -618,7 +625,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||||||
newThumbnailUrl = "drawable://" + R.drawable.placeholder_thumbnail_playlist;
|
newThumbnailUrl = "drawable://" + R.drawable.placeholder_thumbnail_playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
changeThumbnailUrl(newThumbnailUrl);
|
changeThumbnailUrl(newThumbnailUrl, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void deleteItem(final PlaylistStreamEntry item) {
|
private void deleteItem(final PlaylistStreamEntry item) {
|
||||||
@ -786,7 +793,8 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
|
|||||||
.setAction(
|
.setAction(
|
||||||
StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL,
|
StreamDialogDefaultEntry.SET_AS_PLAYLIST_THUMBNAIL,
|
||||||
(f, i) ->
|
(f, i) ->
|
||||||
changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl()))
|
changeThumbnailUrl(item.getStreamEntity().getThumbnailUrl(),
|
||||||
|
true))
|
||||||
.setAction(
|
.setAction(
|
||||||
StreamDialogDefaultEntry.DELETE,
|
StreamDialogDefaultEntry.DELETE,
|
||||||
(f, i) -> deleteItem(item))
|
(f, i) -> deleteItem(item))
|
||||||
|
@ -2,6 +2,7 @@ package org.schabi.newpipe.local.playlist;
|
|||||||
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.database.AppDatabase;
|
import org.schabi.newpipe.database.AppDatabase;
|
||||||
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
|
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
|
||||||
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
|
import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
|
||||||
@ -41,7 +42,7 @@ public class LocalPlaylistManager {
|
|||||||
}
|
}
|
||||||
final StreamEntity defaultStream = streams.get(0);
|
final StreamEntity defaultStream = streams.get(0);
|
||||||
final PlaylistEntity newPlaylist =
|
final PlaylistEntity newPlaylist =
|
||||||
new PlaylistEntity(name, defaultStream.getThumbnailUrl());
|
new PlaylistEntity(name, defaultStream.getThumbnailUrl(), false);
|
||||||
|
|
||||||
return Maybe.fromCallable(() -> database.runInTransaction(() ->
|
return Maybe.fromCallable(() -> database.runInTransaction(() ->
|
||||||
upsertStreams(playlistTable.insert(newPlaylist), streams, 0))
|
upsertStreams(playlistTable.insert(newPlaylist), streams, 0))
|
||||||
@ -96,21 +97,33 @@ public class LocalPlaylistManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Maybe<Integer> renamePlaylist(final long playlistId, final String name) {
|
public Maybe<Integer> renamePlaylist(final long playlistId, final String name) {
|
||||||
return modifyPlaylist(playlistId, name, null);
|
return modifyPlaylist(playlistId, name, null, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Maybe<Integer> changePlaylistThumbnail(final long playlistId,
|
public Maybe<Integer> changePlaylistThumbnail(final long playlistId,
|
||||||
final String thumbnailUrl) {
|
final String thumbnailUrl,
|
||||||
return modifyPlaylist(playlistId, null, thumbnailUrl);
|
final boolean isPermanent) {
|
||||||
|
return modifyPlaylist(playlistId, null, thumbnailUrl, isPermanent);
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getPlaylistThumbnail(final long playlistId) {
|
public String getPlaylistThumbnail(final long playlistId) {
|
||||||
return playlistTable.getPlaylist(playlistId).blockingFirst().get(0).getThumbnailUrl();
|
return playlistTable.getPlaylist(playlistId).blockingFirst().get(0).getThumbnailUrl();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean getIsPlaylistThumbnailPermanent(final long playlistId) {
|
||||||
|
return playlistTable.getPlaylist(playlistId).blockingFirst().get(0)
|
||||||
|
.getIsThumbnailPermanent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getAutomaticPlaylistThumbnail(final long playlistId) {
|
||||||
|
final String def = "drawable://" + R.drawable.placeholder_thumbnail_playlist;
|
||||||
|
return playlistStreamTable.getAutomaticThumbnailUrl(playlistId, def).blockingFirst();
|
||||||
|
}
|
||||||
|
|
||||||
private Maybe<Integer> modifyPlaylist(final long playlistId,
|
private Maybe<Integer> modifyPlaylist(final long playlistId,
|
||||||
@Nullable final String name,
|
@Nullable final String name,
|
||||||
@Nullable final String thumbnailUrl) {
|
@Nullable final String thumbnailUrl,
|
||||||
|
final boolean isPermanent) {
|
||||||
return playlistTable.getPlaylist(playlistId)
|
return playlistTable.getPlaylist(playlistId)
|
||||||
.firstElement()
|
.firstElement()
|
||||||
.filter(playlistEntities -> !playlistEntities.isEmpty())
|
.filter(playlistEntities -> !playlistEntities.isEmpty())
|
||||||
@ -121,6 +134,7 @@ public class LocalPlaylistManager {
|
|||||||
}
|
}
|
||||||
if (thumbnailUrl != null) {
|
if (thumbnailUrl != null) {
|
||||||
playlist.setThumbnailUrl(thumbnailUrl);
|
playlist.setThumbnailUrl(thumbnailUrl);
|
||||||
|
playlist.setIsThumbnailPermanent(isPermanent);
|
||||||
}
|
}
|
||||||
return playlistTable.update(playlist);
|
return playlistTable.update(playlist);
|
||||||
}).subscribeOn(Schedulers.io());
|
}).subscribeOn(Schedulers.io());
|
||||||
|
@ -51,7 +51,8 @@ enum class FeedGroupIcon(
|
|||||||
WORLD(34, R.drawable.ic_public),
|
WORLD(34, R.drawable.ic_public),
|
||||||
STAR(35, R.drawable.ic_stars),
|
STAR(35, R.drawable.ic_stars),
|
||||||
SUN(36, R.drawable.ic_wb_sunny),
|
SUN(36, R.drawable.ic_wb_sunny),
|
||||||
RSS(37, R.drawable.ic_rss_feed);
|
RSS(37, R.drawable.ic_rss_feed),
|
||||||
|
WHATS_NEW(38, R.drawable.ic_subscriptions);
|
||||||
|
|
||||||
@DrawableRes
|
@DrawableRes
|
||||||
fun getDrawableRes(): Int {
|
fun getDrawableRes(): Int {
|
||||||
|
@ -41,7 +41,6 @@ import org.schabi.newpipe.local.subscription.SubscriptionViewModel.SubscriptionS
|
|||||||
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog
|
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog
|
||||||
import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialog
|
import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialog
|
||||||
import org.schabi.newpipe.local.subscription.item.ChannelItem
|
import org.schabi.newpipe.local.subscription.item.ChannelItem
|
||||||
import org.schabi.newpipe.local.subscription.item.EmptyPlaceholderItem
|
|
||||||
import org.schabi.newpipe.local.subscription.item.FeedGroupAddNewGridItem
|
import org.schabi.newpipe.local.subscription.item.FeedGroupAddNewGridItem
|
||||||
import org.schabi.newpipe.local.subscription.item.FeedGroupAddNewItem
|
import org.schabi.newpipe.local.subscription.item.FeedGroupAddNewItem
|
||||||
import org.schabi.newpipe.local.subscription.item.FeedGroupCardGridItem
|
import org.schabi.newpipe.local.subscription.item.FeedGroupCardGridItem
|
||||||
@ -49,6 +48,7 @@ import org.schabi.newpipe.local.subscription.item.FeedGroupCardItem
|
|||||||
import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem
|
import org.schabi.newpipe.local.subscription.item.FeedGroupCarouselItem
|
||||||
import org.schabi.newpipe.local.subscription.item.GroupsHeader
|
import org.schabi.newpipe.local.subscription.item.GroupsHeader
|
||||||
import org.schabi.newpipe.local.subscription.item.Header
|
import org.schabi.newpipe.local.subscription.item.Header
|
||||||
|
import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem
|
||||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService
|
import org.schabi.newpipe.local.subscription.services.SubscriptionsExportService
|
||||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService
|
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService
|
||||||
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE
|
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.KEY_MODE
|
||||||
@ -312,7 +312,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
|||||||
groupAdapter.add(this)
|
groupAdapter.add(this)
|
||||||
}
|
}
|
||||||
|
|
||||||
subscriptionsSection.setPlaceholder(EmptyPlaceholderItem())
|
subscriptionsSection.setPlaceholder(ImportSubscriptionsHintPlaceholderItem())
|
||||||
subscriptionsSection.setHideWhenEmpty(true)
|
subscriptionsSection.setHideWhenEmpty(true)
|
||||||
|
|
||||||
groupAdapter.add(
|
groupAdapter.add(
|
||||||
@ -433,10 +433,10 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
|||||||
clear()
|
clear()
|
||||||
if (listViewMode) {
|
if (listViewMode) {
|
||||||
add(FeedGroupAddNewItem())
|
add(FeedGroupAddNewItem())
|
||||||
add(FeedGroupCardItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.RSS))
|
add(FeedGroupCardItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.WHATS_NEW))
|
||||||
} else {
|
} else {
|
||||||
add(FeedGroupAddNewGridItem())
|
add(FeedGroupAddNewGridItem())
|
||||||
add(FeedGroupCardGridItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.RSS))
|
add(FeedGroupCardGridItem(GROUP_ALL_ID, getString(R.string.all), FeedGroupIcon.WHATS_NEW))
|
||||||
}
|
}
|
||||||
addAll(groups)
|
addAll(groups)
|
||||||
}
|
}
|
||||||
|
@ -35,7 +35,7 @@ import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.
|
|||||||
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.SubscriptionsPickerScreen
|
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialog.ScreenState.SubscriptionsPickerScreen
|
||||||
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.ProcessingEvent
|
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.ProcessingEvent
|
||||||
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.SuccessEvent
|
import org.schabi.newpipe.local.subscription.dialog.FeedGroupDialogViewModel.DialogEvent.SuccessEvent
|
||||||
import org.schabi.newpipe.local.subscription.item.EmptyPlaceholderItem
|
import org.schabi.newpipe.local.subscription.item.ImportSubscriptionsHintPlaceholderItem
|
||||||
import org.schabi.newpipe.local.subscription.item.PickerIconItem
|
import org.schabi.newpipe.local.subscription.item.PickerIconItem
|
||||||
import org.schabi.newpipe.local.subscription.item.PickerSubscriptionItem
|
import org.schabi.newpipe.local.subscription.item.PickerSubscriptionItem
|
||||||
import org.schabi.newpipe.util.DeviceUtils
|
import org.schabi.newpipe.util.DeviceUtils
|
||||||
@ -338,7 +338,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
|
|||||||
|
|
||||||
if (subscriptions.isEmpty()) {
|
if (subscriptions.isEmpty()) {
|
||||||
subscriptionEmptyFooter.clear()
|
subscriptionEmptyFooter.clear()
|
||||||
subscriptionEmptyFooter.add(EmptyPlaceholderItem())
|
subscriptionEmptyFooter.add(ImportSubscriptionsHintPlaceholderItem())
|
||||||
} else {
|
} else {
|
||||||
subscriptionEmptyFooter.clear()
|
subscriptionEmptyFooter.clear()
|
||||||
}
|
}
|
||||||
|
@ -5,8 +5,11 @@ import com.xwray.groupie.viewbinding.BindableItem
|
|||||||
import org.schabi.newpipe.R
|
import org.schabi.newpipe.R
|
||||||
import org.schabi.newpipe.databinding.ListEmptyViewBinding
|
import org.schabi.newpipe.databinding.ListEmptyViewBinding
|
||||||
|
|
||||||
class EmptyPlaceholderItem : BindableItem<ListEmptyViewBinding>() {
|
/**
|
||||||
override fun getLayout(): Int = R.layout.list_empty_view
|
* When there are no subscriptions, show a hint to the user about how to import subscriptions
|
||||||
|
*/
|
||||||
|
class ImportSubscriptionsHintPlaceholderItem : BindableItem<ListEmptyViewBinding>() {
|
||||||
|
override fun getLayout(): Int = R.layout.list_empty_view_subscriptions
|
||||||
override fun bind(viewBinding: ListEmptyViewBinding, position: Int) {}
|
override fun bind(viewBinding: ListEmptyViewBinding, position: Int) {}
|
||||||
override fun getSpanSize(spanCount: Int, position: Int): Int = spanCount
|
override fun getSpanSize(spanCount: Int, position: Int): Int = spanCount
|
||||||
override fun initializeViewBinding(view: View) = ListEmptyViewBinding.bind(view)
|
override fun initializeViewBinding(view: View) = ListEmptyViewBinding.bind(view)
|
@ -143,11 +143,9 @@ public final class PlayQueueActivity extends AppCompatActivity
|
|||||||
NavigationHelper.playOnMainPlayer(this, player.getPlayQueue(), true);
|
NavigationHelper.playOnMainPlayer(this, player.getPlayQueue(), true);
|
||||||
return true;
|
return true;
|
||||||
case R.id.action_switch_popup:
|
case R.id.action_switch_popup:
|
||||||
if (PermissionHelper.isPopupEnabled(this)) {
|
if (PermissionHelper.isPopupEnabledElseAsk(this)) {
|
||||||
this.player.setRecovery();
|
this.player.setRecovery();
|
||||||
NavigationHelper.playOnPopupPlayer(this, player.getPlayQueue(), true);
|
NavigationHelper.playOnPopupPlayer(this, player.getPlayQueue(), true);
|
||||||
} else {
|
|
||||||
PermissionHelper.showPopupEnablementToast(this);
|
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
case R.id.action_switch_background:
|
case R.id.action_switch_background:
|
||||||
|
@ -216,7 +216,6 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
// minimized to background but will resume automatically to the original player type
|
// minimized to background but will resume automatically to the original player type
|
||||||
private boolean isAudioOnly = false;
|
private boolean isAudioOnly = false;
|
||||||
private boolean isPrepared = false;
|
private boolean isPrepared = false;
|
||||||
private boolean wasPlaying = false;
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
// UIs, listeners and disposables
|
// UIs, listeners and disposables
|
||||||
@ -349,7 +348,7 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString(
|
final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString(
|
||||||
R.string.playback_skip_silence_key), getPlaybackSkipSilence());
|
R.string.playback_skip_silence_key), getPlaybackSkipSilence());
|
||||||
|
|
||||||
final boolean samePlayQueue = playQueue != null && playQueue.equals(newQueue);
|
final boolean samePlayQueue = playQueue != null && playQueue.equalStreamsAndIndex(newQueue);
|
||||||
final int repeatMode = intent.getIntExtra(REPEAT_MODE, getRepeatMode());
|
final int repeatMode = intent.getIntExtra(REPEAT_MODE, getRepeatMode());
|
||||||
final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true);
|
final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true);
|
||||||
final boolean isMuted = intent.getBooleanExtra(IS_MUTED, isMuted());
|
final boolean isMuted = intent.getBooleanExtra(IS_MUTED, isMuted());
|
||||||
@ -918,13 +917,6 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
error -> Log.e(TAG, "Progress update failure: ", error));
|
error -> Log.e(TAG, "Progress update failure: ", error));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void saveWasPlaying() {
|
|
||||||
this.wasPlaying = getPlayWhenReady();
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean wasPlaying() {
|
|
||||||
return wasPlaying;
|
|
||||||
}
|
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
|
||||||
@ -1703,18 +1695,16 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void saveStreamProgressState(final long progressMillis) {
|
private void saveStreamProgressState(final long progressMillis) {
|
||||||
//noinspection SimplifyOptionalCallChains
|
getCurrentStreamInfo().ifPresent(info -> {
|
||||||
if (!getCurrentStreamInfo().isPresent()
|
if (!prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) {
|
||||||
|| !prefs.getBoolean(context.getString(R.string.enable_watch_history_key), true)) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
Log.d(TAG, "saveStreamProgressState() called with: progressMillis=" + progressMillis
|
Log.d(TAG, "saveStreamProgressState() called with: progressMillis=" + progressMillis
|
||||||
+ ", currentMetadata=[" + getCurrentStreamInfo().get().getName() + "]");
|
+ ", currentMetadata=[" + info.getName() + "]");
|
||||||
}
|
}
|
||||||
|
|
||||||
databaseUpdateDisposable
|
databaseUpdateDisposable.add(recordManager.saveStreamState(info, progressMillis)
|
||||||
.add(recordManager.saveStreamState(getCurrentStreamInfo().get(), progressMillis)
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.doOnError(e -> {
|
.doOnError(e -> {
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
@ -1723,6 +1713,7 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
})
|
})
|
||||||
.onErrorComplete()
|
.onErrorComplete()
|
||||||
.subscribe());
|
.subscribe());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void saveStreamProgressState() {
|
public void saveStreamProgressState() {
|
||||||
@ -1884,23 +1875,16 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
loadController.disablePreloadingOfCurrentTrack();
|
loadController.disablePreloadingOfCurrentTrack();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
public Optional<VideoStream> getSelectedVideoStream() {
|
||||||
public VideoStream getSelectedVideoStream() {
|
return Optional.ofNullable(currentMetadata)
|
||||||
@Nullable final MediaItemTag.Quality quality = Optional.ofNullable(currentMetadata)
|
|
||||||
.flatMap(MediaItemTag::getMaybeQuality)
|
.flatMap(MediaItemTag::getMaybeQuality)
|
||||||
.orElse(null);
|
.filter(quality -> {
|
||||||
if (quality == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final List<VideoStream> availableStreams = quality.getSortedVideoStreams();
|
|
||||||
final int selectedStreamIndex = quality.getSelectedVideoStreamIndex();
|
final int selectedStreamIndex = quality.getSelectedVideoStreamIndex();
|
||||||
|
return selectedStreamIndex >= 0
|
||||||
if (selectedStreamIndex >= 0 && availableStreams.size() > selectedStreamIndex) {
|
&& selectedStreamIndex < quality.getSortedVideoStreams().size();
|
||||||
return availableStreams.get(selectedStreamIndex);
|
})
|
||||||
} else {
|
.map(quality -> quality.getSortedVideoStreams()
|
||||||
return null;
|
.get(quality.getSelectedVideoStreamIndex()));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@ -2044,19 +2028,11 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
// in livestreams) so we will be not able to execute the block below.
|
// in livestreams) so we will be not able to execute the block below.
|
||||||
// Reload the play queue manager in this case, which is the behavior when we don't know the
|
// Reload the play queue manager in this case, which is the behavior when we don't know the
|
||||||
// index of the video renderer or playQueueManagerReloadingNeeded returns true.
|
// index of the video renderer or playQueueManagerReloadingNeeded returns true.
|
||||||
final Optional<StreamInfo> optCurrentStreamInfo = getCurrentStreamInfo();
|
getCurrentStreamInfo().ifPresentOrElse(info -> {
|
||||||
if (!optCurrentStreamInfo.isPresent()) {
|
// In the case we don't know the source type, fallback to the one with video with audio
|
||||||
reloadPlayQueueManager();
|
// or audio-only source.
|
||||||
setRecovery();
|
final SourceType sourceType = videoResolver.getStreamSourceType()
|
||||||
return;
|
.orElse(SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY);
|
||||||
}
|
|
||||||
|
|
||||||
final StreamInfo info = optCurrentStreamInfo.get();
|
|
||||||
|
|
||||||
// In the case we don't know the source type, fallback to the one with video with audio or
|
|
||||||
// audio-only source.
|
|
||||||
final SourceType sourceType = videoResolver.getStreamSourceType().orElse(
|
|
||||||
SourceType.VIDEO_WITH_AUDIO_OR_AUDIO_ONLY);
|
|
||||||
|
|
||||||
if (playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) {
|
if (playQueueManagerReloadingNeeded(sourceType, info, getVideoRendererIndex())) {
|
||||||
reloadPlayQueueManager();
|
reloadPlayQueueManager();
|
||||||
@ -2067,8 +2043,7 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final DefaultTrackSelector.Parameters.Builder parametersBuilder =
|
final var parametersBuilder = trackSelector.buildUponParameters();
|
||||||
trackSelector.buildUponParameters();
|
|
||||||
|
|
||||||
// Enable/disable the video track and the ability to select subtitles
|
// Enable/disable the video track and the ability to select subtitles
|
||||||
parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !videoEnabled);
|
parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !videoEnabled);
|
||||||
@ -2078,6 +2053,11 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setRecovery();
|
setRecovery();
|
||||||
|
}, () -> {
|
||||||
|
// This is executed when the current stream info is not available.
|
||||||
|
reloadPlayQueueManager();
|
||||||
|
setRecovery();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -86,8 +86,6 @@ public final class PlayerService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!player.exoPlayerIsNull()) {
|
if (!player.exoPlayerIsNull()) {
|
||||||
player.saveWasPlaying();
|
|
||||||
|
|
||||||
// Releases wifi & cpu, disables keepScreenOn, etc.
|
// Releases wifi & cpu, disables keepScreenOn, etc.
|
||||||
// We can't just pause the player here because it will make transition
|
// We can't just pause the player here because it will make transition
|
||||||
// from one stream to a new stream not smooth
|
// from one stream to a new stream not smooth
|
||||||
|
@ -6,6 +6,7 @@ import android.util.Log
|
|||||||
import android.view.GestureDetector
|
import android.view.GestureDetector
|
||||||
import android.view.MotionEvent
|
import android.view.MotionEvent
|
||||||
import android.view.View
|
import android.view.View
|
||||||
|
import androidx.core.os.postDelayed
|
||||||
import org.schabi.newpipe.databinding.PlayerBinding
|
import org.schabi.newpipe.databinding.PlayerBinding
|
||||||
import org.schabi.newpipe.player.Player
|
import org.schabi.newpipe.player.Player
|
||||||
import org.schabi.newpipe.player.ui.VideoPlayerUi
|
import org.schabi.newpipe.player.ui.VideoPlayerUi
|
||||||
@ -132,13 +133,6 @@ abstract class BasePlayerGestureListener(
|
|||||||
|
|
||||||
private var doubleTapDelay = DOUBLE_TAP_DELAY
|
private var doubleTapDelay = DOUBLE_TAP_DELAY
|
||||||
private val doubleTapHandler: Handler = Handler(Looper.getMainLooper())
|
private val doubleTapHandler: Handler = Handler(Looper.getMainLooper())
|
||||||
private val doubleTapRunnable = Runnable {
|
|
||||||
if (DEBUG)
|
|
||||||
Log.d(TAG, "doubleTapRunnable called")
|
|
||||||
|
|
||||||
isDoubleTapping = false
|
|
||||||
doubleTapControls?.onDoubleTapFinished()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startMultiDoubleTap(e: MotionEvent) {
|
private fun startMultiDoubleTap(e: MotionEvent) {
|
||||||
if (!isDoubleTapping) {
|
if (!isDoubleTapping) {
|
||||||
@ -155,8 +149,15 @@ abstract class BasePlayerGestureListener(
|
|||||||
Log.d(TAG, "keepInDoubleTapMode called")
|
Log.d(TAG, "keepInDoubleTapMode called")
|
||||||
|
|
||||||
isDoubleTapping = true
|
isDoubleTapping = true
|
||||||
doubleTapHandler.removeCallbacks(doubleTapRunnable)
|
doubleTapHandler.removeCallbacksAndMessages(DOUBLE_TAP)
|
||||||
doubleTapHandler.postDelayed(doubleTapRunnable, doubleTapDelay)
|
doubleTapHandler.postDelayed(DOUBLE_TAP_DELAY, DOUBLE_TAP) {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "doubleTapRunnable called")
|
||||||
|
}
|
||||||
|
|
||||||
|
isDoubleTapping = false
|
||||||
|
doubleTapControls?.onDoubleTapFinished()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun endMultiDoubleTap() {
|
fun endMultiDoubleTap() {
|
||||||
@ -164,7 +165,7 @@ abstract class BasePlayerGestureListener(
|
|||||||
Log.d(TAG, "endMultiDoubleTap called")
|
Log.d(TAG, "endMultiDoubleTap called")
|
||||||
|
|
||||||
isDoubleTapping = false
|
isDoubleTapping = false
|
||||||
doubleTapHandler.removeCallbacks(doubleTapRunnable)
|
doubleTapHandler.removeCallbacksAndMessages(DOUBLE_TAP)
|
||||||
doubleTapControls?.onDoubleTapFinished()
|
doubleTapControls?.onDoubleTapFinished()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,6 +182,7 @@ abstract class BasePlayerGestureListener(
|
|||||||
private const val TAG = "BasePlayerGestListener"
|
private const val TAG = "BasePlayerGestListener"
|
||||||
private val DEBUG = Player.DEBUG
|
private val DEBUG = Player.DEBUG
|
||||||
|
|
||||||
|
private const val DOUBLE_TAP = "doubleTap"
|
||||||
private const val DOUBLE_TAP_DELAY = 550L
|
private const val DOUBLE_TAP_DELAY = 550L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -160,15 +160,15 @@ class PopupPlayerGestureListener(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onLongPress(e: MotionEvent?) {
|
override fun onLongPress(e: MotionEvent) {
|
||||||
playerUi.updateScreenSize()
|
playerUi.updateScreenSize()
|
||||||
playerUi.checkPopupPositionBounds()
|
playerUi.checkPopupPositionBounds()
|
||||||
playerUi.changePopupSize(playerUi.screenWidth)
|
playerUi.changePopupSize(playerUi.screenWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onFling(
|
override fun onFling(
|
||||||
e1: MotionEvent?,
|
e1: MotionEvent,
|
||||||
e2: MotionEvent?,
|
e2: MotionEvent,
|
||||||
velocityX: Float,
|
velocityX: Float,
|
||||||
velocityY: Float
|
velocityY: Float
|
||||||
): Boolean {
|
): Boolean {
|
||||||
|
@ -92,6 +92,13 @@ public final class PlayerHolder {
|
|||||||
return player.getPlayQueue().size();
|
return player.getPlayQueue().size();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public int getQueuePosition() {
|
||||||
|
if (player == null || player.getPlayQueue() == null) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return player.getPlayQueue().getIndex();
|
||||||
|
}
|
||||||
|
|
||||||
public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) {
|
public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) {
|
||||||
listener = newListener;
|
listener = newListener;
|
||||||
|
|
||||||
|
@ -61,12 +61,11 @@ public interface MediaItemTag {
|
|||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
static Optional<MediaItemTag> from(@Nullable final MediaItem mediaItem) {
|
static Optional<MediaItemTag> from(@Nullable final MediaItem mediaItem) {
|
||||||
if (mediaItem == null || mediaItem.localConfiguration == null
|
return Optional.ofNullable(mediaItem)
|
||||||
|| !(mediaItem.localConfiguration.tag instanceof MediaItemTag)) {
|
.map(item -> item.localConfiguration)
|
||||||
return Optional.empty();
|
.map(localConfiguration -> localConfiguration.tag)
|
||||||
}
|
.filter(MediaItemTag.class::isInstance)
|
||||||
|
.map(MediaItemTag.class::cast);
|
||||||
return Optional.of((MediaItemTag) mediaItem.localConfiguration.tag);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package org.schabi.newpipe.player.notification;
|
package org.schabi.newpipe.player.notification;
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
import android.annotation.SuppressLint;
|
||||||
import android.app.PendingIntent;
|
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.pm.ServiceInfo;
|
import android.content.pm.ServiceInfo;
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
@ -22,6 +21,7 @@ import org.schabi.newpipe.R;
|
|||||||
import org.schabi.newpipe.player.Player;
|
import org.schabi.newpipe.player.Player;
|
||||||
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
|
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
|
||||||
import org.schabi.newpipe.util.NavigationHelper;
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
|
import org.schabi.newpipe.util.PendingIntentCompat;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@ -133,8 +133,8 @@ public final class NotificationUtil {
|
|||||||
R.color.dark_background_color))
|
R.color.dark_background_color))
|
||||||
.setColorized(player.getPrefs().getBoolean(
|
.setColorized(player.getPrefs().getBoolean(
|
||||||
player.getContext().getString(R.string.notification_colorize_key), true))
|
player.getContext().getString(R.string.notification_colorize_key), true))
|
||||||
.setDeleteIntent(PendingIntent.getBroadcast(player.getContext(), NOTIFICATION_ID,
|
.setDeleteIntent(PendingIntentCompat.getBroadcast(player.getContext(),
|
||||||
new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT));
|
NOTIFICATION_ID, new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT));
|
||||||
|
|
||||||
// set the initial value for the video thumbnail, updatable with updateNotificationThumbnail
|
// set the initial value for the video thumbnail, updatable with updateNotificationThumbnail
|
||||||
setLargeIcon(builder);
|
setLargeIcon(builder);
|
||||||
@ -151,7 +151,7 @@ public final class NotificationUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// also update content intent, in case the user switched players
|
// also update content intent, in case the user switched players
|
||||||
notificationBuilder.setContentIntent(PendingIntent.getActivity(player.getContext(),
|
notificationBuilder.setContentIntent(PendingIntentCompat.getActivity(player.getContext(),
|
||||||
NOTIFICATION_ID, getIntentForNotification(), FLAG_UPDATE_CURRENT));
|
NOTIFICATION_ID, getIntentForNotification(), FLAG_UPDATE_CURRENT));
|
||||||
notificationBuilder.setContentTitle(player.getVideoTitle());
|
notificationBuilder.setContentTitle(player.getVideoTitle());
|
||||||
notificationBuilder.setContentText(player.getUploaderName());
|
notificationBuilder.setContentText(player.getUploaderName());
|
||||||
@ -334,7 +334,7 @@ public final class NotificationUtil {
|
|||||||
@StringRes final int title,
|
@StringRes final int title,
|
||||||
final String intentAction) {
|
final String intentAction) {
|
||||||
return new NotificationCompat.Action(drawable, player.getContext().getString(title),
|
return new NotificationCompat.Action(drawable, player.getContext().getString(title),
|
||||||
PendingIntent.getBroadcast(player.getContext(), NOTIFICATION_ID,
|
PendingIntentCompat.getBroadcast(player.getContext(), NOTIFICATION_ID,
|
||||||
new Intent(intentAction), FLAG_UPDATE_CURRENT));
|
new Intent(intentAction), FLAG_UPDATE_CURRENT));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,8 +7,6 @@ import androidx.annotation.NonNull;
|
|||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.collection.ArraySet;
|
import androidx.collection.ArraySet;
|
||||||
|
|
||||||
import com.google.android.exoplayer2.source.MediaSource;
|
|
||||||
|
|
||||||
import org.reactivestreams.Subscriber;
|
import org.reactivestreams.Subscriber;
|
||||||
import org.reactivestreams.Subscription;
|
import org.reactivestreams.Subscription;
|
||||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||||
@ -23,10 +21,10 @@ import org.schabi.newpipe.player.playqueue.events.MoveEvent;
|
|||||||
import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent;
|
import org.schabi.newpipe.player.playqueue.events.PlayQueueEvent;
|
||||||
import org.schabi.newpipe.player.playqueue.events.RemoveEvent;
|
import org.schabi.newpipe.player.playqueue.events.RemoveEvent;
|
||||||
import org.schabi.newpipe.player.playqueue.events.ReorderEvent;
|
import org.schabi.newpipe.player.playqueue.events.ReorderEvent;
|
||||||
import org.schabi.newpipe.util.ServiceHelper;
|
|
||||||
|
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
@ -43,6 +41,7 @@ import io.reactivex.rxjava3.subjects.PublishSubject;
|
|||||||
import static org.schabi.newpipe.player.mediasource.FailedMediaSource.MediaSourceResolutionException;
|
import static org.schabi.newpipe.player.mediasource.FailedMediaSource.MediaSourceResolutionException;
|
||||||
import static org.schabi.newpipe.player.mediasource.FailedMediaSource.StreamInfoLoadException;
|
import static org.schabi.newpipe.player.mediasource.FailedMediaSource.StreamInfoLoadException;
|
||||||
import static org.schabi.newpipe.player.playqueue.PlayQueue.DEBUG;
|
import static org.schabi.newpipe.player.playqueue.PlayQueue.DEBUG;
|
||||||
|
import static org.schabi.newpipe.util.ServiceHelper.getCacheExpirationMillis;
|
||||||
|
|
||||||
public class MediaSourceManager {
|
public class MediaSourceManager {
|
||||||
@NonNull
|
@NonNull
|
||||||
@ -421,30 +420,38 @@ public class MediaSourceManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private Single<ManagedMediaSource> getLoadedMediaSource(@NonNull final PlayQueueItem stream) {
|
private Single<ManagedMediaSource> getLoadedMediaSource(@NonNull final PlayQueueItem stream) {
|
||||||
return stream.getStream().map(streamInfo -> {
|
return stream.getStream()
|
||||||
final MediaSource source = playbackListener.sourceOf(stream, streamInfo);
|
.map(streamInfo -> Optional
|
||||||
if (source == null || !MediaItemTag.from(source.getMediaItem()).isPresent()) {
|
.ofNullable(playbackListener.sourceOf(stream, streamInfo))
|
||||||
final String message = "Unable to resolve source from stream info. "
|
.<ManagedMediaSource>flatMap(source ->
|
||||||
+ "URL: " + stream.getUrl() + ", "
|
MediaItemTag.from(source.getMediaItem())
|
||||||
+ "audio count: " + streamInfo.getAudioStreams().size() + ", "
|
.map(tag -> {
|
||||||
+ "video count: " + streamInfo.getVideoOnlyStreams().size() + ", "
|
final int serviceId = streamInfo.getServiceId();
|
||||||
+ streamInfo.getVideoStreams().size();
|
|
||||||
return (ManagedMediaSource)
|
|
||||||
FailedMediaSource.of(stream, new MediaSourceResolutionException(message));
|
|
||||||
}
|
|
||||||
|
|
||||||
final MediaItemTag tag = MediaItemTag.from(source.getMediaItem()).get();
|
|
||||||
final long expiration = System.currentTimeMillis()
|
final long expiration = System.currentTimeMillis()
|
||||||
+ ServiceHelper.getCacheExpirationMillis(streamInfo.getServiceId());
|
+ getCacheExpirationMillis(serviceId);
|
||||||
return new LoadedMediaSource(source, tag, stream, expiration);
|
return new LoadedMediaSource(source, tag, stream,
|
||||||
}).onErrorReturn(throwable -> {
|
expiration);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.orElseGet(() -> {
|
||||||
|
final String message = "Unable to resolve source from stream info. "
|
||||||
|
+ "URL: " + stream.getUrl()
|
||||||
|
+ ", audio count: " + streamInfo.getAudioStreams().size()
|
||||||
|
+ ", video count: " + streamInfo.getVideoOnlyStreams().size()
|
||||||
|
+ ", " + streamInfo.getVideoStreams().size();
|
||||||
|
return FailedMediaSource.of(stream,
|
||||||
|
new MediaSourceResolutionException(message));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.onErrorReturn(throwable -> {
|
||||||
if (throwable instanceof ExtractionException) {
|
if (throwable instanceof ExtractionException) {
|
||||||
return FailedMediaSource.of(stream, new StreamInfoLoadException(throwable));
|
return FailedMediaSource.of(stream, new StreamInfoLoadException(throwable));
|
||||||
}
|
}
|
||||||
// Non-source related error expected here (e.g. network),
|
// Non-source related error expected here (e.g. network),
|
||||||
// should allow retry shortly after the error.
|
// should allow retry shortly after the error.
|
||||||
return FailedMediaSource.of(stream, new Exception(throwable),
|
final long allowRetryIn = TimeUnit.MILLISECONDS.convert(3,
|
||||||
/*allowRetryIn=*/TimeUnit.MILLISECONDS.convert(3, TimeUnit.SECONDS));
|
TimeUnit.SECONDS);
|
||||||
|
return FailedMediaSource.of(stream, new Exception(throwable), allowRetryIn);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -518,12 +518,10 @@ public abstract class PlayQueue implements Serializable {
|
|||||||
* This method also gives a chance to track history of items in a queue in
|
* This method also gives a chance to track history of items in a queue in
|
||||||
* VideoDetailFragment without duplicating items from two identical queues
|
* VideoDetailFragment without duplicating items from two identical queues
|
||||||
*/
|
*/
|
||||||
@Override
|
public boolean equalStreams(@Nullable final PlayQueue other) {
|
||||||
public boolean equals(@Nullable final Object obj) {
|
if (other == null) {
|
||||||
if (!(obj instanceof PlayQueue)) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
final PlayQueue other = (PlayQueue) obj;
|
|
||||||
if (size() != other.size()) {
|
if (size() != other.size()) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@ -539,9 +537,11 @@ public abstract class PlayQueue implements Serializable {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
public boolean equalStreamsAndIndex(@Nullable final PlayQueue other) {
|
||||||
public int hashCode() {
|
if (equalStreams(other)) {
|
||||||
return streams.hashCode();
|
return other.getIndex() == getIndex();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isDisposed() {
|
public boolean isDisposed() {
|
||||||
|
@ -11,7 +11,9 @@ import androidx.annotation.Nullable;
|
|||||||
import com.google.android.exoplayer2.source.MediaSource;
|
import com.google.android.exoplayer2.source.MediaSource;
|
||||||
|
|
||||||
import org.schabi.newpipe.extractor.stream.AudioStream;
|
import org.schabi.newpipe.extractor.stream.AudioStream;
|
||||||
|
import org.schabi.newpipe.extractor.stream.Stream;
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||||
|
import org.schabi.newpipe.extractor.stream.VideoStream;
|
||||||
import org.schabi.newpipe.player.helper.PlayerDataSource;
|
import org.schabi.newpipe.player.helper.PlayerDataSource;
|
||||||
import org.schabi.newpipe.player.mediaitem.MediaItemTag;
|
import org.schabi.newpipe.player.mediaitem.MediaItemTag;
|
||||||
import org.schabi.newpipe.player.mediaitem.StreamInfoTag;
|
import org.schabi.newpipe.player.mediaitem.StreamInfoTag;
|
||||||
@ -41,22 +43,50 @@ public class AudioPlaybackResolver implements PlaybackResolver {
|
|||||||
return liveSource;
|
return liveSource;
|
||||||
}
|
}
|
||||||
|
|
||||||
final List<AudioStream> audioStreams = getNonTorrentStreams(info.getAudioStreams());
|
final Stream stream = getAudioSource(info);
|
||||||
|
if (stream == null) {
|
||||||
final int index = ListHelper.getDefaultAudioFormat(context, audioStreams);
|
|
||||||
if (index < 0 || index >= info.getAudioStreams().size()) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
final AudioStream audio = info.getAudioStreams().get(index);
|
|
||||||
final MediaItemTag tag = StreamInfoTag.of(info);
|
final MediaItemTag tag = StreamInfoTag.of(info);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return PlaybackResolver.buildMediaSource(
|
return PlaybackResolver.buildMediaSource(
|
||||||
dataSource, audio, info, PlaybackResolver.cacheKeyOf(info, audio), tag);
|
dataSource, stream, info, PlaybackResolver.cacheKeyOf(info, stream), tag);
|
||||||
} catch (final ResolverException e) {
|
} catch (final ResolverException e) {
|
||||||
Log.e(TAG, "Unable to create audio source", e);
|
Log.e(TAG, "Unable to create audio source", e);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a stream to be played as audio. If a service has no separate {@link AudioStream}s we
|
||||||
|
* use a video stream as audio source to support audio background playback.
|
||||||
|
*
|
||||||
|
* @param info of the stream
|
||||||
|
* @return the audio source to use or null if none could be found
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
private Stream getAudioSource(@NonNull final StreamInfo info) {
|
||||||
|
final List<AudioStream> audioStreams = getNonTorrentStreams(info.getAudioStreams());
|
||||||
|
if (!audioStreams.isEmpty()) {
|
||||||
|
final int index = ListHelper.getDefaultAudioFormat(context, audioStreams);
|
||||||
|
return getStreamForIndex(index, audioStreams);
|
||||||
|
} else {
|
||||||
|
final List<VideoStream> videoStreams = getNonTorrentStreams(info.getVideoStreams());
|
||||||
|
if (!videoStreams.isEmpty()) {
|
||||||
|
final int index = ListHelper.getDefaultResolutionIndex(context, videoStreams);
|
||||||
|
return getStreamForIndex(index, videoStreams);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
Stream getStreamForIndex(final int index, @NonNull final List<? extends Stream> streams) {
|
||||||
|
if (index >= 0 && index < streams.size()) {
|
||||||
|
return streams.get(index);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -158,6 +158,26 @@ public interface PlaybackResolver extends Resolver<StreamInfo, MediaSource> {
|
|||||||
|
|
||||||
return cacheKey.toString();
|
return cacheKey.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use common base type {@link Stream} to handle {@link AudioStream} or {@link VideoStream}
|
||||||
|
* transparently. For more info see {@link #cacheKeyOf(StreamInfo, AudioStream)} or
|
||||||
|
* {@link #cacheKeyOf(StreamInfo, VideoStream)}.
|
||||||
|
*
|
||||||
|
* @param info the {@link StreamInfo stream info}, to distinguish between streams with
|
||||||
|
* the same features but coming from different stream infos
|
||||||
|
* @param stream the {@link Stream} ({@link AudioStream} or {@link VideoStream})
|
||||||
|
* for which the cache key should be created
|
||||||
|
* @return a key to be used to store the cache of the provided {@link Stream}
|
||||||
|
*/
|
||||||
|
static String cacheKeyOf(final StreamInfo info, final Stream stream) {
|
||||||
|
if (stream instanceof AudioStream) {
|
||||||
|
return cacheKeyOf(info, (AudioStream) stream);
|
||||||
|
} else if (stream instanceof VideoStream) {
|
||||||
|
return cacheKeyOf(info, (VideoStream) stream);
|
||||||
|
}
|
||||||
|
throw new RuntimeException("no audio or video stream. That should never happen");
|
||||||
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
|
||||||
|
@ -8,6 +8,8 @@ import android.widget.ImageView;
|
|||||||
|
|
||||||
import androidx.annotation.IntDef;
|
import androidx.annotation.IntDef;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.core.graphics.BitmapCompat;
|
||||||
import androidx.core.math.MathUtils;
|
import androidx.core.math.MathUtils;
|
||||||
import androidx.preference.PreferenceManager;
|
import androidx.preference.PreferenceManager;
|
||||||
|
|
||||||
@ -15,7 +17,6 @@ import org.schabi.newpipe.R;
|
|||||||
import org.schabi.newpipe.util.DeviceUtils;
|
import org.schabi.newpipe.util.DeviceUtils;
|
||||||
|
|
||||||
import java.lang.annotation.Retention;
|
import java.lang.annotation.Retention;
|
||||||
import java.util.Optional;
|
|
||||||
import java.util.function.IntSupplier;
|
import java.util.function.IntSupplier;
|
||||||
|
|
||||||
import static java.lang.annotation.RetentionPolicy.SOURCE;
|
import static java.lang.annotation.RetentionPolicy.SOURCE;
|
||||||
@ -65,21 +66,19 @@ public final class SeekbarPreviewThumbnailHelper {
|
|||||||
|
|
||||||
public static void tryResizeAndSetSeekbarPreviewThumbnail(
|
public static void tryResizeAndSetSeekbarPreviewThumbnail(
|
||||||
@NonNull final Context context,
|
@NonNull final Context context,
|
||||||
@NonNull final Optional<Bitmap> optPreviewThumbnail,
|
@Nullable final Bitmap previewThumbnail,
|
||||||
@NonNull final ImageView currentSeekbarPreviewThumbnail,
|
@NonNull final ImageView currentSeekbarPreviewThumbnail,
|
||||||
@NonNull final IntSupplier baseViewWidthSupplier) {
|
@NonNull final IntSupplier baseViewWidthSupplier) {
|
||||||
|
if (previewThumbnail == null) {
|
||||||
if (!optPreviewThumbnail.isPresent()) {
|
|
||||||
currentSeekbarPreviewThumbnail.setVisibility(View.GONE);
|
currentSeekbarPreviewThumbnail.setVisibility(View.GONE);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
currentSeekbarPreviewThumbnail.setVisibility(View.VISIBLE);
|
currentSeekbarPreviewThumbnail.setVisibility(View.VISIBLE);
|
||||||
final Bitmap srcBitmap = optPreviewThumbnail.get();
|
|
||||||
|
|
||||||
// Resize original bitmap
|
// Resize original bitmap
|
||||||
try {
|
try {
|
||||||
final int srcWidth = srcBitmap.getWidth() > 0 ? srcBitmap.getWidth() : 1;
|
final int srcWidth = previewThumbnail.getWidth() > 0 ? previewThumbnail.getWidth() : 1;
|
||||||
final int newWidth = MathUtils.clamp(
|
final int newWidth = MathUtils.clamp(
|
||||||
// Use 1/4 of the width for the preview
|
// Use 1/4 of the width for the preview
|
||||||
Math.round(baseViewWidthSupplier.getAsInt() / 4f),
|
Math.round(baseViewWidthSupplier.getAsInt() / 4f),
|
||||||
@ -89,15 +88,15 @@ public final class SeekbarPreviewThumbnailHelper {
|
|||||||
Math.round(srcWidth * 2.5f));
|
Math.round(srcWidth * 2.5f));
|
||||||
|
|
||||||
final float scaleFactor = (float) newWidth / srcWidth;
|
final float scaleFactor = (float) newWidth / srcWidth;
|
||||||
final int newHeight = (int) (srcBitmap.getHeight() * scaleFactor);
|
final int newHeight = (int) (previewThumbnail.getHeight() * scaleFactor);
|
||||||
|
|
||||||
currentSeekbarPreviewThumbnail.setImageBitmap(
|
currentSeekbarPreviewThumbnail.setImageBitmap(BitmapCompat
|
||||||
Bitmap.createScaledBitmap(srcBitmap, newWidth, newHeight, true));
|
.createScaledBitmap(previewThumbnail, newWidth, newHeight, null, true));
|
||||||
} catch (final Exception ex) {
|
} catch (final Exception ex) {
|
||||||
Log.e(TAG, "Failed to resize and set seekbar preview thumbnail", ex);
|
Log.e(TAG, "Failed to resize and set seekbar preview thumbnail", ex);
|
||||||
currentSeekbarPreviewThumbnail.setVisibility(View.GONE);
|
currentSeekbarPreviewThumbnail.setVisibility(View.GONE);
|
||||||
} finally {
|
} finally {
|
||||||
srcBitmap.recycle();
|
previewThumbnail.recycle();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package org.schabi.newpipe.player.seekbarpreview;
|
package org.schabi.newpipe.player.seekbarpreview;
|
||||||
|
|
||||||
import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType;
|
import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.SeekbarPreviewThumbnailType;
|
||||||
|
import static org.schabi.newpipe.player.seekbarpreview.SeekbarPreviewThumbnailHelper.getSeekbarPreviewThumbnailType;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
@ -8,6 +9,7 @@ import android.util.Log;
|
|||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.collection.SparseArrayCompat;
|
||||||
|
|
||||||
import com.google.common.base.Stopwatch;
|
import com.google.common.base.Stopwatch;
|
||||||
|
|
||||||
@ -15,12 +17,9 @@ import org.schabi.newpipe.extractor.stream.Frameset;
|
|||||||
import org.schabi.newpipe.util.PicassoHelper;
|
import org.schabi.newpipe.util.PicassoHelper;
|
||||||
|
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.ExecutorService;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.concurrent.Executors;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
@ -34,18 +33,15 @@ public class SeekbarPreviewThumbnailHolder {
|
|||||||
|
|
||||||
// Key = Position of the picture in milliseconds
|
// Key = Position of the picture in milliseconds
|
||||||
// Supplier = Supplies the bitmap for that position
|
// Supplier = Supplies the bitmap for that position
|
||||||
private final Map<Integer, Supplier<Bitmap>> seekbarPreviewData = new ConcurrentHashMap<>();
|
private final SparseArrayCompat<Supplier<Bitmap>> seekbarPreviewData =
|
||||||
|
new SparseArrayCompat<>();
|
||||||
|
|
||||||
// This ensures that if the reset is still undergoing
|
// This ensures that if the reset is still undergoing
|
||||||
// and another reset starts, only the last reset is processed
|
// and another reset starts, only the last reset is processed
|
||||||
private UUID currentUpdateRequestIdentifier = UUID.randomUUID();
|
private UUID currentUpdateRequestIdentifier = UUID.randomUUID();
|
||||||
|
|
||||||
public synchronized void resetFrom(
|
public void resetFrom(@NonNull final Context context, final List<Frameset> framesets) {
|
||||||
@NonNull final Context context,
|
final int seekbarPreviewType = getSeekbarPreviewThumbnailType(context);
|
||||||
final List<Frameset> framesets) {
|
|
||||||
|
|
||||||
final int seekbarPreviewType =
|
|
||||||
SeekbarPreviewThumbnailHelper.getSeekbarPreviewThumbnailType(context);
|
|
||||||
|
|
||||||
final UUID updateRequestIdentifier = UUID.randomUUID();
|
final UUID updateRequestIdentifier = UUID.randomUUID();
|
||||||
this.currentUpdateRequestIdentifier = updateRequestIdentifier;
|
this.currentUpdateRequestIdentifier = updateRequestIdentifier;
|
||||||
@ -63,13 +59,12 @@ public class SeekbarPreviewThumbnailHolder {
|
|||||||
executorService.shutdown();
|
executorService.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void resetFromAsync(
|
private void resetFromAsync(final int seekbarPreviewType, final List<Frameset> framesets,
|
||||||
final int seekbarPreviewType,
|
|
||||||
final List<Frameset> framesets,
|
|
||||||
final UUID updateRequestIdentifier) {
|
final UUID updateRequestIdentifier) {
|
||||||
|
|
||||||
Log.d(TAG, "Clearing seekbarPreviewData");
|
Log.d(TAG, "Clearing seekbarPreviewData");
|
||||||
|
synchronized (seekbarPreviewData) {
|
||||||
seekbarPreviewData.clear();
|
seekbarPreviewData.clear();
|
||||||
|
}
|
||||||
|
|
||||||
if (seekbarPreviewType == SeekbarPreviewThumbnailType.NONE) {
|
if (seekbarPreviewType == SeekbarPreviewThumbnailType.NONE) {
|
||||||
Log.d(TAG, "Not processing seekbarPreviewData due to settings");
|
Log.d(TAG, "Not processing seekbarPreviewData due to settings");
|
||||||
@ -94,10 +89,8 @@ public class SeekbarPreviewThumbnailHolder {
|
|||||||
generateDataFrom(frameset, updateRequestIdentifier);
|
generateDataFrom(frameset, updateRequestIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Frameset getFrameSetForType(
|
private Frameset getFrameSetForType(final List<Frameset> framesets,
|
||||||
final List<Frameset> framesets,
|
|
||||||
final int seekbarPreviewType) {
|
final int seekbarPreviewType) {
|
||||||
|
|
||||||
if (seekbarPreviewType == SeekbarPreviewThumbnailType.HIGH_QUALITY) {
|
if (seekbarPreviewType == SeekbarPreviewThumbnailType.HIGH_QUALITY) {
|
||||||
Log.d(TAG, "Strategy for seekbarPreviewData: high quality");
|
Log.d(TAG, "Strategy for seekbarPreviewData: high quality");
|
||||||
return framesets.stream()
|
return framesets.stream()
|
||||||
@ -111,17 +104,14 @@ public class SeekbarPreviewThumbnailHolder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void generateDataFrom(
|
private void generateDataFrom(final Frameset frameset, final UUID updateRequestIdentifier) {
|
||||||
final Frameset frameset,
|
|
||||||
final UUID updateRequestIdentifier) {
|
|
||||||
|
|
||||||
Log.d(TAG, "Starting generation of seekbarPreviewData");
|
Log.d(TAG, "Starting generation of seekbarPreviewData");
|
||||||
final Stopwatch sw = Log.isLoggable(TAG, Log.DEBUG) ? Stopwatch.createStarted() : null;
|
final Stopwatch sw = Log.isLoggable(TAG, Log.DEBUG) ? Stopwatch.createStarted() : null;
|
||||||
|
|
||||||
int currentPosMs = 0;
|
int currentPosMs = 0;
|
||||||
int pos = 1;
|
int pos = 1;
|
||||||
|
|
||||||
final int frameCountPerUrl = frameset.getFramesPerPageX() * frameset.getFramesPerPageY();
|
final int urlFrameCount = frameset.getFramesPerPageX() * frameset.getFramesPerPageY();
|
||||||
|
|
||||||
// Process each url in the frameset
|
// Process each url in the frameset
|
||||||
for (final String url : frameset.getUrls()) {
|
for (final String url : frameset.getUrls()) {
|
||||||
@ -130,11 +120,11 @@ public class SeekbarPreviewThumbnailHolder {
|
|||||||
|
|
||||||
// The data is not added directly to "seekbarPreviewData" due to
|
// The data is not added directly to "seekbarPreviewData" due to
|
||||||
// concurrency and checks for "updateRequestIdentifier"
|
// concurrency and checks for "updateRequestIdentifier"
|
||||||
final Map<Integer, Supplier<Bitmap>> generatedDataForUrl = new HashMap<>();
|
final var generatedDataForUrl = new SparseArrayCompat<Supplier<Bitmap>>(urlFrameCount);
|
||||||
|
|
||||||
// The bitmap consists of several images, which we process here
|
// The bitmap consists of several images, which we process here
|
||||||
// foreach frame in the returned bitmap
|
// foreach frame in the returned bitmap
|
||||||
for (int i = 0; i < frameCountPerUrl; i++) {
|
for (int i = 0; i < urlFrameCount; i++) {
|
||||||
// Frames outside the video length are skipped
|
// Frames outside the video length are skipped
|
||||||
if (pos > frameset.getTotalCount()) {
|
if (pos > frameset.getTotalCount()) {
|
||||||
break;
|
break;
|
||||||
@ -161,7 +151,9 @@ public class SeekbarPreviewThumbnailHolder {
|
|||||||
// Check if we are still the latest request
|
// Check if we are still the latest request
|
||||||
// If not abort method execution
|
// If not abort method execution
|
||||||
if (isRequestIdentifierCurrent(updateRequestIdentifier)) {
|
if (isRequestIdentifierCurrent(updateRequestIdentifier)) {
|
||||||
|
synchronized (seekbarPreviewData) {
|
||||||
seekbarPreviewData.putAll(generatedDataForUrl);
|
seekbarPreviewData.putAll(generatedDataForUrl);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "Aborted of generation of seekbarPreviewData");
|
Log.d(TAG, "Aborted of generation of seekbarPreviewData");
|
||||||
break;
|
break;
|
||||||
@ -169,7 +161,7 @@ public class SeekbarPreviewThumbnailHolder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (sw != null) {
|
if (sw != null) {
|
||||||
Log.d(TAG, "Generation of seekbarPreviewData took " + sw.stop().toString());
|
Log.d(TAG, "Generation of seekbarPreviewData took " + sw.stop());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -189,17 +181,14 @@ public class SeekbarPreviewThumbnailHolder {
|
|||||||
final Bitmap bitmap = PicassoHelper.loadSeekbarThumbnailPreview(url).get();
|
final Bitmap bitmap = PicassoHelper.loadSeekbarThumbnailPreview(url).get();
|
||||||
|
|
||||||
if (sw != null) {
|
if (sw != null) {
|
||||||
Log.d(TAG,
|
Log.d(TAG, "Download of bitmap for seekbarPreview from '" + url + "' took "
|
||||||
"Download of bitmap for seekbarPreview from '" + url
|
+ sw.stop());
|
||||||
+ "' took " + sw.stop().toString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return bitmap;
|
return bitmap;
|
||||||
} catch (final Exception ex) {
|
} catch (final Exception ex) {
|
||||||
Log.w(TAG,
|
Log.w(TAG, "Failed to get bitmap for seekbarPreview from url='" + url
|
||||||
"Failed to get bitmap for seekbarPreview from url='" + url
|
+ "' in time", ex);
|
||||||
+ "' in time",
|
|
||||||
ex);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -208,32 +197,20 @@ public class SeekbarPreviewThumbnailHolder {
|
|||||||
return this.currentUpdateRequestIdentifier.equals(requestIdentifier);
|
return this.currentUpdateRequestIdentifier.equals(requestIdentifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public Optional<Bitmap> getBitmapAt(final int positionInMs) {
|
public Optional<Bitmap> getBitmapAt(final int positionInMs) {
|
||||||
// Check if the BitmapData is empty
|
// Get the frame supplier closest to the requested position
|
||||||
if (seekbarPreviewData.isEmpty()) {
|
Supplier<Bitmap> closestFrame = () -> null;
|
||||||
return Optional.empty();
|
synchronized (seekbarPreviewData) {
|
||||||
|
int min = Integer.MAX_VALUE;
|
||||||
|
for (int i = 0; i < seekbarPreviewData.size(); i++) {
|
||||||
|
final int pos = Math.abs(seekbarPreviewData.keyAt(i) - positionInMs);
|
||||||
|
if (pos < min) {
|
||||||
|
closestFrame = seekbarPreviewData.valueAt(i);
|
||||||
|
min = pos;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the closest frame to the requested position
|
return Optional.ofNullable(closestFrame.get());
|
||||||
final int closestIndexPosition =
|
|
||||||
seekbarPreviewData.keySet().stream()
|
|
||||||
.min(Comparator.comparingInt(i -> Math.abs(i - positionInMs)))
|
|
||||||
.orElse(-1);
|
|
||||||
|
|
||||||
// this should never happen, because
|
|
||||||
// it indicates that "seekbarPreviewData" is empty which was already checked
|
|
||||||
if (closestIndexPosition == -1) {
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get the bitmap for the position (executes the supplier)
|
|
||||||
return Optional.ofNullable(seekbarPreviewData.get(closestIndexPosition).get());
|
|
||||||
} catch (final Exception ex) {
|
|
||||||
// If there is an error, log it and return Optional.empty
|
|
||||||
Log.w(TAG, "Unable to get seekbar preview", ex);
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -32,7 +32,6 @@ import android.view.KeyEvent;
|
|||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
import android.view.ViewParent;
|
import android.view.ViewParent;
|
||||||
import android.view.WindowManager;
|
|
||||||
import android.widget.FrameLayout;
|
import android.widget.FrameLayout;
|
||||||
import android.widget.LinearLayout;
|
import android.widget.LinearLayout;
|
||||||
|
|
||||||
@ -40,6 +39,8 @@ import androidx.annotation.NonNull;
|
|||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
import androidx.appcompat.content.res.AppCompatResources;
|
import androidx.appcompat.content.res.AppCompatResources;
|
||||||
|
import androidx.core.view.WindowCompat;
|
||||||
|
import androidx.core.view.WindowInsetsCompat;
|
||||||
import androidx.fragment.app.FragmentActivity;
|
import androidx.fragment.app.FragmentActivity;
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
@ -74,6 +75,7 @@ import org.schabi.newpipe.util.NavigationHelper;
|
|||||||
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
import org.schabi.newpipe.util.external_communication.KoreUtils;
|
||||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@ -154,6 +156,16 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
|||||||
protected void initListeners() {
|
protected void initListeners() {
|
||||||
super.initListeners();
|
super.initListeners();
|
||||||
|
|
||||||
|
binding.screenRotationButton.setOnClickListener(makeOnClickListener(() -> {
|
||||||
|
// Only if it's not a vertical video or vertical video but in landscape with locked
|
||||||
|
// orientation a screen orientation can be changed automatically
|
||||||
|
if (!isVerticalVideo || (isLandscape() && globalScreenOrientationLocked(context))) {
|
||||||
|
player.getFragmentListener()
|
||||||
|
.ifPresent(PlayerServiceEventListener::onScreenRotationButtonClicked);
|
||||||
|
} else {
|
||||||
|
toggleFullscreen();
|
||||||
|
}
|
||||||
|
}));
|
||||||
binding.queueButton.setOnClickListener(v -> onQueueClicked());
|
binding.queueButton.setOnClickListener(v -> onQueueClicked());
|
||||||
binding.segmentsButton.setOnClickListener(v -> onSegmentsClicked());
|
binding.segmentsButton.setOnClickListener(v -> onSegmentsClicked());
|
||||||
|
|
||||||
@ -173,6 +185,14 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
|||||||
settingsContentObserver);
|
settingsContentObserver);
|
||||||
|
|
||||||
binding.getRoot().addOnLayoutChangeListener(this);
|
binding.getRoot().addOnLayoutChangeListener(this);
|
||||||
|
|
||||||
|
binding.moreOptionsButton.setOnLongClickListener(v -> {
|
||||||
|
player.getFragmentListener()
|
||||||
|
.ifPresent(PlayerServiceEventListener::onMoreOptionsLongClicked);
|
||||||
|
hideControls(0, 0);
|
||||||
|
hideSystemUIIfNeeded();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -433,11 +453,9 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
|||||||
getParentActivity().map(Activity::getWindow).ifPresent(window -> {
|
getParentActivity().map(Activity::getWindow).ifPresent(window -> {
|
||||||
window.setStatusBarColor(Color.TRANSPARENT);
|
window.setStatusBarColor(Color.TRANSPARENT);
|
||||||
window.setNavigationBarColor(Color.TRANSPARENT);
|
window.setNavigationBarColor(Color.TRANSPARENT);
|
||||||
final int visibility = View.SYSTEM_UI_FLAG_LAYOUT_STABLE
|
WindowCompat.setDecorFitsSystemWindows(window, false);
|
||||||
| View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
|
WindowCompat.getInsetsController(window, window.getDecorView())
|
||||||
| View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION;
|
.show(WindowInsetsCompat.Type.systemBars());
|
||||||
window.getDecorView().setSystemUiVisibility(visibility);
|
|
||||||
window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -728,15 +746,10 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
|||||||
}
|
}
|
||||||
|
|
||||||
private int getNearestStreamSegmentPosition(final long playbackPosition) {
|
private int getNearestStreamSegmentPosition(final long playbackPosition) {
|
||||||
//noinspection SimplifyOptionalCallChains
|
|
||||||
if (!player.getCurrentStreamInfo().isPresent()) {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int nearestPosition = 0;
|
int nearestPosition = 0;
|
||||||
final List<StreamSegment> segments = player.getCurrentStreamInfo()
|
final List<StreamSegment> segments = player.getCurrentStreamInfo()
|
||||||
.get()
|
.map(StreamInfo::getStreamSegments)
|
||||||
.getStreamSegments();
|
.orElse(Collections.emptyList());
|
||||||
|
|
||||||
for (int i = 0; i < segments.size(); i++) {
|
for (int i = 0; i < segments.size(); i++) {
|
||||||
if (segments.get(i).getStartTimeSeconds() * 1000L > playbackPosition) {
|
if (segments.get(i).getStartTimeSeconds() * 1000L > playbackPosition) {
|
||||||
@ -846,45 +859,13 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
|||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
//region Click listeners
|
//region Click listeners
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onClick(final View v) {
|
|
||||||
if (v.getId() == binding.screenRotationButton.getId()) {
|
|
||||||
// Only if it's not a vertical video or vertical video but in landscape with locked
|
|
||||||
// orientation a screen orientation can be changed automatically
|
|
||||||
if (!isVerticalVideo || (isLandscape() && globalScreenOrientationLocked(context))) {
|
|
||||||
player.getFragmentListener().ifPresent(
|
|
||||||
PlayerServiceEventListener::onScreenRotationButtonClicked);
|
|
||||||
} else {
|
|
||||||
toggleFullscreen();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// call it later since it calls manageControlsAfterOnClick at the end
|
|
||||||
super.onClick(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onPlaybackSpeedClicked() {
|
protected void onPlaybackSpeedClicked() {
|
||||||
final AppCompatActivity activity = getParentActivity().orElse(null);
|
getParentActivity().ifPresent(activity ->
|
||||||
if (activity == null) {
|
PlaybackParameterDialog.newInstance(player.getPlaybackSpeed(),
|
||||||
return;
|
player.getPlaybackPitch(), player.getPlaybackSkipSilence(),
|
||||||
}
|
player::setPlaybackParameters)
|
||||||
|
.show(activity.getSupportFragmentManager(), null));
|
||||||
PlaybackParameterDialog.newInstance(player.getPlaybackSpeed(), player.getPlaybackPitch(),
|
|
||||||
player.getPlaybackSkipSilence(), player::setPlaybackParameters)
|
|
||||||
.show(activity.getSupportFragmentManager(), null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onLongClick(final View v) {
|
|
||||||
if (v.getId() == binding.moreOptionsButton.getId() && isFullscreen) {
|
|
||||||
player.getFragmentListener().ifPresent(
|
|
||||||
PlayerServiceEventListener::onMoreOptionsLongClicked);
|
|
||||||
hideControls(0, 0);
|
|
||||||
hideSystemUIIfNeeded();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return super.onLongClick(v);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -984,22 +965,22 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
|||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
//region Getters
|
//region Getters
|
||||||
|
|
||||||
|
private Optional<Context> getParentContext() {
|
||||||
|
return Optional.ofNullable(binding.getRoot().getParent())
|
||||||
|
.filter(ViewGroup.class::isInstance)
|
||||||
|
.map(parent -> ((ViewGroup) parent).getContext());
|
||||||
|
}
|
||||||
|
|
||||||
public Optional<AppCompatActivity> getParentActivity() {
|
public Optional<AppCompatActivity> getParentActivity() {
|
||||||
final ViewParent rootParent = binding.getRoot().getParent();
|
return getParentContext()
|
||||||
if (rootParent instanceof ViewGroup) {
|
.filter(AppCompatActivity.class::isInstance)
|
||||||
final Context activity = ((ViewGroup) rootParent).getContext();
|
.map(AppCompatActivity.class::cast);
|
||||||
if (activity instanceof AppCompatActivity) {
|
|
||||||
return Optional.of((AppCompatActivity) activity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Optional.empty();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isLandscape() {
|
public boolean isLandscape() {
|
||||||
// DisplayMetrics from activity context knows about MultiWindow feature
|
// DisplayMetrics from activity context knows about MultiWindow feature
|
||||||
// while DisplayMetrics from app context doesn't
|
// while DisplayMetrics from app context doesn't
|
||||||
return DeviceUtils.isLandscape(
|
return DeviceUtils.isLandscape(getParentContext().orElse(player.getService()));
|
||||||
getParentActivity().map(Context.class::cast).orElse(player.getService()));
|
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
}
|
}
|
||||||
|
@ -42,6 +42,7 @@ import androidx.annotation.Nullable;
|
|||||||
import androidx.appcompat.content.res.AppCompatResources;
|
import androidx.appcompat.content.res.AppCompatResources;
|
||||||
import androidx.appcompat.view.ContextThemeWrapper;
|
import androidx.appcompat.view.ContextThemeWrapper;
|
||||||
import androidx.appcompat.widget.PopupMenu;
|
import androidx.appcompat.widget.PopupMenu;
|
||||||
|
import androidx.core.graphics.BitmapCompat;
|
||||||
import androidx.core.graphics.Insets;
|
import androidx.core.graphics.Insets;
|
||||||
import androidx.core.math.MathUtils;
|
import androidx.core.math.MathUtils;
|
||||||
import androidx.core.view.ViewCompat;
|
import androidx.core.view.ViewCompat;
|
||||||
@ -83,11 +84,11 @@ import org.schabi.newpipe.util.external_communication.ShareUtils;
|
|||||||
import org.schabi.newpipe.views.player.PlayerFastSeekOverlay;
|
import org.schabi.newpipe.views.player.PlayerFastSeekOverlay;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
public abstract class VideoPlayerUi extends PlayerUi
|
public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBarChangeListener,
|
||||||
implements SeekBar.OnSeekBarChangeListener, View.OnClickListener, View.OnLongClickListener,
|
|
||||||
PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener {
|
PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener {
|
||||||
private static final String TAG = VideoPlayerUi.class.getSimpleName();
|
private static final String TAG = VideoPlayerUi.class.getSimpleName();
|
||||||
|
|
||||||
@ -131,9 +132,11 @@ public abstract class VideoPlayerUi extends PlayerUi
|
|||||||
|
|
||||||
private GestureDetector gestureDetector;
|
private GestureDetector gestureDetector;
|
||||||
private BasePlayerGestureListener playerGestureListener;
|
private BasePlayerGestureListener playerGestureListener;
|
||||||
@Nullable private View.OnLayoutChangeListener onLayoutChangeListener = null;
|
@Nullable
|
||||||
|
private View.OnLayoutChangeListener onLayoutChangeListener = null;
|
||||||
|
|
||||||
@NonNull private final SeekbarPreviewThumbnailHolder seekbarPreviewThumbnailHolder =
|
@NonNull
|
||||||
|
private final SeekbarPreviewThumbnailHolder seekbarPreviewThumbnailHolder =
|
||||||
new SeekbarPreviewThumbnailHolder();
|
new SeekbarPreviewThumbnailHolder();
|
||||||
|
|
||||||
|
|
||||||
@ -186,13 +189,13 @@ public abstract class VideoPlayerUi extends PlayerUi
|
|||||||
abstract BasePlayerGestureListener buildGestureListener();
|
abstract BasePlayerGestureListener buildGestureListener();
|
||||||
|
|
||||||
protected void initListeners() {
|
protected void initListeners() {
|
||||||
binding.qualityTextView.setOnClickListener(this);
|
binding.qualityTextView.setOnClickListener(makeOnClickListener(this::onQualityClicked));
|
||||||
binding.playbackSpeed.setOnClickListener(this);
|
binding.playbackSpeed.setOnClickListener(makeOnClickListener(this::onPlaybackSpeedClicked));
|
||||||
|
|
||||||
binding.playbackSeekBar.setOnSeekBarChangeListener(this);
|
binding.playbackSeekBar.setOnSeekBarChangeListener(this);
|
||||||
binding.captionTextView.setOnClickListener(this);
|
binding.captionTextView.setOnClickListener(makeOnClickListener(this::onCaptionClicked));
|
||||||
binding.resizeTextView.setOnClickListener(this);
|
binding.resizeTextView.setOnClickListener(makeOnClickListener(this::onResizeClicked));
|
||||||
binding.playbackLiveSync.setOnClickListener(this);
|
binding.playbackLiveSync.setOnClickListener(makeOnClickListener(player::seekToDefault));
|
||||||
|
|
||||||
playerGestureListener = buildGestureListener();
|
playerGestureListener = buildGestureListener();
|
||||||
gestureDetector = new GestureDetector(context, playerGestureListener);
|
gestureDetector = new GestureDetector(context, playerGestureListener);
|
||||||
@ -201,20 +204,36 @@ public abstract class VideoPlayerUi extends PlayerUi
|
|||||||
binding.repeatButton.setOnClickListener(v -> onRepeatClicked());
|
binding.repeatButton.setOnClickListener(v -> onRepeatClicked());
|
||||||
binding.shuffleButton.setOnClickListener(v -> onShuffleClicked());
|
binding.shuffleButton.setOnClickListener(v -> onShuffleClicked());
|
||||||
|
|
||||||
binding.playPauseButton.setOnClickListener(this);
|
binding.playPauseButton.setOnClickListener(makeOnClickListener(player::playPause));
|
||||||
binding.playPreviousButton.setOnClickListener(this);
|
binding.playPreviousButton.setOnClickListener(makeOnClickListener(player::playPrevious));
|
||||||
binding.playNextButton.setOnClickListener(this);
|
binding.playNextButton.setOnClickListener(makeOnClickListener(player::playNext));
|
||||||
|
|
||||||
binding.moreOptionsButton.setOnClickListener(this);
|
binding.moreOptionsButton.setOnClickListener(
|
||||||
binding.moreOptionsButton.setOnLongClickListener(this);
|
makeOnClickListener(this::onMoreOptionsClicked));
|
||||||
binding.share.setOnClickListener(this);
|
binding.share.setOnClickListener(makeOnClickListener(() -> {
|
||||||
binding.share.setOnLongClickListener(this);
|
final PlayQueueItem currentItem = player.getCurrentItem();
|
||||||
binding.fullScreenButton.setOnClickListener(this);
|
if (currentItem != null) {
|
||||||
binding.screenRotationButton.setOnClickListener(this);
|
ShareUtils.shareText(context, currentItem.getTitle(),
|
||||||
binding.playWithKodi.setOnClickListener(this);
|
player.getVideoUrlAtCurrentTime(), currentItem.getThumbnailUrl());
|
||||||
binding.openInBrowser.setOnClickListener(this);
|
}
|
||||||
binding.playerCloseButton.setOnClickListener(this);
|
}));
|
||||||
binding.switchMute.setOnClickListener(this);
|
binding.share.setOnLongClickListener(v -> {
|
||||||
|
ShareUtils.copyToClipboard(context, player.getVideoUrlAtCurrentTime());
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
binding.fullScreenButton.setOnClickListener(makeOnClickListener(() -> {
|
||||||
|
player.setRecovery();
|
||||||
|
NavigationHelper.playOnMainPlayer(context,
|
||||||
|
Objects.requireNonNull(player.getPlayQueue()), true);
|
||||||
|
}));
|
||||||
|
binding.playWithKodi.setOnClickListener(makeOnClickListener(this::onPlayWithKodiClicked));
|
||||||
|
binding.openInBrowser.setOnClickListener(makeOnClickListener(this::onOpenInBrowserClicked));
|
||||||
|
binding.playerCloseButton.setOnClickListener(makeOnClickListener(() ->
|
||||||
|
// set package to this app's package to prevent the intent from being seen outside
|
||||||
|
context.sendBroadcast(new Intent(VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER)
|
||||||
|
.setPackage(App.PACKAGE_NAME))
|
||||||
|
));
|
||||||
|
binding.switchMute.setOnClickListener(makeOnClickListener(player::toggleMute));
|
||||||
|
|
||||||
ViewCompat.setOnApplyWindowInsetsListener(binding.itemsListPanel, (view, windowInsets) -> {
|
ViewCompat.setOnApplyWindowInsetsListener(binding.itemsListPanel, (view, windowInsets) -> {
|
||||||
final Insets cutout = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout());
|
final Insets cutout = windowInsets.getInsets(WindowInsetsCompat.Type.displayCutout());
|
||||||
@ -228,11 +247,8 @@ public abstract class VideoPlayerUi extends PlayerUi
|
|||||||
// player_overlays and fast_seek_overlay too. Without it they will be off-centered.
|
// player_overlays and fast_seek_overlay too. Without it they will be off-centered.
|
||||||
onLayoutChangeListener =
|
onLayoutChangeListener =
|
||||||
(v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
|
(v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom) -> {
|
||||||
binding.playerOverlays.setPadding(
|
binding.playerOverlays.setPadding(v.getPaddingLeft(), v.getPaddingTop(),
|
||||||
v.getPaddingLeft(),
|
v.getPaddingRight(), v.getPaddingBottom());
|
||||||
v.getPaddingTop(),
|
|
||||||
v.getPaddingRight(),
|
|
||||||
v.getPaddingBottom());
|
|
||||||
|
|
||||||
// If we added padding to the fast seek overlay, too, it would not go under the
|
// If we added padding to the fast seek overlay, too, it would not go under the
|
||||||
// system ui. Instead we apply negative margins equal to the window insets of
|
// system ui. Instead we apply negative margins equal to the window insets of
|
||||||
@ -455,10 +471,11 @@ public abstract class VideoPlayerUi extends PlayerUi
|
|||||||
}
|
}
|
||||||
|
|
||||||
final float endScreenHeight = calculateMaxEndScreenThumbnailHeight(thumbnail);
|
final float endScreenHeight = calculateMaxEndScreenThumbnailHeight(thumbnail);
|
||||||
final Bitmap endScreenBitmap = Bitmap.createScaledBitmap(
|
final Bitmap endScreenBitmap = BitmapCompat.createScaledBitmap(
|
||||||
thumbnail,
|
thumbnail,
|
||||||
(int) (thumbnail.getWidth() / (thumbnail.getHeight() / endScreenHeight)),
|
(int) (thumbnail.getWidth() / (thumbnail.getHeight() / endScreenHeight)),
|
||||||
(int) endScreenHeight,
|
(int) endScreenHeight,
|
||||||
|
null,
|
||||||
true);
|
true);
|
||||||
|
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
@ -549,7 +566,7 @@ public abstract class VideoPlayerUi extends PlayerUi
|
|||||||
SeekbarPreviewThumbnailHelper
|
SeekbarPreviewThumbnailHelper
|
||||||
.tryResizeAndSetSeekbarPreviewThumbnail(
|
.tryResizeAndSetSeekbarPreviewThumbnail(
|
||||||
player.getContext(),
|
player.getContext(),
|
||||||
seekbarPreviewThumbnailHolder.getBitmapAt(progress),
|
seekbarPreviewThumbnailHolder.getBitmapAt(progress).orElse(null),
|
||||||
binding.currentSeekbarPreviewThumbnail,
|
binding.currentSeekbarPreviewThumbnail,
|
||||||
binding.subtitleView::getWidth);
|
binding.subtitleView::getWidth);
|
||||||
|
|
||||||
@ -601,11 +618,6 @@ public abstract class VideoPlayerUi extends PlayerUi
|
|||||||
player.changeState(STATE_PAUSED_SEEK);
|
player.changeState(STATE_PAUSED_SEEK);
|
||||||
}
|
}
|
||||||
|
|
||||||
player.saveWasPlaying();
|
|
||||||
if (player.isPlaying()) {
|
|
||||||
player.getExoPlayer().pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
showControls(0);
|
showControls(0);
|
||||||
animate(binding.currentDisplaySeek, true, DEFAULT_CONTROLS_DURATION,
|
animate(binding.currentDisplaySeek, true, DEFAULT_CONTROLS_DURATION,
|
||||||
AnimationType.SCALE_AND_ALPHA);
|
AnimationType.SCALE_AND_ALPHA);
|
||||||
@ -620,7 +632,7 @@ public abstract class VideoPlayerUi extends PlayerUi
|
|||||||
}
|
}
|
||||||
|
|
||||||
player.seekTo(seekBar.getProgress());
|
player.seekTo(seekBar.getProgress());
|
||||||
if (player.wasPlaying() || player.getExoPlayer().getDuration() == seekBar.getProgress()) {
|
if (player.getExoPlayer().getDuration() == seekBar.getProgress()) {
|
||||||
player.getExoPlayer().play();
|
player.getExoPlayer().play();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -634,10 +646,9 @@ public abstract class VideoPlayerUi extends PlayerUi
|
|||||||
if (!player.isProgressLoopRunning()) {
|
if (!player.isProgressLoopRunning()) {
|
||||||
player.startProgressLoop();
|
player.startProgressLoop();
|
||||||
}
|
}
|
||||||
if (player.wasPlaying()) {
|
|
||||||
showControlsThenHide();
|
showControlsThenHide();
|
||||||
}
|
}
|
||||||
}
|
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
|
|
||||||
@ -971,12 +982,7 @@ public abstract class VideoPlayerUi extends PlayerUi
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void updateStreamRelatedViews() {
|
private void updateStreamRelatedViews() {
|
||||||
//noinspection SimplifyOptionalCallChains
|
player.getCurrentStreamInfo().ifPresent(info -> {
|
||||||
if (!player.getCurrentStreamInfo().isPresent()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final StreamInfo info = player.getCurrentStreamInfo().get();
|
|
||||||
|
|
||||||
binding.qualityTextView.setVisibility(View.GONE);
|
binding.qualityTextView.setVisibility(View.GONE);
|
||||||
binding.playbackSpeed.setVisibility(View.GONE);
|
binding.playbackSpeed.setVisibility(View.GONE);
|
||||||
|
|
||||||
@ -1005,9 +1011,8 @@ public abstract class VideoPlayerUi extends PlayerUi
|
|||||||
|
|
||||||
case VIDEO_STREAM:
|
case VIDEO_STREAM:
|
||||||
case POST_LIVE_STREAM:
|
case POST_LIVE_STREAM:
|
||||||
//noinspection SimplifyOptionalCallChains
|
|
||||||
if (player.getCurrentMetadata() != null
|
if (player.getCurrentMetadata() != null
|
||||||
&& !player.getCurrentMetadata().getMaybeQuality().isPresent()
|
&& player.getCurrentMetadata().getMaybeQuality().isEmpty()
|
||||||
|| (info.getVideoStreams().isEmpty()
|
|| (info.getVideoStreams().isEmpty()
|
||||||
&& info.getVideoOnlyStreams().isEmpty())) {
|
&& info.getVideoOnlyStreams().isEmpty())) {
|
||||||
break;
|
break;
|
||||||
@ -1026,6 +1031,7 @@ public abstract class VideoPlayerUi extends PlayerUi
|
|||||||
|
|
||||||
buildPlaybackSpeedMenu();
|
buildPlaybackSpeedMenu();
|
||||||
binding.playbackSpeed.setVisibility(View.VISIBLE);
|
binding.playbackSpeed.setVisibility(View.VISIBLE);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|
||||||
@ -1054,12 +1060,11 @@ public abstract class VideoPlayerUi extends PlayerUi
|
|||||||
qualityPopupMenu.getMenu().add(POPUP_MENU_ID_QUALITY, i, Menu.NONE, MediaFormat
|
qualityPopupMenu.getMenu().add(POPUP_MENU_ID_QUALITY, i, Menu.NONE, MediaFormat
|
||||||
.getNameById(videoStream.getFormatId()) + " " + videoStream.getResolution());
|
.getNameById(videoStream.getFormatId()) + " " + videoStream.getResolution());
|
||||||
}
|
}
|
||||||
final VideoStream selectedVideoStream = player.getSelectedVideoStream();
|
|
||||||
if (selectedVideoStream != null) {
|
|
||||||
binding.qualityTextView.setText(selectedVideoStream.getResolution());
|
|
||||||
}
|
|
||||||
qualityPopupMenu.setOnMenuItemClickListener(this);
|
qualityPopupMenu.setOnMenuItemClickListener(this);
|
||||||
qualityPopupMenu.setOnDismissListener(this);
|
qualityPopupMenu.setOnDismissListener(this);
|
||||||
|
|
||||||
|
player.getSelectedVideoStream()
|
||||||
|
.ifPresent(s -> binding.qualityTextView.setText(s.getResolution()));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void buildPlaybackSpeedMenu() {
|
private void buildPlaybackSpeedMenu() {
|
||||||
@ -1165,14 +1170,9 @@ public abstract class VideoPlayerUi extends PlayerUi
|
|||||||
qualityPopupMenu.show();
|
qualityPopupMenu.show();
|
||||||
isSomePopupMenuVisible = true;
|
isSomePopupMenuVisible = true;
|
||||||
|
|
||||||
final VideoStream videoStream = player.getSelectedVideoStream();
|
player.getSelectedVideoStream()
|
||||||
if (videoStream != null) {
|
.map(s -> MediaFormat.getNameById(s.getFormatId()) + " " + s.getResolution())
|
||||||
//noinspection SetTextI18n
|
.ifPresent(binding.qualityTextView::setText);
|
||||||
binding.qualityTextView.setText(MediaFormat.getNameById(videoStream.getFormatId())
|
|
||||||
+ " " + videoStream.getResolution());
|
|
||||||
}
|
|
||||||
|
|
||||||
player.saveWasPlaying();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -1189,8 +1189,7 @@ public abstract class VideoPlayerUi extends PlayerUi
|
|||||||
if (menuItem.getGroupId() == POPUP_MENU_ID_QUALITY) {
|
if (menuItem.getGroupId() == POPUP_MENU_ID_QUALITY) {
|
||||||
final int menuItemIndex = menuItem.getItemId();
|
final int menuItemIndex = menuItem.getItemId();
|
||||||
@Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata();
|
@Nullable final MediaItemTag currentMetadata = player.getCurrentMetadata();
|
||||||
//noinspection SimplifyOptionalCallChains
|
if (currentMetadata == null || currentMetadata.getMaybeQuality().isEmpty()) {
|
||||||
if (currentMetadata == null || !currentMetadata.getMaybeQuality().isPresent()) {
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1229,10 +1228,9 @@ public abstract class VideoPlayerUi extends PlayerUi
|
|||||||
Log.d(TAG, "onDismiss() called with: menu = [" + menu + "]");
|
Log.d(TAG, "onDismiss() called with: menu = [" + menu + "]");
|
||||||
}
|
}
|
||||||
isSomePopupMenuVisible = false; //TODO check if this works
|
isSomePopupMenuVisible = false; //TODO check if this works
|
||||||
final VideoStream selectedVideoStream = player.getSelectedVideoStream();
|
player.getSelectedVideoStream()
|
||||||
if (selectedVideoStream != null) {
|
.ifPresent(s -> binding.qualityTextView.setText(s.getResolution()));
|
||||||
binding.qualityTextView.setText(selectedVideoStream.getResolution());
|
|
||||||
}
|
|
||||||
if (player.isPlaying()) {
|
if (player.isPlaying()) {
|
||||||
hideControls(DEFAULT_CONTROLS_DURATION, 0);
|
hideControls(DEFAULT_CONTROLS_DURATION, 0);
|
||||||
hideSystemUIIfNeeded();
|
hideSystemUIIfNeeded();
|
||||||
@ -1291,9 +1289,8 @@ public abstract class VideoPlayerUi extends PlayerUi
|
|||||||
|
|
||||||
// Build UI
|
// Build UI
|
||||||
buildCaptionMenu(availableLanguages);
|
buildCaptionMenu(availableLanguages);
|
||||||
//noinspection SimplifyOptionalCallChains
|
|
||||||
if (player.getTrackSelector().getParameters().getRendererDisabled(
|
if (player.getTrackSelector().getParameters().getRendererDisabled(
|
||||||
player.getCaptionRendererIndex()) || !selectedTracks.isPresent()) {
|
player.getCaptionRendererIndex()) || selectedTracks.isEmpty()) {
|
||||||
binding.captionTextView.setText(R.string.caption_none);
|
binding.captionTextView.setText(R.string.caption_none);
|
||||||
} else {
|
} else {
|
||||||
binding.captionTextView.setText(selectedTracks.get().language);
|
binding.captionTextView.setText(selectedTracks.get().language);
|
||||||
@ -1324,86 +1321,39 @@ public abstract class VideoPlayerUi extends PlayerUi
|
|||||||
//////////////////////////////////////////////////////////////////////////*/
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
//region Click listeners
|
//region Click listeners
|
||||||
|
|
||||||
@Override
|
/**
|
||||||
public void onClick(final View v) {
|
* Create on-click listener which manages the player controls after the view on-click action.
|
||||||
|
*
|
||||||
|
* @param runnable The action to be executed.
|
||||||
|
* @return The view click listener.
|
||||||
|
*/
|
||||||
|
protected View.OnClickListener makeOnClickListener(@NonNull final Runnable runnable) {
|
||||||
|
return v -> {
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
Log.d(TAG, "onClick() called with: v = [" + v + "]");
|
Log.d(TAG, "onClick() called with: v = [" + v + "]");
|
||||||
}
|
}
|
||||||
if (v.getId() == binding.resizeTextView.getId()) {
|
|
||||||
onResizeClicked();
|
|
||||||
} else if (v.getId() == binding.captionTextView.getId()) {
|
|
||||||
onCaptionClicked();
|
|
||||||
} else if (v.getId() == binding.playbackLiveSync.getId()) {
|
|
||||||
player.seekToDefault();
|
|
||||||
} else if (v.getId() == binding.playPauseButton.getId()) {
|
|
||||||
player.playPause();
|
|
||||||
} else if (v.getId() == binding.playPreviousButton.getId()) {
|
|
||||||
player.playPrevious();
|
|
||||||
} else if (v.getId() == binding.playNextButton.getId()) {
|
|
||||||
player.playNext();
|
|
||||||
} else if (v.getId() == binding.moreOptionsButton.getId()) {
|
|
||||||
onMoreOptionsClicked();
|
|
||||||
} else if (v.getId() == binding.share.getId()) {
|
|
||||||
final PlayQueueItem currentItem = player.getCurrentItem();
|
|
||||||
if (currentItem != null) {
|
|
||||||
ShareUtils.shareText(context, currentItem.getTitle(),
|
|
||||||
player.getVideoUrlAtCurrentTime(), currentItem.getThumbnailUrl());
|
|
||||||
}
|
|
||||||
} else if (v.getId() == binding.playWithKodi.getId()) {
|
|
||||||
onPlayWithKodiClicked();
|
|
||||||
} else if (v.getId() == binding.openInBrowser.getId()) {
|
|
||||||
onOpenInBrowserClicked();
|
|
||||||
} else if (v.getId() == binding.fullScreenButton.getId()) {
|
|
||||||
player.setRecovery();
|
|
||||||
NavigationHelper.playOnMainPlayer(context, player.getPlayQueue(), true);
|
|
||||||
return;
|
|
||||||
} else if (v.getId() == binding.switchMute.getId()) {
|
|
||||||
player.toggleMute();
|
|
||||||
} else if (v.getId() == binding.playerCloseButton.getId()) {
|
|
||||||
// set package to this app's package to prevent the intent from being seen outside
|
|
||||||
context.sendBroadcast(new Intent(VideoDetailFragment.ACTION_HIDE_MAIN_PLAYER)
|
|
||||||
.setPackage(App.PACKAGE_NAME));
|
|
||||||
} else if (v.getId() == binding.playbackSpeed.getId()) {
|
|
||||||
onPlaybackSpeedClicked();
|
|
||||||
} else if (v.getId() == binding.qualityTextView.getId()) {
|
|
||||||
onQualityClicked();
|
|
||||||
}
|
|
||||||
|
|
||||||
manageControlsAfterOnClick(v);
|
runnable.run();
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// Manages the player controls after handling the view click.
|
||||||
* Manages the controls after a click occurred on the player UI.
|
|
||||||
* @param v – The view that was clicked
|
|
||||||
*/
|
|
||||||
public void manageControlsAfterOnClick(@NonNull final View v) {
|
|
||||||
if (player.getCurrentState() == STATE_COMPLETED) {
|
if (player.getCurrentState() == STATE_COMPLETED) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
controlsVisibilityHandler.removeCallbacksAndMessages(null);
|
controlsVisibilityHandler.removeCallbacksAndMessages(null);
|
||||||
showHideShadow(true, DEFAULT_CONTROLS_DURATION);
|
showHideShadow(true, DEFAULT_CONTROLS_DURATION);
|
||||||
animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION,
|
animate(binding.playbackControlRoot, true, DEFAULT_CONTROLS_DURATION,
|
||||||
AnimationType.ALPHA, 0, () -> {
|
AnimationType.ALPHA, 0, () -> {
|
||||||
if (player.getCurrentState() == STATE_PLAYING && !isSomePopupMenuVisible) {
|
if (player.getCurrentState() == STATE_PLAYING && !isSomePopupMenuVisible) {
|
||||||
if (v.getId() == binding.playPauseButton.getId()
|
if (v == binding.playPauseButton
|
||||||
// Hide controls in fullscreen immediately
|
// Hide controls in fullscreen immediately
|
||||||
|| (v.getId() == binding.screenRotationButton.getId()
|
|| (v == binding.screenRotationButton && isFullscreen())) {
|
||||||
&& isFullscreen())) {
|
|
||||||
hideControls(0, 0);
|
hideControls(0, 0);
|
||||||
} else {
|
} else {
|
||||||
hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME);
|
hideControls(DEFAULT_CONTROLS_DURATION, DEFAULT_CONTROLS_HIDE_TIME);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onLongClick(final View v) {
|
|
||||||
if (v.getId() == binding.share.getId()) {
|
|
||||||
ShareUtils.copyToClipboard(context, player.getVideoUrlAtCurrentTime());
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean onKeyDown(final int keyCode) {
|
public boolean onKeyDown(final int keyCode) {
|
||||||
|
@ -44,7 +44,13 @@ public class AppearanceSettingsFragment extends BasePreferenceFragment {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
removePreference(nightThemeKey);
|
// disable the night theme selection
|
||||||
|
final Preference preference = findPreference(nightThemeKey);
|
||||||
|
if (preference != null) {
|
||||||
|
preference.setEnabled(false);
|
||||||
|
preference.setSummary(getString(R.string.night_theme_available,
|
||||||
|
getString(R.string.auto_device_theme_title)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,13 +67,6 @@ public class AppearanceSettingsFragment extends BasePreferenceFragment {
|
|||||||
return super.onPreferenceTreeClick(preference);
|
return super.onPreferenceTreeClick(preference);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void removePreference(final String preferenceKey) {
|
|
||||||
final Preference preference = findPreference(preferenceKey);
|
|
||||||
if (preference != null) {
|
|
||||||
getPreferenceScreen().removePreference(preference);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void applyThemeChange(final String beginningThemeKey,
|
private void applyThemeChange(final String beginningThemeKey,
|
||||||
final String themeKey,
|
final String themeKey,
|
||||||
final Object newValue) {
|
final Object newValue) {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package org.schabi.newpipe.settings;
|
package org.schabi.newpipe.settings;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.extractor.utils.Utils.decodeUrlUtf8;
|
||||||
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
|
||||||
|
|
||||||
import android.app.Activity;
|
import android.app.Activity;
|
||||||
@ -31,8 +32,6 @@ import java.io.File;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.UnsupportedEncodingException;
|
import java.io.UnsupportedEncodingException;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.URLDecoder;
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
public class DownloadSettingsFragment extends BasePreferenceFragment {
|
public class DownloadSettingsFragment extends BasePreferenceFragment {
|
||||||
public static final boolean IGNORE_RELEASE_ON_OLD_PATH = true;
|
public static final boolean IGNORE_RELEASE_ON_OLD_PATH = true;
|
||||||
@ -125,7 +124,7 @@ public class DownloadSettingsFragment extends BasePreferenceFragment {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
rawUri = URLDecoder.decode(rawUri, StandardCharsets.UTF_8.name());
|
rawUri = decodeUrlUtf8(rawUri);
|
||||||
} catch (final UnsupportedEncodingException e) {
|
} catch (final UnsupportedEncodingException e) {
|
||||||
// nothing to do
|
// nothing to do
|
||||||
}
|
}
|
||||||
|
@ -16,25 +16,17 @@ public class UpdateSettingsFragment extends BasePreferenceFragment {
|
|||||||
.apply();
|
.apply();
|
||||||
|
|
||||||
if (checkForUpdates) {
|
if (checkForUpdates) {
|
||||||
checkNewVersionNow();
|
NewVersionWorker.enqueueNewVersionCheckingWork(requireContext(), true);
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
private final Preference.OnPreferenceClickListener manualUpdateClick = preference -> {
|
private final Preference.OnPreferenceClickListener manualUpdateClick = preference -> {
|
||||||
Toast.makeText(getContext(), R.string.checking_updates_toast, Toast.LENGTH_SHORT).show();
|
Toast.makeText(getContext(), R.string.checking_updates_toast, Toast.LENGTH_SHORT).show();
|
||||||
checkNewVersionNow();
|
NewVersionWorker.enqueueNewVersionCheckingWork(requireContext(), true);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
private void checkNewVersionNow() {
|
|
||||||
// Search for updates immediately when update checks are enabled.
|
|
||||||
// Reset the expire time. This is necessary to check for an update immediately.
|
|
||||||
defaultPreferences.edit()
|
|
||||||
.putLong(getString(R.string.update_expiry_key), 0).apply();
|
|
||||||
NewVersionWorker.enqueueNewVersionCheckingWork(getContext());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
|
||||||
addPreferencesFromResourceRegistry();
|
addPreferencesFromResourceRegistry();
|
||||||
|
@ -27,14 +27,14 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment {
|
|||||||
|
|
||||||
updateSeekOptions();
|
updateSeekOptions();
|
||||||
|
|
||||||
listener = (sharedPreferences, s) -> {
|
listener = (sharedPreferences, key) -> {
|
||||||
|
|
||||||
// on M and above, if user chooses to minimise to popup player on exit
|
// on M and above, if user chooses to minimise to popup player on exit
|
||||||
// and the app doesn't have display over other apps permission,
|
// and the app doesn't have display over other apps permission,
|
||||||
// show a snackbar to let the user give permission
|
// show a snackbar to let the user give permission
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
|
||||||
&& s.equals(getString(R.string.minimize_on_exit_key))) {
|
&& getString(R.string.minimize_on_exit_key).equals(key)) {
|
||||||
final String newSetting = sharedPreferences.getString(s, null);
|
final String newSetting = sharedPreferences.getString(key, null);
|
||||||
if (newSetting != null
|
if (newSetting != null
|
||||||
&& newSetting.equals(getString(R.string.minimize_on_exit_popup_key))
|
&& newSetting.equals(getString(R.string.minimize_on_exit_popup_key))
|
||||||
&& !Settings.canDrawOverlays(getContext())) {
|
&& !Settings.canDrawOverlays(getContext())) {
|
||||||
@ -46,7 +46,7 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment {
|
|||||||
.show();
|
.show();
|
||||||
|
|
||||||
}
|
}
|
||||||
} else if (s.equals(getString(R.string.use_inexact_seek_key))) {
|
} else if (getString(R.string.use_inexact_seek_key).equals(key)) {
|
||||||
updateSeekOptions();
|
updateSeekOptions();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -1,15 +1,13 @@
|
|||||||
package org.schabi.newpipe.settings.notifications
|
package org.schabi.newpipe.settings.notifications
|
||||||
|
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.View
|
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.widget.CheckedTextView
|
|
||||||
import androidx.recyclerview.widget.AsyncListDiffer
|
|
||||||
import androidx.recyclerview.widget.DiffUtil
|
import androidx.recyclerview.widget.DiffUtil
|
||||||
|
import androidx.recyclerview.widget.ListAdapter
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
import org.schabi.newpipe.R
|
|
||||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity
|
||||||
|
import org.schabi.newpipe.databinding.ItemNotificationConfigBinding
|
||||||
import org.schabi.newpipe.settings.notifications.NotificationModeConfigAdapter.SubscriptionHolder
|
import org.schabi.newpipe.settings.notifications.NotificationModeConfigAdapter.SubscriptionHolder
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -19,75 +17,31 @@ import org.schabi.newpipe.settings.notifications.NotificationModeConfigAdapter.S
|
|||||||
*/
|
*/
|
||||||
class NotificationModeConfigAdapter(
|
class NotificationModeConfigAdapter(
|
||||||
private val listener: ModeToggleListener
|
private val listener: ModeToggleListener
|
||||||
) : RecyclerView.Adapter<SubscriptionHolder>() {
|
) : ListAdapter<SubscriptionItem, SubscriptionHolder>(DiffCallback) {
|
||||||
|
override fun onCreateViewHolder(parent: ViewGroup, i: Int): SubscriptionHolder {
|
||||||
private val differ = AsyncListDiffer(this, DiffCallback())
|
return SubscriptionHolder(
|
||||||
|
ItemNotificationConfigBinding
|
||||||
init {
|
.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||||
setHasStableIds(true)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onCreateViewHolder(viewGroup: ViewGroup, i: Int): SubscriptionHolder {
|
override fun onBindViewHolder(holder: SubscriptionHolder, position: Int) {
|
||||||
val view = LayoutInflater.from(viewGroup.context)
|
holder.bind(currentList[position])
|
||||||
.inflate(R.layout.item_notification_config, viewGroup, false)
|
|
||||||
return SubscriptionHolder(view, listener)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBindViewHolder(subscriptionHolder: SubscriptionHolder, i: Int) {
|
|
||||||
subscriptionHolder.bind(differ.currentList[i])
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getItem(position: Int): SubscriptionItem = differ.currentList[position]
|
|
||||||
|
|
||||||
override fun getItemCount() = differ.currentList.size
|
|
||||||
|
|
||||||
override fun getItemId(position: Int): Long {
|
|
||||||
return differ.currentList[position].id
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCurrentList(): List<SubscriptionItem> = differ.currentList
|
|
||||||
|
|
||||||
fun update(newData: List<SubscriptionEntity>) {
|
fun update(newData: List<SubscriptionEntity>) {
|
||||||
differ.submitList(
|
val items = newData.map {
|
||||||
newData.map {
|
SubscriptionItem(it.uid, it.name, it.notificationMode, it.serviceId, it.url)
|
||||||
SubscriptionItem(
|
|
||||||
id = it.uid,
|
|
||||||
title = it.name,
|
|
||||||
notificationMode = it.notificationMode,
|
|
||||||
serviceId = it.serviceId,
|
|
||||||
url = it.url
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
submitList(items)
|
||||||
}
|
}
|
||||||
|
|
||||||
data class SubscriptionItem(
|
inner class SubscriptionHolder(
|
||||||
val id: Long,
|
private val itemBinding: ItemNotificationConfigBinding
|
||||||
val title: String,
|
) : RecyclerView.ViewHolder(itemBinding.root) {
|
||||||
@NotificationMode
|
|
||||||
val notificationMode: Int,
|
|
||||||
val serviceId: Int,
|
|
||||||
val url: String
|
|
||||||
)
|
|
||||||
|
|
||||||
class SubscriptionHolder(
|
|
||||||
itemView: View,
|
|
||||||
private val listener: ModeToggleListener
|
|
||||||
) : RecyclerView.ViewHolder(itemView), View.OnClickListener {
|
|
||||||
|
|
||||||
private val checkedTextView = itemView as CheckedTextView
|
|
||||||
|
|
||||||
init {
|
init {
|
||||||
itemView.setOnClickListener(this)
|
itemView.setOnClickListener {
|
||||||
}
|
val mode = if (itemBinding.root.isChecked) {
|
||||||
|
|
||||||
fun bind(data: SubscriptionItem) {
|
|
||||||
checkedTextView.text = data.title
|
|
||||||
checkedTextView.isChecked = data.notificationMode != NotificationMode.DISABLED
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClick(v: View) {
|
|
||||||
val mode = if (checkedTextView.isChecked) {
|
|
||||||
NotificationMode.DISABLED
|
NotificationMode.DISABLED
|
||||||
} else {
|
} else {
|
||||||
NotificationMode.ENABLED
|
NotificationMode.ENABLED
|
||||||
@ -96,8 +50,13 @@ class NotificationModeConfigAdapter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class DiffCallback : DiffUtil.ItemCallback<SubscriptionItem>() {
|
fun bind(data: SubscriptionItem) {
|
||||||
|
itemBinding.root.text = data.title
|
||||||
|
itemBinding.root.isChecked = data.notificationMode != NotificationMode.DISABLED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private object DiffCallback : DiffUtil.ItemCallback<SubscriptionItem>() {
|
||||||
override fun areItemsTheSame(oldItem: SubscriptionItem, newItem: SubscriptionItem): Boolean {
|
override fun areItemsTheSame(oldItem: SubscriptionItem, newItem: SubscriptionItem): Boolean {
|
||||||
return oldItem.id == newItem.id
|
return oldItem.id == newItem.id
|
||||||
}
|
}
|
||||||
@ -107,18 +66,27 @@ class NotificationModeConfigAdapter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun getChangePayload(oldItem: SubscriptionItem, newItem: SubscriptionItem): Any? {
|
override fun getChangePayload(oldItem: SubscriptionItem, newItem: SubscriptionItem): Any? {
|
||||||
if (oldItem.notificationMode != newItem.notificationMode) {
|
return if (oldItem.notificationMode != newItem.notificationMode) {
|
||||||
return newItem.notificationMode
|
newItem.notificationMode
|
||||||
} else {
|
} else {
|
||||||
return super.getChangePayload(oldItem, newItem)
|
super.getChangePayload(oldItem, newItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ModeToggleListener {
|
fun interface ModeToggleListener {
|
||||||
/**
|
/**
|
||||||
* Triggered when the UI representation of a notification mode is changed.
|
* Triggered when the UI representation of a notification mode is changed.
|
||||||
*/
|
*/
|
||||||
fun onModeChange(position: Int, @NotificationMode mode: Int)
|
fun onModeChange(position: Int, @NotificationMode mode: Int)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class SubscriptionItem(
|
||||||
|
val id: Long,
|
||||||
|
val title: String,
|
||||||
|
@NotificationMode
|
||||||
|
val notificationMode: Int,
|
||||||
|
val serviceId: Int,
|
||||||
|
val url: String
|
||||||
|
)
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
package org.schabi.newpipe.settings.notifications
|
package org.schabi.newpipe.settings.notifications
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
import android.view.LayoutInflater
|
||||||
import android.view.Menu
|
import android.view.Menu
|
||||||
@ -8,30 +9,36 @@ import android.view.MenuItem
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import androidx.fragment.app.Fragment
|
import androidx.fragment.app.Fragment
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
import io.reactivex.rxjava3.disposables.Disposable
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
import org.schabi.newpipe.R
|
import org.schabi.newpipe.R
|
||||||
import org.schabi.newpipe.database.subscription.NotificationMode
|
import org.schabi.newpipe.database.subscription.NotificationMode
|
||||||
|
import org.schabi.newpipe.databinding.FragmentChannelsNotificationsBinding
|
||||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||||
import org.schabi.newpipe.settings.notifications.NotificationModeConfigAdapter.ModeToggleListener
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [NotificationModeConfigFragment] is a settings fragment
|
* [NotificationModeConfigFragment] is a settings fragment
|
||||||
* which allows changing the [NotificationMode] of all subscribed channels.
|
* which allows changing the [NotificationMode] of all subscribed channels.
|
||||||
* The [NotificationMode] can either be changed one by one or toggled for all channels.
|
* The [NotificationMode] can either be changed one by one or toggled for all channels.
|
||||||
*/
|
*/
|
||||||
class NotificationModeConfigFragment : Fragment(), ModeToggleListener {
|
class NotificationModeConfigFragment : Fragment() {
|
||||||
|
private var _binding: FragmentChannelsNotificationsBinding? = null
|
||||||
|
private val binding get() = _binding!!
|
||||||
|
|
||||||
private lateinit var updaters: CompositeDisposable
|
private val disposables = CompositeDisposable()
|
||||||
private var loader: Disposable? = null
|
private var loader: Disposable? = null
|
||||||
private var adapter: NotificationModeConfigAdapter? = null
|
private lateinit var adapter: NotificationModeConfigAdapter
|
||||||
|
private lateinit var subscriptionManager: SubscriptionManager
|
||||||
|
|
||||||
|
override fun onAttach(context: Context) {
|
||||||
|
super.onAttach(context)
|
||||||
|
subscriptionManager = SubscriptionManager(context)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
updaters = CompositeDisposable()
|
|
||||||
setHasOptionsMenu(true)
|
setHasOptionsMenu(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,28 +46,34 @@ class NotificationModeConfigFragment : Fragment(), ModeToggleListener {
|
|||||||
inflater: LayoutInflater,
|
inflater: LayoutInflater,
|
||||||
container: ViewGroup?,
|
container: ViewGroup?,
|
||||||
savedInstanceState: Bundle?,
|
savedInstanceState: Bundle?,
|
||||||
): View = inflater.inflate(R.layout.fragment_channels_notifications, container, false)
|
): View {
|
||||||
|
_binding = FragmentChannelsNotificationsBinding.inflate(inflater, container, false)
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||||
super.onViewCreated(view, savedInstanceState)
|
super.onViewCreated(view, savedInstanceState)
|
||||||
val recyclerView: RecyclerView = view.findViewById(R.id.recycler_view)
|
adapter = NotificationModeConfigAdapter { position, mode ->
|
||||||
adapter = NotificationModeConfigAdapter(this)
|
// Notification mode has been changed via the UI.
|
||||||
recyclerView.adapter = adapter
|
// Now change it in the database.
|
||||||
|
updateNotificationMode(adapter.currentList[position], mode)
|
||||||
|
}
|
||||||
|
binding.recyclerView.adapter = adapter
|
||||||
loader?.dispose()
|
loader?.dispose()
|
||||||
loader = SubscriptionManager(requireContext())
|
loader = subscriptionManager.subscriptions()
|
||||||
.subscriptions()
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe { newData -> adapter?.update(newData) }
|
.subscribe(adapter::update)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroyView() {
|
override fun onDestroyView() {
|
||||||
loader?.dispose()
|
loader?.dispose()
|
||||||
loader = null
|
loader = null
|
||||||
|
_binding = null
|
||||||
super.onDestroyView()
|
super.onDestroyView()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
updaters.dispose()
|
disposables.dispose()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,41 +92,20 @@ class NotificationModeConfigFragment : Fragment(), ModeToggleListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onModeChange(position: Int, @NotificationMode mode: Int) {
|
|
||||||
// Notification mode has been changed via the UI.
|
|
||||||
// Now change it in the database.
|
|
||||||
val subscription = adapter?.getItem(position) ?: return
|
|
||||||
updaters.add(
|
|
||||||
SubscriptionManager(requireContext())
|
|
||||||
.updateNotificationMode(
|
|
||||||
subscription.serviceId,
|
|
||||||
subscription.url,
|
|
||||||
mode
|
|
||||||
)
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.subscribe()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun toggleAll() {
|
private fun toggleAll() {
|
||||||
val subscriptions = adapter?.getCurrentList() ?: return
|
val mode = adapter.currentList.firstOrNull()?.notificationMode ?: return
|
||||||
val mode = subscriptions.firstOrNull()?.notificationMode ?: return
|
|
||||||
val newMode = when (mode) {
|
val newMode = when (mode) {
|
||||||
NotificationMode.DISABLED -> NotificationMode.ENABLED
|
NotificationMode.DISABLED -> NotificationMode.ENABLED
|
||||||
else -> NotificationMode.DISABLED
|
else -> NotificationMode.DISABLED
|
||||||
}
|
}
|
||||||
val subscriptionManager = SubscriptionManager(requireContext())
|
adapter.currentList.forEach { updateNotificationMode(it, newMode) }
|
||||||
updaters.add(
|
|
||||||
CompositeDisposable(
|
|
||||||
subscriptions.map { item ->
|
|
||||||
subscriptionManager.updateNotificationMode(
|
|
||||||
serviceId = item.serviceId,
|
|
||||||
url = item.url,
|
|
||||||
mode = newMode
|
|
||||||
).subscribeOn(Schedulers.io())
|
|
||||||
.subscribe()
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
private fun updateNotificationMode(item: SubscriptionItem, @NotificationMode mode: Int) {
|
||||||
|
disposables.add(
|
||||||
|
subscriptionManager.updateNotificationMode(item.serviceId, item.url, mode)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.subscribe()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -248,7 +248,7 @@ public abstract class Tab {
|
|||||||
@DrawableRes
|
@DrawableRes
|
||||||
@Override
|
@Override
|
||||||
public int getTabIconRes(final Context context) {
|
public int getTabIconRes(final Context context) {
|
||||||
return R.drawable.ic_rss_feed;
|
return R.drawable.ic_subscriptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -20,6 +20,7 @@ public final class TabsJsonHelper {
|
|||||||
|
|
||||||
private static final List<Tab> FALLBACK_INITIAL_TABS_LIST = List.of(
|
private static final List<Tab> FALLBACK_INITIAL_TABS_LIST = List.of(
|
||||||
Tab.Type.DEFAULT_KIOSK.getTab(),
|
Tab.Type.DEFAULT_KIOSK.getTab(),
|
||||||
|
Tab.Type.FEED.getTab(),
|
||||||
Tab.Type.SUBSCRIPTIONS.getTab(),
|
Tab.Type.SUBSCRIPTIONS.getTab(),
|
||||||
Tab.Type.BOOKMARKS.getTab());
|
Tab.Type.BOOKMARKS.getTab());
|
||||||
|
|
||||||
|
@ -73,7 +73,7 @@ public final class TabsManager {
|
|||||||
|
|
||||||
private SharedPreferences.OnSharedPreferenceChangeListener getPreferenceChangeListener() {
|
private SharedPreferences.OnSharedPreferenceChangeListener getPreferenceChangeListener() {
|
||||||
return (sp, key) -> {
|
return (sp, key) -> {
|
||||||
if (key.equals(savedTabsKey)) {
|
if (savedTabsKey.equals(key)) {
|
||||||
if (savedTabsChangeListener != null) {
|
if (savedTabsChangeListener != null) {
|
||||||
savedTabsChangeListener.onTabsChanged();
|
savedTabsChangeListener.onTabsChanged();
|
||||||
}
|
}
|
||||||
|
@ -652,7 +652,7 @@ public class WebMWriter implements Closeable {
|
|||||||
|
|
||||||
final int offset = withLength ? 1 : 0;
|
final int offset = withLength ? 1 : 0;
|
||||||
final byte[] buffer = new byte[offset + length];
|
final byte[] buffer = new byte[offset + length];
|
||||||
final long marker = (long) Math.floor((length - 1f) / 8f);
|
final long marker = Math.floorDiv(length - 1, 8);
|
||||||
|
|
||||||
int shift = 0;
|
int shift = 0;
|
||||||
for (int i = length - 1; i >= 0; i--, shift += 8) {
|
for (int i = length - 1; i >= 0; i--, shift += 8) {
|
||||||
|
@ -1,71 +0,0 @@
|
|||||||
package org.schabi.newpipe.util;
|
|
||||||
|
|
||||||
import android.text.Layout;
|
|
||||||
import android.text.Selection;
|
|
||||||
import android.text.Spannable;
|
|
||||||
import android.text.Spanned;
|
|
||||||
import android.text.style.ClickableSpan;
|
|
||||||
import android.text.style.URLSpan;
|
|
||||||
import android.view.MotionEvent;
|
|
||||||
import android.view.View;
|
|
||||||
import android.widget.TextView;
|
|
||||||
|
|
||||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
|
||||||
import org.schabi.newpipe.util.external_communication.InternalUrlsHandler;
|
|
||||||
|
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
|
||||||
|
|
||||||
public class CommentTextOnTouchListener implements View.OnTouchListener {
|
|
||||||
public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean onTouch(final View v, final MotionEvent event) {
|
|
||||||
if (!(v instanceof TextView)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
final TextView widget = (TextView) v;
|
|
||||||
final Object text = widget.getText();
|
|
||||||
if (text instanceof Spanned) {
|
|
||||||
final Spannable buffer = (Spannable) text;
|
|
||||||
|
|
||||||
final int action = event.getAction();
|
|
||||||
|
|
||||||
if (action == MotionEvent.ACTION_UP
|
|
||||||
|| action == MotionEvent.ACTION_DOWN) {
|
|
||||||
int x = (int) event.getX();
|
|
||||||
int y = (int) event.getY();
|
|
||||||
|
|
||||||
x -= widget.getTotalPaddingLeft();
|
|
||||||
y -= widget.getTotalPaddingTop();
|
|
||||||
|
|
||||||
x += widget.getScrollX();
|
|
||||||
y += widget.getScrollY();
|
|
||||||
|
|
||||||
final Layout layout = widget.getLayout();
|
|
||||||
final int line = layout.getLineForVertical(y);
|
|
||||||
final int off = layout.getOffsetForHorizontal(line, x);
|
|
||||||
|
|
||||||
final ClickableSpan[] link = buffer.getSpans(off, off,
|
|
||||||
ClickableSpan.class);
|
|
||||||
|
|
||||||
if (link.length != 0) {
|
|
||||||
if (action == MotionEvent.ACTION_UP) {
|
|
||||||
if (link[0] instanceof URLSpan) {
|
|
||||||
final String url = ((URLSpan) link[0]).getURL();
|
|
||||||
if (!InternalUrlsHandler.handleUrlCommentsTimestamp(
|
|
||||||
new CompositeDisposable(), v.getContext(), url)) {
|
|
||||||
ShareUtils.openUrlInBrowser(v.getContext(), url, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (action == MotionEvent.ACTION_DOWN) {
|
|
||||||
Selection.setSelection(buffer,
|
|
||||||
buffer.getSpanStart(link[0]),
|
|
||||||
buffer.getSpanEnd(link[0]));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,14 +1,19 @@
|
|||||||
package org.schabi.newpipe.util;
|
package org.schabi.newpipe.util;
|
||||||
|
|
||||||
|
import static android.content.Context.INPUT_SERVICE;
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
import android.app.UiModeManager;
|
import android.app.UiModeManager;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
import android.content.res.Configuration;
|
import android.content.res.Configuration;
|
||||||
import android.graphics.Point;
|
import android.graphics.Point;
|
||||||
|
import android.hardware.input.InputManager;
|
||||||
import android.os.BatteryManager;
|
import android.os.BatteryManager;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
import android.util.TypedValue;
|
import android.util.TypedValue;
|
||||||
|
import android.view.InputDevice;
|
||||||
import android.view.KeyEvent;
|
import android.view.KeyEvent;
|
||||||
import android.view.WindowInsets;
|
import android.view.WindowInsets;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
@ -22,9 +27,12 @@ import androidx.preference.PreferenceManager;
|
|||||||
import org.schabi.newpipe.App;
|
import org.schabi.newpipe.App;
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
|
||||||
public final class DeviceUtils {
|
public final class DeviceUtils {
|
||||||
|
|
||||||
private static final String AMAZON_FEATURE_FIRE_TV = "amazon.hardware.fire_tv";
|
private static final String AMAZON_FEATURE_FIRE_TV = "amazon.hardware.fire_tv";
|
||||||
|
private static final boolean SAMSUNG = Build.MANUFACTURER.equals("samsung");
|
||||||
private static Boolean isTV = null;
|
private static Boolean isTV = null;
|
||||||
private static Boolean isFireTV = null;
|
private static Boolean isFireTV = null;
|
||||||
|
|
||||||
@ -84,6 +92,82 @@ public final class DeviceUtils {
|
|||||||
return DeviceUtils.isTV;
|
return DeviceUtils.isTV;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the device is in desktop or DeX mode. This function should only
|
||||||
|
* be invoked once on view load as it is using reflection for the DeX checks.
|
||||||
|
* @param context the context to use for services and config.
|
||||||
|
* @return true if the Android device is in desktop mode or using DeX.
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("JavaReflectionMemberAccess")
|
||||||
|
public static boolean isDesktopMode(@NonNull final Context context) {
|
||||||
|
// Adapted from https://stackoverflow.com/a/64615568
|
||||||
|
// to check for all input devices that have an active cursor
|
||||||
|
final InputManager im = (InputManager) context.getSystemService(INPUT_SERVICE);
|
||||||
|
for (final int id : im.getInputDeviceIds()) {
|
||||||
|
final InputDevice inputDevice = im.getInputDevice(id);
|
||||||
|
if (inputDevice.supportsSource(InputDevice.SOURCE_BLUETOOTH_STYLUS)
|
||||||
|
|| inputDevice.supportsSource(InputDevice.SOURCE_MOUSE)
|
||||||
|
|| inputDevice.supportsSource(InputDevice.SOURCE_STYLUS)
|
||||||
|
|| inputDevice.supportsSource(InputDevice.SOURCE_TOUCHPAD)
|
||||||
|
|| inputDevice.supportsSource(InputDevice.SOURCE_TRACKBALL)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final UiModeManager uiModeManager =
|
||||||
|
ContextCompat.getSystemService(context, UiModeManager.class);
|
||||||
|
if (uiModeManager != null
|
||||||
|
&& uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_DESK) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SAMSUNG) {
|
||||||
|
return false;
|
||||||
|
// DeX is Samsung-specific, skip the checks below on non-Samsung devices
|
||||||
|
}
|
||||||
|
// DeX check for standalone and multi-window mode, from:
|
||||||
|
// https://developer.samsung.com/samsung-dex/modify-optimizing.html
|
||||||
|
try {
|
||||||
|
final Configuration config = context.getResources().getConfiguration();
|
||||||
|
final Class<?> configClass = config.getClass();
|
||||||
|
final int semDesktopModeEnabledConst =
|
||||||
|
configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass);
|
||||||
|
final int currentMode =
|
||||||
|
configClass.getField("semDesktopModeEnabled").getInt(config);
|
||||||
|
if (semDesktopModeEnabledConst == currentMode) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (final NoSuchFieldException | IllegalAccessException ignored) {
|
||||||
|
// Device doesn't seem to support DeX
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("WrongConstant") final Object desktopModeManager = context
|
||||||
|
.getApplicationContext()
|
||||||
|
.getSystemService("desktopmode");
|
||||||
|
|
||||||
|
if (desktopModeManager != null) {
|
||||||
|
try {
|
||||||
|
final Method getDesktopModeStateMethod = desktopModeManager.getClass()
|
||||||
|
.getDeclaredMethod("getDesktopModeState");
|
||||||
|
final Object desktopModeState = getDesktopModeStateMethod
|
||||||
|
.invoke(desktopModeManager);
|
||||||
|
final Class<?> desktopModeStateClass = desktopModeState.getClass();
|
||||||
|
final Method getEnabledMethod = desktopModeStateClass
|
||||||
|
.getDeclaredMethod("getEnabled");
|
||||||
|
final int enabledStatus = (int) getEnabledMethod.invoke(desktopModeState);
|
||||||
|
if (enabledStatus == desktopModeStateClass
|
||||||
|
.getDeclaredField("ENABLED").getInt(desktopModeStateClass)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (final Exception ignored) {
|
||||||
|
// Device does not support DeX 3.0 or something went wrong when trying to determine
|
||||||
|
// if it supports this feature
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public static boolean isTablet(@NonNull final Context context) {
|
public static boolean isTablet(@NonNull final Context context) {
|
||||||
final String tabletModeSetting = PreferenceManager.getDefaultSharedPreferences(context)
|
final String tabletModeSetting = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
.getString(context.getString(R.string.tablet_mode_key), "");
|
.getString(context.getString(R.string.tablet_mode_key), "");
|
||||||
|
@ -20,6 +20,7 @@
|
|||||||
package org.schabi.newpipe.util;
|
package org.schabi.newpipe.util;
|
||||||
|
|
||||||
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
|
||||||
|
import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
@ -51,7 +52,7 @@ import org.schabi.newpipe.extractor.search.SearchInfo;
|
|||||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||||
import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor;
|
import org.schabi.newpipe.extractor.suggestion.SuggestionExtractor;
|
||||||
import org.schabi.newpipe.util.external_communication.TextLinkifier;
|
import org.schabi.newpipe.util.text.TextLinkifier;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -319,8 +320,9 @@ public final class ExtractorHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
metaInfoSeparator.setVisibility(View.VISIBLE);
|
metaInfoSeparator.setVisibility(View.VISIBLE);
|
||||||
TextLinkifier.createLinksFromHtmlBlock(metaInfoTextView, stringBuilder.toString(),
|
TextLinkifier.fromHtml(metaInfoTextView, stringBuilder.toString(),
|
||||||
HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, disposables);
|
HtmlCompat.FROM_HTML_SEPARATOR_LINE_BREAK_HEADING, null, null, disposables,
|
||||||
|
SET_LINK_MOVEMENT_METHOD);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,7 +56,6 @@ import java.util.stream.Collectors;
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
public final class Localization {
|
public final class Localization {
|
||||||
|
|
||||||
public static final String DOT_SEPARATOR = " • ";
|
public static final String DOT_SEPARATOR = " • ";
|
||||||
private static PrettyTime prettyTime;
|
private static PrettyTime prettyTime;
|
||||||
|
|
||||||
@ -76,16 +75,8 @@ public final class Localization {
|
|||||||
|
|
||||||
public static org.schabi.newpipe.extractor.localization.Localization getPreferredLocalization(
|
public static org.schabi.newpipe.extractor.localization.Localization getPreferredLocalization(
|
||||||
final Context context) {
|
final Context context) {
|
||||||
final String contentLanguage = PreferenceManager
|
|
||||||
.getDefaultSharedPreferences(context)
|
|
||||||
.getString(context.getString(R.string.content_language_key),
|
|
||||||
context.getString(R.string.default_localization_key));
|
|
||||||
if (contentLanguage.equals(context.getString(R.string.default_localization_key))) {
|
|
||||||
return org.schabi.newpipe.extractor.localization.Localization
|
return org.schabi.newpipe.extractor.localization.Localization
|
||||||
.fromLocale(Locale.getDefault());
|
.fromLocale(getPreferredLocale(context));
|
||||||
}
|
|
||||||
return org.schabi.newpipe.extractor.localization.Localization
|
|
||||||
.fromLocalizationCode(contentLanguage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ContentCountry getPreferredContentCountry(final Context context) {
|
public static ContentCountry getPreferredContentCountry(final Context context) {
|
||||||
@ -99,22 +90,11 @@ public final class Localization {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static Locale getPreferredLocale(final Context context) {
|
public static Locale getPreferredLocale(final Context context) {
|
||||||
final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
|
return getLocaleFromPrefs(context, R.string.content_language_key);
|
||||||
|
|
||||||
final String languageCode = sp.getString(context.getString(R.string.content_language_key),
|
|
||||||
context.getString(R.string.default_localization_key));
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (languageCode.length() == 2) {
|
|
||||||
return new Locale(languageCode);
|
|
||||||
} else if (languageCode.contains("_")) {
|
|
||||||
final String country = languageCode.substring(languageCode.indexOf("_"));
|
|
||||||
return new Locale(languageCode.substring(0, 2), country);
|
|
||||||
}
|
|
||||||
} catch (final Exception ignored) {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Locale.getDefault();
|
public static Locale getAppLocale(final Context context) {
|
||||||
|
return getLocaleFromPrefs(context, R.string.app_language_key);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static String localizeNumber(final Context context, final long number) {
|
public static String localizeNumber(final Context context, final long number) {
|
||||||
@ -183,13 +163,13 @@ public final class Localization {
|
|||||||
|
|
||||||
final double value = (double) count;
|
final double value = (double) count;
|
||||||
if (count >= 1000000000) {
|
if (count >= 1000000000) {
|
||||||
return localizeNumber(context, round(value / 1000000000, 1))
|
return localizeNumber(context, round(value / 1000000000))
|
||||||
+ context.getString(R.string.short_billion);
|
+ context.getString(R.string.short_billion);
|
||||||
} else if (count >= 1000000) {
|
} else if (count >= 1000000) {
|
||||||
return localizeNumber(context, round(value / 1000000, 1))
|
return localizeNumber(context, round(value / 1000000))
|
||||||
+ context.getString(R.string.short_million);
|
+ context.getString(R.string.short_million);
|
||||||
} else if (count >= 1000) {
|
} else if (count >= 1000) {
|
||||||
return localizeNumber(context, round(value / 1000, 1))
|
return localizeNumber(context, round(value / 1000))
|
||||||
+ context.getString(R.string.short_thousand);
|
+ context.getString(R.string.short_thousand);
|
||||||
} else {
|
} else {
|
||||||
return localizeNumber(context, value);
|
return localizeNumber(context, value);
|
||||||
@ -226,21 +206,6 @@ public final class Localization {
|
|||||||
deletedCount, shortCount(context, deletedCount));
|
deletedCount, shortCount(context, deletedCount));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String getQuantity(final Context context, @PluralsRes final int pluralId,
|
|
||||||
@StringRes final int zeroCaseStringId, final long count,
|
|
||||||
final String formattedCount) {
|
|
||||||
if (count == 0) {
|
|
||||||
return context.getString(zeroCaseStringId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// As we use the already formatted count
|
|
||||||
// is not the responsibility of this method handle long numbers
|
|
||||||
// (it probably will fall in the "other" category,
|
|
||||||
// or some language have some specific rule... then we have to change it)
|
|
||||||
final int safeCount = (int) MathUtils.clamp(count, Integer.MIN_VALUE, Integer.MAX_VALUE);
|
|
||||||
return context.getResources().getQuantityString(pluralId, safeCount, formattedCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static String getDurationString(final long duration) {
|
public static String getDurationString(final long duration) {
|
||||||
final String output;
|
final String output;
|
||||||
|
|
||||||
@ -314,37 +279,42 @@ public final class Localization {
|
|||||||
return prettyTime.formatUnrounded(offsetDateTime);
|
return prettyTime.formatUnrounded(offsetDateTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void changeAppLanguage(final Locale loc, final Resources res) {
|
public static void assureCorrectAppLanguage(final Context c) {
|
||||||
|
final Resources res = c.getResources();
|
||||||
final DisplayMetrics dm = res.getDisplayMetrics();
|
final DisplayMetrics dm = res.getDisplayMetrics();
|
||||||
final Configuration conf = res.getConfiguration();
|
final Configuration conf = res.getConfiguration();
|
||||||
conf.setLocale(loc);
|
conf.setLocale(getAppLocale(c));
|
||||||
res.updateConfiguration(conf, dm);
|
res.updateConfiguration(conf, dm);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Locale getAppLocale(final Context context) {
|
private static Locale getLocaleFromPrefs(final Context context, @StringRes final int prefKey) {
|
||||||
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context);
|
final SharedPreferences sp = PreferenceManager.getDefaultSharedPreferences(context);
|
||||||
String lang = prefs.getString(context.getString(R.string.app_language_key), "en");
|
final String defaultKey = context.getString(R.string.default_localization_key);
|
||||||
final Locale loc;
|
final String languageCode = sp.getString(context.getString(prefKey), defaultKey);
|
||||||
if (lang.equals(context.getString(R.string.default_localization_key))) {
|
|
||||||
loc = Locale.getDefault();
|
if (languageCode.equals(defaultKey)) {
|
||||||
} else if (lang.matches(".*-.*")) {
|
return Locale.getDefault();
|
||||||
//to differentiate different versions of the language
|
|
||||||
//for example, pt (portuguese in Portugal) and pt-br (portuguese in Brazil)
|
|
||||||
final String[] localisation = lang.split("-");
|
|
||||||
lang = localisation[0];
|
|
||||||
final String country = localisation[1];
|
|
||||||
loc = new Locale(lang, country);
|
|
||||||
} else {
|
} else {
|
||||||
loc = new Locale(lang);
|
return Locale.forLanguageTag(languageCode);
|
||||||
}
|
}
|
||||||
return loc;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void assureCorrectAppLanguage(final Context c) {
|
private static double round(final double value) {
|
||||||
changeAppLanguage(getAppLocale(c), c.getResources());
|
return new BigDecimal(value).setScale(1, RoundingMode.HALF_UP).doubleValue();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static double round(final double value, final int places) {
|
private static String getQuantity(final Context context, @PluralsRes final int pluralId,
|
||||||
return new BigDecimal(value).setScale(places, RoundingMode.HALF_UP).doubleValue();
|
@StringRes final int zeroCaseStringId, final long count,
|
||||||
|
final String formattedCount) {
|
||||||
|
if (count == 0) {
|
||||||
|
return context.getString(zeroCaseStringId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// As we use the already formatted count
|
||||||
|
// is not the responsibility of this method handle long numbers
|
||||||
|
// (it probably will fall in the "other" category,
|
||||||
|
// or some language have some specific rule... then we have to change it)
|
||||||
|
final int safeCount = (int) MathUtils.clamp(count, Integer.MIN_VALUE, Integer.MAX_VALUE);
|
||||||
|
return context.getResources().getQuantityString(pluralId, safeCount, formattedCount);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -156,8 +156,7 @@ public final class NavigationHelper {
|
|||||||
public static void playOnPopupPlayer(final Context context,
|
public static void playOnPopupPlayer(final Context context,
|
||||||
final PlayQueue queue,
|
final PlayQueue queue,
|
||||||
final boolean resumePlayback) {
|
final boolean resumePlayback) {
|
||||||
if (!PermissionHelper.isPopupEnabled(context)) {
|
if (!PermissionHelper.isPopupEnabledElseAsk(context)) {
|
||||||
PermissionHelper.showPopupEnablementToast(context);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,6 +182,10 @@ public final class NavigationHelper {
|
|||||||
public static void enqueueOnPlayer(final Context context,
|
public static void enqueueOnPlayer(final Context context,
|
||||||
final PlayQueue queue,
|
final PlayQueue queue,
|
||||||
final PlayerType playerType) {
|
final PlayerType playerType) {
|
||||||
|
if (playerType == PlayerType.POPUP && !PermissionHelper.isPopupEnabledElseAsk(context)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show();
|
Toast.makeText(context, R.string.enqueued, Toast.LENGTH_SHORT).show();
|
||||||
final Intent intent = getPlayerEnqueueIntent(context, PlayerService.class, queue);
|
final Intent intent = getPlayerEnqueueIntent(context, PlayerService.class, queue);
|
||||||
|
|
||||||
|
@ -0,0 +1,69 @@
|
|||||||
|
package org.schabi.newpipe.util;
|
||||||
|
|
||||||
|
import android.app.PendingIntent;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Build;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
public final class PendingIntentCompat {
|
||||||
|
private PendingIntentCompat() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int addImmutableFlag(final int flags) {
|
||||||
|
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
|
||||||
|
? flags | PendingIntent.FLAG_IMMUTABLE : flags;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a {@link PendingIntent} to start an activity. It is immutable on API level 23 and
|
||||||
|
* greater.
|
||||||
|
*
|
||||||
|
* @param context The context in which the activity should be started.
|
||||||
|
* @param requestCode The request code
|
||||||
|
* @param intent The Intent of the activity to be launched.
|
||||||
|
* @param flags The flags for the intent.
|
||||||
|
* @return The pending intent.
|
||||||
|
* @see PendingIntent#getActivity(Context, int, Intent, int)
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public static PendingIntent getActivity(@NonNull final Context context, final int requestCode,
|
||||||
|
@NonNull final Intent intent, final int flags) {
|
||||||
|
return PendingIntent.getActivity(context, requestCode, intent, addImmutableFlag(flags));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a {@link PendingIntent} to start a service. It is immutable on API level 23 and
|
||||||
|
* greater.
|
||||||
|
*
|
||||||
|
* @param context The context in which the service should be started.
|
||||||
|
* @param requestCode The request code
|
||||||
|
* @param intent The Intent of the service to be launched.
|
||||||
|
* @param flags The flags for the intent.
|
||||||
|
* @return The pending intent.
|
||||||
|
* @see PendingIntent#getService(Context, int, Intent, int)
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public static PendingIntent getService(@NonNull final Context context, final int requestCode,
|
||||||
|
@NonNull final Intent intent, final int flags) {
|
||||||
|
return PendingIntent.getService(context, requestCode, intent, addImmutableFlag(flags));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a {@link PendingIntent} to perform a broadcast. It is immutable on API level 23 and
|
||||||
|
* greater.
|
||||||
|
*
|
||||||
|
* @param context The context in which the broadcast should be performed.
|
||||||
|
* @param requestCode The request code
|
||||||
|
* @param intent The Intent to be broadcast.
|
||||||
|
* @param flags The flags for the intent.
|
||||||
|
* @return The pending intent.
|
||||||
|
* @see PendingIntent#getBroadcast(Context, int, Intent, int)
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public static PendingIntent getBroadcast(@NonNull final Context context, final int requestCode,
|
||||||
|
@NonNull final Intent intent, final int flags) {
|
||||||
|
return PendingIntent.getBroadcast(context, requestCode, intent, addImmutableFlag(flags));
|
||||||
|
}
|
||||||
|
}
|
@ -9,8 +9,6 @@ import android.content.pm.PackageManager;
|
|||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
import android.view.Gravity;
|
|
||||||
import android.widget.TextView;
|
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
import androidx.annotation.RequiresApi;
|
import androidx.annotation.RequiresApi;
|
||||||
@ -21,6 +19,7 @@ import org.schabi.newpipe.R;
|
|||||||
import org.schabi.newpipe.settings.NewPipeSettings;
|
import org.schabi.newpipe.settings.NewPipeSettings;
|
||||||
|
|
||||||
public final class PermissionHelper {
|
public final class PermissionHelper {
|
||||||
|
public static final int POST_NOTIFICATIONS_REQUEST_CODE = 779;
|
||||||
public static final int DOWNLOAD_DIALOG_REQUEST_CODE = 778;
|
public static final int DOWNLOAD_DIALOG_REQUEST_CODE = 778;
|
||||||
public static final int DOWNLOADS_REQUEST_CODE = 777;
|
public static final int DOWNLOADS_REQUEST_CODE = 777;
|
||||||
|
|
||||||
@ -71,8 +70,7 @@ public final class PermissionHelper {
|
|||||||
|
|
||||||
// No explanation needed, we can request the permission.
|
// No explanation needed, we can request the permission.
|
||||||
ActivityCompat.requestPermissions(activity,
|
ActivityCompat.requestPermissions(activity,
|
||||||
new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE},
|
new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, requestCode);
|
||||||
requestCode);
|
|
||||||
|
|
||||||
// PERMISSION_WRITE_STORAGE is an
|
// PERMISSION_WRITE_STORAGE is an
|
||||||
// app-defined int constant. The callback method gets the
|
// app-defined int constant. The callback method gets the
|
||||||
@ -83,6 +81,18 @@ public final class PermissionHelper {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static boolean checkPostNotificationsPermission(final Activity activity,
|
||||||
|
final int requestCode) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
|
||||||
|
&& ContextCompat.checkSelfPermission(activity,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
!= PackageManager.PERMISSION_GRANTED) {
|
||||||
|
ActivityCompat.requestPermissions(activity,
|
||||||
|
new String[] {Manifest.permission.POST_NOTIFICATIONS}, requestCode);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* In order to be able to draw over other apps,
|
* In order to be able to draw over other apps,
|
||||||
@ -116,18 +126,21 @@ public final class PermissionHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public static boolean isPopupEnabled(final Context context) {
|
/**
|
||||||
return Build.VERSION.SDK_INT < Build.VERSION_CODES.M
|
* Determines whether the popup is enabled, and if it is not, starts the system activity to
|
||||||
|| checkSystemAlertWindowPermission(context);
|
* request the permission with {@link #checkSystemAlertWindowPermission(Context)} and shows a
|
||||||
|
* toast to the user explaining why the permission is needed.
|
||||||
|
*
|
||||||
|
* @param context the Android context
|
||||||
|
* @return whether the popup is enabled
|
||||||
|
*/
|
||||||
|
public static boolean isPopupEnabledElseAsk(final Context context) {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M
|
||||||
|
|| checkSystemAlertWindowPermission(context)) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
Toast.makeText(context, R.string.msg_popup_permission, Toast.LENGTH_LONG).show();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static void showPopupEnablementToast(final Context context) {
|
|
||||||
final Toast toast =
|
|
||||||
Toast.makeText(context, R.string.msg_popup_permission, Toast.LENGTH_LONG);
|
|
||||||
final TextView messageView = toast.getView().findViewById(android.R.id.message);
|
|
||||||
if (messageView != null) {
|
|
||||||
messageView.setGravity(Gravity.CENTER);
|
|
||||||
}
|
|
||||||
toast.show();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ import android.graphics.Bitmap;
|
|||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.core.graphics.BitmapCompat;
|
||||||
|
|
||||||
import com.squareup.picasso.Cache;
|
import com.squareup.picasso.Cache;
|
||||||
import com.squareup.picasso.LruCache;
|
import com.squareup.picasso.LruCache;
|
||||||
@ -139,21 +140,23 @@ public final class PicassoHelper {
|
|||||||
.getDimension(R.dimen.player_notification_thumbnail_width),
|
.getDimension(R.dimen.player_notification_thumbnail_width),
|
||||||
source.getWidth());
|
source.getWidth());
|
||||||
|
|
||||||
final Bitmap result = Bitmap.createScaledBitmap(
|
final Bitmap result = BitmapCompat.createScaledBitmap(
|
||||||
source,
|
source,
|
||||||
(int) notificationThumbnailWidth,
|
(int) notificationThumbnailWidth,
|
||||||
(int) (source.getHeight()
|
(int) (source.getHeight()
|
||||||
/ (source.getWidth() / notificationThumbnailWidth)),
|
/ (source.getWidth() / notificationThumbnailWidth)),
|
||||||
|
null,
|
||||||
true);
|
true);
|
||||||
|
|
||||||
if (result == source) {
|
if (result == source || !result.isMutable()) {
|
||||||
// create a new mutable bitmap to prevent strange crashes on some
|
// create a new mutable bitmap to prevent strange crashes on some
|
||||||
// devices (see #4638)
|
// devices (see #4638)
|
||||||
final Bitmap copied = Bitmap.createScaledBitmap(
|
final Bitmap copied = BitmapCompat.createScaledBitmap(
|
||||||
source,
|
source,
|
||||||
(int) notificationThumbnailWidth - 1,
|
(int) notificationThumbnailWidth - 1,
|
||||||
(int) (source.getHeight() / (source.getWidth()
|
(int) (source.getHeight() / (source.getWidth()
|
||||||
/ (notificationThumbnailWidth - 1))),
|
/ (notificationThumbnailWidth - 1))),
|
||||||
|
null,
|
||||||
true);
|
true);
|
||||||
source.recycle();
|
source.recycle();
|
||||||
return copied;
|
return copied;
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package org.schabi.newpipe.util;
|
package org.schabi.newpipe.util;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.util.SparseArray;
|
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
@ -11,6 +10,8 @@ import android.widget.Spinner;
|
|||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.collection.SparseArrayCompat;
|
||||||
|
|
||||||
import org.schabi.newpipe.DownloaderImpl;
|
import org.schabi.newpipe.DownloaderImpl;
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
@ -39,10 +40,10 @@ import us.shandian.giga.util.Utility;
|
|||||||
* @param <U> the secondary stream type's class extending {@link Stream}
|
* @param <U> the secondary stream type's class extending {@link Stream}
|
||||||
*/
|
*/
|
||||||
public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseAdapter {
|
public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseAdapter {
|
||||||
private final Context context;
|
@NonNull
|
||||||
|
|
||||||
private final StreamSizeWrapper<T> streamsWrapper;
|
private final StreamSizeWrapper<T> streamsWrapper;
|
||||||
private final SparseArray<SecondaryStreamHelper<U>> secondaryStreams;
|
@NonNull
|
||||||
|
private final SparseArrayCompat<SecondaryStreamHelper<U>> secondaryStreams;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indicates that at least one of the primary streams is an instance of {@link VideoStream},
|
* Indicates that at least one of the primary streams is an instance of {@link VideoStream},
|
||||||
@ -51,9 +52,10 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
|
|||||||
*/
|
*/
|
||||||
private final boolean hasAnyVideoOnlyStreamWithNoSecondaryStream;
|
private final boolean hasAnyVideoOnlyStreamWithNoSecondaryStream;
|
||||||
|
|
||||||
public StreamItemAdapter(final Context context, final StreamSizeWrapper<T> streamsWrapper,
|
public StreamItemAdapter(
|
||||||
final SparseArray<SecondaryStreamHelper<U>> secondaryStreams) {
|
@NonNull final StreamSizeWrapper<T> streamsWrapper,
|
||||||
this.context = context;
|
@NonNull final SparseArrayCompat<SecondaryStreamHelper<U>> secondaryStreams
|
||||||
|
) {
|
||||||
this.streamsWrapper = streamsWrapper;
|
this.streamsWrapper = streamsWrapper;
|
||||||
this.secondaryStreams = secondaryStreams;
|
this.secondaryStreams = secondaryStreams;
|
||||||
|
|
||||||
@ -61,15 +63,15 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
|
|||||||
checkHasAnyVideoOnlyStreamWithNoSecondaryStream();
|
checkHasAnyVideoOnlyStreamWithNoSecondaryStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
public StreamItemAdapter(final Context context, final StreamSizeWrapper<T> streamsWrapper) {
|
public StreamItemAdapter(final StreamSizeWrapper<T> streamsWrapper) {
|
||||||
this(context, streamsWrapper, null);
|
this(streamsWrapper, new SparseArrayCompat<>(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<T> getAll() {
|
public List<T> getAll() {
|
||||||
return streamsWrapper.getStreamsList();
|
return streamsWrapper.getStreamsList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public SparseArray<SecondaryStreamHelper<U>> getAllSecondary() {
|
public SparseArrayCompat<SecondaryStreamHelper<U>> getAllSecondary() {
|
||||||
return secondaryStreams;
|
return secondaryStreams;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,6 +108,7 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
|
|||||||
final View view,
|
final View view,
|
||||||
final ViewGroup parent,
|
final ViewGroup parent,
|
||||||
final boolean isDropdownItem) {
|
final boolean isDropdownItem) {
|
||||||
|
final var context = parent.getContext();
|
||||||
View convertView = view;
|
View convertView = view;
|
||||||
if (convertView == null) {
|
if (convertView == null) {
|
||||||
convertView = LayoutInflater.from(context).inflate(
|
convertView = LayoutInflater.from(context).inflate(
|
||||||
@ -129,7 +132,7 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
|
|||||||
|
|
||||||
if (hasAnyVideoOnlyStreamWithNoSecondaryStream) {
|
if (hasAnyVideoOnlyStreamWithNoSecondaryStream) {
|
||||||
if (videoStream.isVideoOnly()) {
|
if (videoStream.isVideoOnly()) {
|
||||||
woSoundIconVisibility = hasSecondaryStream(position)
|
woSoundIconVisibility = secondaryStreams.get(position) != null
|
||||||
// It has a secondary stream associated with it, so check if it's a
|
// It has a secondary stream associated with it, so check if it's a
|
||||||
// dropdown view so it doesn't look out of place (missing margin)
|
// dropdown view so it doesn't look out of place (missing margin)
|
||||||
// compared to those that don't.
|
// compared to those that don't.
|
||||||
@ -163,8 +166,7 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (streamsWrapper.getSizeInBytes(position) > 0) {
|
if (streamsWrapper.getSizeInBytes(position) > 0) {
|
||||||
final SecondaryStreamHelper<U> secondary = secondaryStreams == null ? null
|
final var secondary = secondaryStreams.get(position);
|
||||||
: secondaryStreams.get(position);
|
|
||||||
if (secondary != null) {
|
if (secondary != null) {
|
||||||
final long size = secondary.getSizeInBytes()
|
final long size = secondary.getSizeInBytes()
|
||||||
+ streamsWrapper.getSizeInBytes(position);
|
+ streamsWrapper.getSizeInBytes(position);
|
||||||
@ -196,14 +198,6 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
|
|||||||
return convertView;
|
return convertView;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @param position which primary stream to check.
|
|
||||||
* @return whether the primary stream at position has a secondary stream associated with it.
|
|
||||||
*/
|
|
||||||
private boolean hasSecondaryStream(final int position) {
|
|
||||||
return secondaryStreams != null && secondaryStreams.get(position) != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return if there are any video-only streams with no secondary stream associated with them.
|
* @return if there are any video-only streams with no secondary stream associated with them.
|
||||||
* @see #hasAnyVideoOnlyStreamWithNoSecondaryStream
|
* @see #hasAnyVideoOnlyStreamWithNoSecondaryStream
|
||||||
@ -213,7 +207,7 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
|
|||||||
final T stream = streamsWrapper.getStreamsList().get(i);
|
final T stream = streamsWrapper.getStreamsList().get(i);
|
||||||
if (stream instanceof VideoStream) {
|
if (stream instanceof VideoStream) {
|
||||||
final boolean videoOnly = ((VideoStream) stream).isVideoOnly();
|
final boolean videoOnly = ((VideoStream) stream).isVideoOnly();
|
||||||
if (videoOnly && !hasSecondaryStream(i)) {
|
if (videoOnly && secondaryStreams.get(i) == null) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -228,16 +222,15 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
|
|||||||
* @param <T> the stream type's class extending {@link Stream}
|
* @param <T> the stream type's class extending {@link Stream}
|
||||||
*/
|
*/
|
||||||
public static class StreamSizeWrapper<T extends Stream> implements Serializable {
|
public static class StreamSizeWrapper<T extends Stream> implements Serializable {
|
||||||
private static final StreamSizeWrapper<Stream> EMPTY = new StreamSizeWrapper<>(
|
private static final StreamSizeWrapper<Stream> EMPTY =
|
||||||
Collections.emptyList(), null);
|
new StreamSizeWrapper<>(Collections.emptyList(), null);
|
||||||
private final List<T> streamsList;
|
private final List<T> streamsList;
|
||||||
private final long[] streamSizes;
|
private final long[] streamSizes;
|
||||||
private final String unknownSize;
|
private final String unknownSize;
|
||||||
|
|
||||||
public StreamSizeWrapper(final List<T> sL, final Context context) {
|
public StreamSizeWrapper(@NonNull final List<T> streamList,
|
||||||
this.streamsList = sL != null
|
@Nullable final Context context) {
|
||||||
? sL
|
this.streamsList = streamList;
|
||||||
: Collections.emptyList();
|
|
||||||
this.streamSizes = new long[streamsList.size()];
|
this.streamSizes = new long[streamsList.size()];
|
||||||
this.unknownSize = context == null
|
this.unknownSize = context == null
|
||||||
? "--.-" : context.getString(R.string.unknown_content);
|
? "--.-" : context.getString(R.string.unknown_content);
|
||||||
@ -297,10 +290,6 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
|
|||||||
return formatSize(getSizeInBytes(streamIndex));
|
return formatSize(getSizeInBytes(streamIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getFormattedSize(final T stream) {
|
|
||||||
return formatSize(getSizeInBytes(stream));
|
|
||||||
}
|
|
||||||
|
|
||||||
private String formatSize(final long size) {
|
private String formatSize(final long size) {
|
||||||
if (size > -1) {
|
if (size > -1) {
|
||||||
return Utility.formatBytes(size);
|
return Utility.formatBytes(size);
|
||||||
@ -308,10 +297,6 @@ public class StreamItemAdapter<T extends Stream, U extends Stream> extends BaseA
|
|||||||
return unknownSize;
|
return unknownSize;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setSize(final int streamIndex, final long sizeInBytes) {
|
|
||||||
streamSizes[streamIndex] = sizeInBytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void setSize(final T stream, final long sizeInBytes) {
|
public void setSize(final T stream, final long sizeInBytes) {
|
||||||
streamSizes[streamsList.indexOf(stream)] = sizeInBytes;
|
streamSizes[streamsList.indexOf(stream)] = sizeInBytes;
|
||||||
}
|
}
|
||||||
|
@ -41,6 +41,7 @@ import org.schabi.newpipe.R;
|
|||||||
import org.schabi.newpipe.extractor.NewPipe;
|
import org.schabi.newpipe.extractor.NewPipe;
|
||||||
import org.schabi.newpipe.extractor.StreamingService;
|
import org.schabi.newpipe.extractor.StreamingService;
|
||||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||||
|
import org.schabi.newpipe.info_list.ItemViewMode;
|
||||||
|
|
||||||
public final class ThemeHelper {
|
public final class ThemeHelper {
|
||||||
private ThemeHelper() {
|
private ThemeHelper() {
|
||||||
@ -332,7 +333,6 @@ public final class ThemeHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns whether the grid layout or the list layout should be used. If the user set "auto"
|
* Returns whether the grid layout or the list layout should be used. If the user set "auto"
|
||||||
* mode in settings, decides based on screen orientation (landscape) and size.
|
* mode in settings, decides based on screen orientation (landscape) and size.
|
||||||
@ -341,19 +341,8 @@ public final class ThemeHelper {
|
|||||||
* @return true:use grid layout, false:use list layout
|
* @return true:use grid layout, false:use list layout
|
||||||
*/
|
*/
|
||||||
public static boolean shouldUseGridLayout(final Context context) {
|
public static boolean shouldUseGridLayout(final Context context) {
|
||||||
final String listMode = PreferenceManager.getDefaultSharedPreferences(context)
|
final ItemViewMode mode = getItemViewMode(context);
|
||||||
.getString(context.getString(R.string.list_view_mode_key),
|
return mode == ItemViewMode.GRID;
|
||||||
context.getString(R.string.list_view_mode_value));
|
|
||||||
|
|
||||||
if (listMode.equals(context.getString(R.string.list_view_mode_list_key))) {
|
|
||||||
return false;
|
|
||||||
} else if (listMode.equals(context.getString(R.string.list_view_mode_grid_key))) {
|
|
||||||
return true;
|
|
||||||
} else /* listMode.equals("auto") */ {
|
|
||||||
final Configuration configuration = context.getResources().getConfiguration();
|
|
||||||
return configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
|
||||||
&& configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -367,6 +356,36 @@ public final class ThemeHelper {
|
|||||||
context.getResources().getDimensionPixelSize(R.dimen.channel_item_grid_min_width));
|
context.getResources().getDimensionPixelSize(R.dimen.channel_item_grid_min_width));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns item view mode.
|
||||||
|
* @param context to read preference and parse string
|
||||||
|
* @return Returns one of ItemViewMode
|
||||||
|
*/
|
||||||
|
public static ItemViewMode getItemViewMode(final Context context) {
|
||||||
|
final String listMode = PreferenceManager.getDefaultSharedPreferences(context)
|
||||||
|
.getString(context.getString(R.string.list_view_mode_key),
|
||||||
|
context.getString(R.string.list_view_mode_value));
|
||||||
|
final ItemViewMode result;
|
||||||
|
if (listMode.equals(context.getString(R.string.list_view_mode_list_key))) {
|
||||||
|
result = ItemViewMode.LIST;
|
||||||
|
} else if (listMode.equals(context.getString(R.string.list_view_mode_grid_key))) {
|
||||||
|
result = ItemViewMode.GRID;
|
||||||
|
} else if (listMode.equals(context.getString(R.string.list_view_mode_card_key))) {
|
||||||
|
result = ItemViewMode.CARD;
|
||||||
|
} else {
|
||||||
|
// Auto mode - evaluate whether to use Grid based on screen real estate.
|
||||||
|
final Configuration configuration = context.getResources().getConfiguration();
|
||||||
|
final boolean useGrid = configuration.orientation == Configuration.ORIENTATION_LANDSCAPE
|
||||||
|
&& configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE);
|
||||||
|
if (useGrid) {
|
||||||
|
result = ItemViewMode.GRID;
|
||||||
|
} else {
|
||||||
|
result = ItemViewMode.LIST;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the number of grid stream info items that can fit horizontally on the screen. The
|
* Calculates the number of grid stream info items that can fit horizontally on the screen. The
|
||||||
* width of a grid stream info item is obtained from the thumbnail width plus the right and left
|
* width of a grid stream info item is obtained from the thumbnail width plus the right and left
|
||||||
|
@ -89,14 +89,12 @@ public final class ShareUtils {
|
|||||||
if (defaultPackageName.equals("android")) {
|
if (defaultPackageName.equals("android")) {
|
||||||
// No browser set as default (doesn't work on some devices)
|
// No browser set as default (doesn't work on some devices)
|
||||||
openAppChooser(context, intent, true);
|
openAppChooser(context, intent, true);
|
||||||
} else {
|
|
||||||
if (defaultPackageName.isEmpty()) {
|
|
||||||
// No app installed to open a web url
|
|
||||||
Toast.makeText(context, R.string.no_app_to_open_intent, Toast.LENGTH_LONG).show();
|
|
||||||
return false;
|
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
|
// will be empty on Android 12+
|
||||||
|
if (!defaultPackageName.isEmpty()) {
|
||||||
intent.setPackage(defaultPackageName);
|
intent.setPackage(defaultPackageName);
|
||||||
|
}
|
||||||
context.startActivity(intent);
|
context.startActivity(intent);
|
||||||
} catch (final ActivityNotFoundException e) {
|
} catch (final ActivityNotFoundException e) {
|
||||||
// Not a browser but an app chooser because of OEMs changes
|
// Not a browser but an app chooser because of OEMs changes
|
||||||
@ -104,7 +102,6 @@ public final class ShareUtils {
|
|||||||
openAppChooser(context, intent, true);
|
openAppChooser(context, intent, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@ -313,9 +310,17 @@ public final class ShareUtils {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text));
|
clipboardManager.setPrimaryClip(ClipData.newPlainText(null, text));
|
||||||
|
if (Build.VERSION.SDK_INT < 33) {
|
||||||
|
// Android 13 has its own "copied to clipboard" dialog
|
||||||
Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show();
|
Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show();
|
||||||
}
|
}
|
||||||
|
} catch (final Exception e) {
|
||||||
|
Log.e(TAG, "Error when trying to copy text to clipboard", e);
|
||||||
|
Toast.makeText(context, R.string.msg_failed_to_copy, Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a {@link ClipData} with the image of the content shared, if it's in the app cache.
|
* Generate a {@link ClipData} with the image of the content shared, if it's in the app cache.
|
||||||
|
@ -1,289 +0,0 @@
|
|||||||
package org.schabi.newpipe.util.external_communication;
|
|
||||||
|
|
||||||
import android.content.Context;
|
|
||||||
import android.text.SpannableStringBuilder;
|
|
||||||
import android.text.method.LinkMovementMethod;
|
|
||||||
import android.text.style.ClickableSpan;
|
|
||||||
import android.text.style.URLSpan;
|
|
||||||
import android.text.util.Linkify;
|
|
||||||
import android.util.Log;
|
|
||||||
import android.view.View;
|
|
||||||
import android.widget.TextView;
|
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.core.text.HtmlCompat;
|
|
||||||
|
|
||||||
import org.schabi.newpipe.extractor.Info;
|
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
|
||||||
import org.schabi.newpipe.util.NavigationHelper;
|
|
||||||
|
|
||||||
import java.util.regex.Matcher;
|
|
||||||
import java.util.regex.Pattern;
|
|
||||||
|
|
||||||
import io.noties.markwon.Markwon;
|
|
||||||
import io.noties.markwon.linkify.LinkifyPlugin;
|
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
|
||||||
import io.reactivex.rxjava3.core.Single;
|
|
||||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
|
||||||
|
|
||||||
import static org.schabi.newpipe.util.external_communication.InternalUrlsHandler.playOnPopup;
|
|
||||||
|
|
||||||
public final class TextLinkifier {
|
|
||||||
public static final String TAG = TextLinkifier.class.getSimpleName();
|
|
||||||
|
|
||||||
// Looks for hashtags with characters from any language (\p{L}), numbers, or underscores
|
|
||||||
private static final Pattern HASHTAGS_PATTERN =
|
|
||||||
Pattern.compile("(#[\\p{L}0-9_]+)");
|
|
||||||
|
|
||||||
private TextLinkifier() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create web links for contents with an HTML description.
|
|
||||||
* <p>
|
|
||||||
* This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence,
|
|
||||||
* Info, CompositeDisposable)} after having linked the URLs with
|
|
||||||
* {@link HtmlCompat#fromHtml(String, int)}.
|
|
||||||
*
|
|
||||||
* @param textView the TextView to set the htmlBlock linked
|
|
||||||
* @param htmlBlock the htmlBlock to be linked
|
|
||||||
* @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String, int)}
|
|
||||||
* will be called
|
|
||||||
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at
|
|
||||||
* the specific time, and hashtags to search for the term in the correct
|
|
||||||
* service
|
|
||||||
* @param disposables disposables created by the method are added here and their lifecycle
|
|
||||||
* should be handled by the calling class
|
|
||||||
*/
|
|
||||||
public static void createLinksFromHtmlBlock(@NonNull final TextView textView,
|
|
||||||
final String htmlBlock,
|
|
||||||
final int htmlCompatFlag,
|
|
||||||
@Nullable final Info relatedInfo,
|
|
||||||
final CompositeDisposable disposables) {
|
|
||||||
changeIntentsOfDescriptionLinks(
|
|
||||||
textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfo, disposables);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create web links for contents with a plain text description.
|
|
||||||
* <p>
|
|
||||||
* This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence,
|
|
||||||
* Info, CompositeDisposable)} after having linked the URLs with
|
|
||||||
* {@link TextView#setAutoLinkMask(int)} and
|
|
||||||
* {@link TextView#setText(CharSequence, TextView.BufferType)}.
|
|
||||||
*
|
|
||||||
* @param textView the TextView to set the plain text block linked
|
|
||||||
* @param plainTextBlock the block of plain text to be linked
|
|
||||||
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at
|
|
||||||
* the specific time, and hashtags to search for the term in the correct
|
|
||||||
* service
|
|
||||||
* @param disposables disposables created by the method are added here and their lifecycle
|
|
||||||
* should be handled by the calling class
|
|
||||||
*/
|
|
||||||
public static void createLinksFromPlainText(@NonNull final TextView textView,
|
|
||||||
final String plainTextBlock,
|
|
||||||
@Nullable final Info relatedInfo,
|
|
||||||
final CompositeDisposable disposables) {
|
|
||||||
textView.setAutoLinkMask(Linkify.WEB_URLS);
|
|
||||||
textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE);
|
|
||||||
changeIntentsOfDescriptionLinks(textView, textView.getText(), relatedInfo, disposables);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create web links for contents with a markdown description.
|
|
||||||
* <p>
|
|
||||||
* This will call {@link TextLinkifier#changeIntentsOfDescriptionLinks(TextView, CharSequence,
|
|
||||||
* Info, CompositeDisposable)} after creating an {@link Markwon} object and using
|
|
||||||
* {@link Markwon#setMarkdown(TextView, String)}.
|
|
||||||
*
|
|
||||||
* @param textView the TextView to set the plain text block linked
|
|
||||||
* @param markdownBlock the block of markdown text to be linked
|
|
||||||
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at
|
|
||||||
* the specific time, and hashtags to search for the term in the correct
|
|
||||||
* @param disposables disposables created by the method are added here and their lifecycle
|
|
||||||
* should be handled by the calling class
|
|
||||||
*/
|
|
||||||
public static void createLinksFromMarkdownText(@NonNull final TextView textView,
|
|
||||||
final String markdownBlock,
|
|
||||||
@Nullable final Info relatedInfo,
|
|
||||||
final CompositeDisposable disposables) {
|
|
||||||
final Markwon markwon = Markwon.builder(textView.getContext())
|
|
||||||
.usePlugin(LinkifyPlugin.create()).build();
|
|
||||||
changeIntentsOfDescriptionLinks(textView, markwon.toMarkdown(markdownBlock), relatedInfo,
|
|
||||||
disposables);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add click listeners which opens a search on hashtags in a plain text.
|
|
||||||
* <p>
|
|
||||||
* This method finds all timestamps in the {@link SpannableStringBuilder} of the description
|
|
||||||
* using a regular expression, adds for each a {@link ClickableSpan} which opens
|
|
||||||
* {@link NavigationHelper#openSearch(Context, int, String)} and makes a search on the hashtag,
|
|
||||||
* in the service of the content.
|
|
||||||
*
|
|
||||||
* @param context the context to use
|
|
||||||
* @param spannableDescription the SpannableStringBuilder with the text of the
|
|
||||||
* content description
|
|
||||||
* @param relatedInfo used to search for the term in the correct service
|
|
||||||
*/
|
|
||||||
private static void addClickListenersOnHashtags(final Context context,
|
|
||||||
@NonNull final SpannableStringBuilder
|
|
||||||
spannableDescription,
|
|
||||||
final Info relatedInfo) {
|
|
||||||
final String descriptionText = spannableDescription.toString();
|
|
||||||
final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText);
|
|
||||||
|
|
||||||
while (hashtagsMatches.find()) {
|
|
||||||
final int hashtagStart = hashtagsMatches.start(1);
|
|
||||||
final int hashtagEnd = hashtagsMatches.end(1);
|
|
||||||
final String parsedHashtag = descriptionText.substring(hashtagStart, hashtagEnd);
|
|
||||||
|
|
||||||
// don't add a ClickableSpan if there is already one, which should be a part of an URL,
|
|
||||||
// already parsed before
|
|
||||||
if (spannableDescription.getSpans(hashtagStart, hashtagEnd,
|
|
||||||
ClickableSpan.class).length == 0) {
|
|
||||||
spannableDescription.setSpan(new ClickableSpan() {
|
|
||||||
@Override
|
|
||||||
public void onClick(@NonNull final View view) {
|
|
||||||
NavigationHelper.openSearch(context, relatedInfo.getServiceId(),
|
|
||||||
parsedHashtag);
|
|
||||||
}
|
|
||||||
}, hashtagStart, hashtagEnd, 0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add click listeners which opens the popup player on timestamps in a plain text.
|
|
||||||
* <p>
|
|
||||||
* This method finds all timestamps in the {@link SpannableStringBuilder} of the description
|
|
||||||
* using a regular expression, adds for each a {@link ClickableSpan} which opens the popup
|
|
||||||
* player at the time indicated in the timestamps.
|
|
||||||
*
|
|
||||||
* @param context the context to use
|
|
||||||
* @param spannableDescription the SpannableStringBuilder with the text of the
|
|
||||||
* content description
|
|
||||||
* @param relatedInfo what to open in the popup player when timestamps are clicked
|
|
||||||
* @param disposables disposables created by the method are added here and their
|
|
||||||
* lifecycle should be handled by the calling class
|
|
||||||
*/
|
|
||||||
private static void addClickListenersOnTimestamps(final Context context,
|
|
||||||
@NonNull final SpannableStringBuilder
|
|
||||||
spannableDescription,
|
|
||||||
final Info relatedInfo,
|
|
||||||
final CompositeDisposable disposables) {
|
|
||||||
final String descriptionText = spannableDescription.toString();
|
|
||||||
final Matcher timestampsMatches =
|
|
||||||
TimestampExtractor.TIMESTAMPS_PATTERN.matcher(descriptionText);
|
|
||||||
|
|
||||||
while (timestampsMatches.find()) {
|
|
||||||
final TimestampExtractor.TimestampMatchDTO timestampMatchDTO =
|
|
||||||
TimestampExtractor.getTimestampFromMatcher(
|
|
||||||
timestampsMatches,
|
|
||||||
descriptionText);
|
|
||||||
|
|
||||||
if (timestampMatchDTO == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
spannableDescription.setSpan(
|
|
||||||
new ClickableSpan() {
|
|
||||||
@Override
|
|
||||||
public void onClick(@NonNull final View view) {
|
|
||||||
playOnPopup(
|
|
||||||
context,
|
|
||||||
relatedInfo.getUrl(),
|
|
||||||
relatedInfo.getService(),
|
|
||||||
timestampMatchDTO.seconds(),
|
|
||||||
disposables);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
timestampMatchDTO.timestampStart(),
|
|
||||||
timestampMatchDTO.timestampEnd(),
|
|
||||||
0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Change links generated by libraries in the description of a content to a custom link action
|
|
||||||
* and add click listeners on timestamps in this description.
|
|
||||||
* <p>
|
|
||||||
* Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of
|
|
||||||
* a content, this method will parse the {@link CharSequence} and replace all current web links
|
|
||||||
* with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}.
|
|
||||||
* This method will also add click listeners on timestamps in this description, which will play
|
|
||||||
* the content in the popup player at the time indicated in the timestamp, by using
|
|
||||||
* {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder, Info,
|
|
||||||
* CompositeDisposable)} method and click listeners on hashtags, by using
|
|
||||||
* {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder, Info)},
|
|
||||||
* which will open a search on the current service with the hashtag.
|
|
||||||
* <p>
|
|
||||||
* This method is required in order to intercept links and e.g. show a confirmation dialog
|
|
||||||
* before opening a web link.
|
|
||||||
*
|
|
||||||
* @param textView the TextView in which the converted CharSequence will be applied
|
|
||||||
* @param chars the CharSequence to be parsed
|
|
||||||
* @param relatedInfo if given, handle timestamps to open the stream in the popup player at
|
|
||||||
* the specific time, and hashtags to search for the term in the correct
|
|
||||||
* service
|
|
||||||
* @param disposables disposables created by the method are added here and their lifecycle
|
|
||||||
* should be handled by the calling class
|
|
||||||
*/
|
|
||||||
private static void changeIntentsOfDescriptionLinks(final TextView textView,
|
|
||||||
final CharSequence chars,
|
|
||||||
@Nullable final Info relatedInfo,
|
|
||||||
final CompositeDisposable disposables) {
|
|
||||||
disposables.add(Single.fromCallable(() -> {
|
|
||||||
final Context context = textView.getContext();
|
|
||||||
|
|
||||||
// add custom click actions on web links
|
|
||||||
final SpannableStringBuilder textBlockLinked = new SpannableStringBuilder(chars);
|
|
||||||
final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(), URLSpan.class);
|
|
||||||
|
|
||||||
for (final URLSpan span : urls) {
|
|
||||||
final String url = span.getURL();
|
|
||||||
final ClickableSpan clickableSpan = new ClickableSpan() {
|
|
||||||
public void onClick(@NonNull final View view) {
|
|
||||||
if (!InternalUrlsHandler.handleUrlDescriptionTimestamp(
|
|
||||||
new CompositeDisposable(), context, url)) {
|
|
||||||
ShareUtils.openUrlInBrowser(context, url, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
textBlockLinked.setSpan(clickableSpan, textBlockLinked.getSpanStart(span),
|
|
||||||
textBlockLinked.getSpanEnd(span), textBlockLinked.getSpanFlags(span));
|
|
||||||
textBlockLinked.removeSpan(span);
|
|
||||||
}
|
|
||||||
|
|
||||||
// add click actions on plain text timestamps only for description of contents,
|
|
||||||
// unneeded for meta-info or other TextViews
|
|
||||||
if (relatedInfo != null) {
|
|
||||||
if (relatedInfo instanceof StreamInfo) {
|
|
||||||
addClickListenersOnTimestamps(context, textBlockLinked, relatedInfo,
|
|
||||||
disposables);
|
|
||||||
}
|
|
||||||
addClickListenersOnHashtags(context, textBlockLinked, relatedInfo);
|
|
||||||
}
|
|
||||||
|
|
||||||
return textBlockLinked;
|
|
||||||
}).subscribeOn(Schedulers.computation())
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.subscribe(
|
|
||||||
textBlockLinked -> setTextViewCharSequence(textView, textBlockLinked),
|
|
||||||
throwable -> {
|
|
||||||
Log.e(TAG, "Unable to linkify text", throwable);
|
|
||||||
// this should never happen, but if it does, just fallback to it
|
|
||||||
setTextViewCharSequence(textView, chars);
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void setTextViewCharSequence(@NonNull final TextView textView,
|
|
||||||
final CharSequence charSequence) {
|
|
||||||
textView.setText(charSequence);
|
|
||||||
textView.setMovementMethod(LinkMovementMethod.getInstance());
|
|
||||||
textView.setVisibility(View.VISIBLE);
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1,42 @@
|
|||||||
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine;
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint;
|
||||||
|
import android.text.Spanned;
|
||||||
|
import android.text.style.ClickableSpan;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
public class CommentTextOnTouchListener implements View.OnTouchListener {
|
||||||
|
public static final CommentTextOnTouchListener INSTANCE = new CommentTextOnTouchListener();
|
||||||
|
|
||||||
|
@SuppressLint("ClickableViewAccessibility")
|
||||||
|
@Override
|
||||||
|
public boolean onTouch(final View v, final MotionEvent event) {
|
||||||
|
if (!(v instanceof TextView)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
final TextView widget = (TextView) v;
|
||||||
|
final CharSequence text = widget.getText();
|
||||||
|
if (text instanceof Spanned) {
|
||||||
|
final Spanned buffer = (Spanned) text;
|
||||||
|
final int action = event.getAction();
|
||||||
|
|
||||||
|
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
|
||||||
|
final int offset = getOffsetForHorizontalLine(widget, event);
|
||||||
|
final ClickableSpan[] links = buffer.getSpans(offset, offset, ClickableSpan.class);
|
||||||
|
|
||||||
|
if (links.length != 0) {
|
||||||
|
if (action == MotionEvent.ACTION_UP) {
|
||||||
|
links[0].onClick(widget);
|
||||||
|
}
|
||||||
|
// we handle events that intersect links, so return true
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
|
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||||
|
|
||||||
|
final class HashtagLongPressClickableSpan extends LongPressClickableSpan {
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private final Context context;
|
||||||
|
@NonNull
|
||||||
|
private final String parsedHashtag;
|
||||||
|
private final int relatedInfoServiceId;
|
||||||
|
|
||||||
|
HashtagLongPressClickableSpan(@NonNull final Context context,
|
||||||
|
@NonNull final String parsedHashtag,
|
||||||
|
final int relatedInfoServiceId) {
|
||||||
|
this.context = context;
|
||||||
|
this.parsedHashtag = parsedHashtag;
|
||||||
|
this.relatedInfoServiceId = relatedInfoServiceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClick(@NonNull final View view) {
|
||||||
|
NavigationHelper.openSearch(context, relatedInfoServiceId, parsedHashtag);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLongClick(@NonNull final View view) {
|
||||||
|
ShareUtils.copyToClipboard(context, parsedHashtag);
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
package org.schabi.newpipe.util.external_communication;
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
@ -0,0 +1,12 @@
|
|||||||
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
|
import android.text.style.ClickableSpan;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
public abstract class LongPressClickableSpan extends ClickableSpan {
|
||||||
|
|
||||||
|
public abstract void onLongClick(@NonNull View view);
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,77 @@
|
|||||||
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine;
|
||||||
|
|
||||||
|
import android.os.Handler;
|
||||||
|
import android.os.Looper;
|
||||||
|
import android.text.Selection;
|
||||||
|
import android.text.Spannable;
|
||||||
|
import android.text.method.LinkMovementMethod;
|
||||||
|
import android.text.method.MovementMethod;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.view.ViewConfiguration;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
// Class adapted from https://stackoverflow.com/a/31786969
|
||||||
|
|
||||||
|
public class LongPressLinkMovementMethod extends LinkMovementMethod {
|
||||||
|
|
||||||
|
private static final int LONG_PRESS_TIME = ViewConfiguration.getLongPressTimeout();
|
||||||
|
|
||||||
|
private static LongPressLinkMovementMethod instance;
|
||||||
|
|
||||||
|
private Handler longClickHandler;
|
||||||
|
private boolean isLongPressed = false;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean onTouchEvent(@NonNull final TextView widget,
|
||||||
|
@NonNull final Spannable buffer,
|
||||||
|
@NonNull final MotionEvent event) {
|
||||||
|
final int action = event.getAction();
|
||||||
|
|
||||||
|
if (action == MotionEvent.ACTION_CANCEL && longClickHandler != null) {
|
||||||
|
longClickHandler.removeCallbacksAndMessages(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
|
||||||
|
final int offset = getOffsetForHorizontalLine(widget, event);
|
||||||
|
final LongPressClickableSpan[] link = buffer.getSpans(offset, offset,
|
||||||
|
LongPressClickableSpan.class);
|
||||||
|
|
||||||
|
if (link.length != 0) {
|
||||||
|
if (action == MotionEvent.ACTION_UP) {
|
||||||
|
if (longClickHandler != null) {
|
||||||
|
longClickHandler.removeCallbacksAndMessages(null);
|
||||||
|
}
|
||||||
|
if (!isLongPressed) {
|
||||||
|
link[0].onClick(widget);
|
||||||
|
}
|
||||||
|
isLongPressed = false;
|
||||||
|
} else {
|
||||||
|
Selection.setSelection(buffer, buffer.getSpanStart(link[0]),
|
||||||
|
buffer.getSpanEnd(link[0]));
|
||||||
|
if (longClickHandler != null) {
|
||||||
|
longClickHandler.postDelayed(() -> {
|
||||||
|
link[0].onLongClick(widget);
|
||||||
|
isLongPressed = true;
|
||||||
|
}, LONG_PRESS_TIME);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.onTouchEvent(widget, buffer, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static MovementMethod getInstance() {
|
||||||
|
if (instance == null) {
|
||||||
|
instance = new LongPressLinkMovementMethod();
|
||||||
|
instance.longClickHandler = new Handler(Looper.myLooper());
|
||||||
|
}
|
||||||
|
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,369 @@
|
|||||||
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.text.SpannableStringBuilder;
|
||||||
|
import android.text.style.URLSpan;
|
||||||
|
import android.text.util.Linkify;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.view.View;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.core.text.HtmlCompat;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.extractor.StreamingService;
|
||||||
|
import org.schabi.newpipe.extractor.stream.Description;
|
||||||
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
|
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||||
|
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
import io.noties.markwon.Markwon;
|
||||||
|
import io.noties.markwon.linkify.LinkifyPlugin;
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||||
|
import io.reactivex.rxjava3.core.Single;
|
||||||
|
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||||
|
|
||||||
|
public final class TextLinkifier {
|
||||||
|
public static final String TAG = TextLinkifier.class.getSimpleName();
|
||||||
|
|
||||||
|
// Looks for hashtags with characters from any language (\p{L}), numbers, or underscores
|
||||||
|
private static final Pattern HASHTAGS_PATTERN = Pattern.compile("(#[\\p{L}0-9_]+)");
|
||||||
|
|
||||||
|
public static final Consumer<TextView> SET_LINK_MOVEMENT_METHOD =
|
||||||
|
v -> v.setMovementMethod(LongPressLinkMovementMethod.getInstance());
|
||||||
|
|
||||||
|
private TextLinkifier() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create links for contents with an {@link Description} in the various possible formats.
|
||||||
|
* <p>
|
||||||
|
* This will call one of these three functions based on the format: {@link #fromHtml},
|
||||||
|
* {@link #fromMarkdown} or {@link #fromPlainText}.
|
||||||
|
*
|
||||||
|
* @param textView the TextView to set the htmlBlock linked
|
||||||
|
* @param description the htmlBlock to be linked
|
||||||
|
* @param htmlCompatFlag the int flag to be set if {@link HtmlCompat#fromHtml(String, int)}
|
||||||
|
* will be called (not used for formats different than HTML)
|
||||||
|
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
|
||||||
|
* service
|
||||||
|
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
|
||||||
|
* timestamps to open the stream in the popup player at the specific
|
||||||
|
* time
|
||||||
|
* @param disposables disposables created by the method are added here and their
|
||||||
|
* lifecycle should be handled by the calling class
|
||||||
|
* @param onCompletion will be run when setting text to the textView completes; use {@link
|
||||||
|
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
|
||||||
|
*/
|
||||||
|
public static void fromDescription(@NonNull final TextView textView,
|
||||||
|
@NonNull final Description description,
|
||||||
|
final int htmlCompatFlag,
|
||||||
|
@Nullable final StreamingService relatedInfoService,
|
||||||
|
@Nullable final String relatedStreamUrl,
|
||||||
|
@NonNull final CompositeDisposable disposables,
|
||||||
|
@Nullable final Consumer<TextView> onCompletion) {
|
||||||
|
switch (description.getType()) {
|
||||||
|
case Description.HTML:
|
||||||
|
TextLinkifier.fromHtml(textView, description.getContent(), htmlCompatFlag,
|
||||||
|
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
|
||||||
|
break;
|
||||||
|
case Description.MARKDOWN:
|
||||||
|
TextLinkifier.fromMarkdown(textView, description.getContent(),
|
||||||
|
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
|
||||||
|
break;
|
||||||
|
case Description.PLAIN_TEXT: default:
|
||||||
|
TextLinkifier.fromPlainText(textView, description.getContent(),
|
||||||
|
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create links for contents with an HTML description.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService,
|
||||||
|
* String, CompositeDisposable, Consumer)} after having linked the URLs with
|
||||||
|
* {@link HtmlCompat#fromHtml(String, int)}.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param textView the {@link TextView} to set the the HTML string block linked
|
||||||
|
* @param htmlBlock the HTML string block to be linked
|
||||||
|
* @param htmlCompatFlag the int flag to be set when {@link HtmlCompat#fromHtml(String,
|
||||||
|
* int)} will be called
|
||||||
|
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
|
||||||
|
* service
|
||||||
|
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
|
||||||
|
* timestamps to open the stream in the popup player at the specific
|
||||||
|
* time
|
||||||
|
* @param disposables disposables created by the method are added here and their
|
||||||
|
* lifecycle should be handled by the calling class
|
||||||
|
* @param onCompletion will be run when setting text to the textView completes; use {@link
|
||||||
|
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
|
||||||
|
*/
|
||||||
|
public static void fromHtml(@NonNull final TextView textView,
|
||||||
|
@NonNull final String htmlBlock,
|
||||||
|
final int htmlCompatFlag,
|
||||||
|
@Nullable final StreamingService relatedInfoService,
|
||||||
|
@Nullable final String relatedStreamUrl,
|
||||||
|
@NonNull final CompositeDisposable disposables,
|
||||||
|
@Nullable final Consumer<TextView> onCompletion) {
|
||||||
|
changeLinkIntents(
|
||||||
|
textView, HtmlCompat.fromHtml(htmlBlock, htmlCompatFlag), relatedInfoService,
|
||||||
|
relatedStreamUrl, disposables, onCompletion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create links for contents with a plain text description.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService,
|
||||||
|
* String, CompositeDisposable, Consumer)} after having linked the URLs with
|
||||||
|
* {@link TextView#setAutoLinkMask(int)} and
|
||||||
|
* {@link TextView#setText(CharSequence, TextView.BufferType)}.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param textView the {@link TextView} to set the plain text block linked
|
||||||
|
* @param plainTextBlock the block of plain text to be linked
|
||||||
|
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
|
||||||
|
* service
|
||||||
|
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
|
||||||
|
* timestamps to open the stream in the popup player at the specific
|
||||||
|
* time
|
||||||
|
* @param disposables disposables created by the method are added here and their
|
||||||
|
* lifecycle should be handled by the calling class
|
||||||
|
* @param onCompletion will be run when setting text to the textView completes; use {@link
|
||||||
|
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
|
||||||
|
*/
|
||||||
|
public static void fromPlainText(@NonNull final TextView textView,
|
||||||
|
@NonNull final String plainTextBlock,
|
||||||
|
@Nullable final StreamingService relatedInfoService,
|
||||||
|
@Nullable final String relatedStreamUrl,
|
||||||
|
@NonNull final CompositeDisposable disposables,
|
||||||
|
@Nullable final Consumer<TextView> onCompletion) {
|
||||||
|
textView.setAutoLinkMask(Linkify.WEB_URLS);
|
||||||
|
textView.setText(plainTextBlock, TextView.BufferType.SPANNABLE);
|
||||||
|
changeLinkIntents(textView, textView.getText(), relatedInfoService,
|
||||||
|
relatedStreamUrl, disposables, onCompletion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create links for contents with a markdown description.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This method will call {@link #changeLinkIntents(TextView, CharSequence, StreamingService,
|
||||||
|
* String, CompositeDisposable, Consumer)} after creating a {@link Markwon} object and using
|
||||||
|
* {@link Markwon#setMarkdown(TextView, String)}.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param textView the {@link TextView} to set the plain text block linked
|
||||||
|
* @param markdownBlock the block of markdown text to be linked
|
||||||
|
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
|
||||||
|
* service
|
||||||
|
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
|
||||||
|
* timestamps to open the stream in the popup player at the specific
|
||||||
|
* time
|
||||||
|
* @param disposables disposables created by the method are added here and their
|
||||||
|
* lifecycle should be handled by the calling class
|
||||||
|
* @param onCompletion will be run when setting text to the textView completes; use {@link
|
||||||
|
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
|
||||||
|
*/
|
||||||
|
public static void fromMarkdown(@NonNull final TextView textView,
|
||||||
|
@NonNull final String markdownBlock,
|
||||||
|
@Nullable final StreamingService relatedInfoService,
|
||||||
|
@Nullable final String relatedStreamUrl,
|
||||||
|
@NonNull final CompositeDisposable disposables,
|
||||||
|
@Nullable final Consumer<TextView> onCompletion) {
|
||||||
|
final Markwon markwon = Markwon.builder(textView.getContext())
|
||||||
|
.usePlugin(LinkifyPlugin.create()).build();
|
||||||
|
changeLinkIntents(textView, markwon.toMarkdown(markdownBlock),
|
||||||
|
relatedInfoService, relatedStreamUrl, disposables, onCompletion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change links generated by libraries in the description of a content to a custom link action
|
||||||
|
* and add click listeners on timestamps in this description.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* Instead of using an {@link android.content.Intent#ACTION_VIEW} intent in the description of
|
||||||
|
* a content, this method will parse the {@link CharSequence} and replace all current web links
|
||||||
|
* with {@link ShareUtils#openUrlInBrowser(Context, String, boolean)}.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This method will also add click listeners on timestamps in this description, which will play
|
||||||
|
* the content in the popup player at the time indicated in the timestamp, by using
|
||||||
|
* {@link TextLinkifier#addClickListenersOnTimestamps(Context, SpannableStringBuilder,
|
||||||
|
* StreamingService, String, CompositeDisposable)} method and click listeners on hashtags, by
|
||||||
|
* using {@link TextLinkifier#addClickListenersOnHashtags(Context, SpannableStringBuilder,
|
||||||
|
* StreamingService)}, which will open a search on the current service with the hashtag.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This method is required in order to intercept links and e.g. show a confirmation dialog
|
||||||
|
* before opening a web link.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param textView the {@link TextView} to which the converted {@link CharSequence}
|
||||||
|
* will be applied
|
||||||
|
* @param chars the {@link CharSequence} to be parsed
|
||||||
|
* @param relatedInfoService if given, handle hashtags to search for the term in the correct
|
||||||
|
* service
|
||||||
|
* @param relatedStreamUrl if given, used alongside {@code relatedInfoService} to handle
|
||||||
|
* timestamps to open the stream in the popup player at the specific
|
||||||
|
* time
|
||||||
|
* @param disposables disposables created by the method are added here and their
|
||||||
|
* lifecycle should be handled by the calling class
|
||||||
|
* @param onCompletion will be run when setting text to the textView completes; use {@link
|
||||||
|
* #SET_LINK_MOVEMENT_METHOD} to make links clickable and focusable
|
||||||
|
*/
|
||||||
|
private static void changeLinkIntents(@NonNull final TextView textView,
|
||||||
|
@NonNull final CharSequence chars,
|
||||||
|
@Nullable final StreamingService relatedInfoService,
|
||||||
|
@Nullable final String relatedStreamUrl,
|
||||||
|
@NonNull final CompositeDisposable disposables,
|
||||||
|
@Nullable final Consumer<TextView> onCompletion) {
|
||||||
|
disposables.add(Single.fromCallable(() -> {
|
||||||
|
final Context context = textView.getContext();
|
||||||
|
|
||||||
|
// add custom click actions on web links
|
||||||
|
final SpannableStringBuilder textBlockLinked =
|
||||||
|
new SpannableStringBuilder(chars);
|
||||||
|
final URLSpan[] urls = textBlockLinked.getSpans(0, chars.length(),
|
||||||
|
URLSpan.class);
|
||||||
|
|
||||||
|
for (final URLSpan span : urls) {
|
||||||
|
final String url = span.getURL();
|
||||||
|
final LongPressClickableSpan longPressClickableSpan =
|
||||||
|
new UrlLongPressClickableSpan(context, disposables, url);
|
||||||
|
|
||||||
|
textBlockLinked.setSpan(longPressClickableSpan,
|
||||||
|
textBlockLinked.getSpanStart(span),
|
||||||
|
textBlockLinked.getSpanEnd(span),
|
||||||
|
textBlockLinked.getSpanFlags(span));
|
||||||
|
textBlockLinked.removeSpan(span);
|
||||||
|
}
|
||||||
|
|
||||||
|
// add click actions on plain text timestamps only for description of contents,
|
||||||
|
// unneeded for meta-info or other TextViews
|
||||||
|
if (relatedInfoService != null) {
|
||||||
|
if (relatedStreamUrl != null) {
|
||||||
|
addClickListenersOnTimestamps(context, textBlockLinked,
|
||||||
|
relatedInfoService, relatedStreamUrl, disposables);
|
||||||
|
}
|
||||||
|
addClickListenersOnHashtags(context, textBlockLinked, relatedInfoService);
|
||||||
|
}
|
||||||
|
|
||||||
|
return textBlockLinked;
|
||||||
|
}).subscribeOn(Schedulers.computation())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe(
|
||||||
|
textBlockLinked ->
|
||||||
|
setTextViewCharSequence(textView, textBlockLinked, onCompletion),
|
||||||
|
throwable -> {
|
||||||
|
Log.e(TAG, "Unable to linkify text", throwable);
|
||||||
|
// this should never happen, but if it does, just fallback to it
|
||||||
|
setTextViewCharSequence(textView, chars, onCompletion);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add click listeners which opens a search on hashtags in a plain text.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This method finds all timestamps in the {@link SpannableStringBuilder} of the description
|
||||||
|
* using a regular expression, adds for each a {@link LongPressClickableSpan} which opens
|
||||||
|
* {@link NavigationHelper#openSearch(Context, int, String)} and makes a search on the hashtag,
|
||||||
|
* in the service of the content when pressed, and copy the hashtag to clipboard when
|
||||||
|
* long-pressed, if allowed by the caller method (parameter {@code addLongClickCopyListener}).
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param context the {@link Context} to use
|
||||||
|
* @param spannableDescription the {@link SpannableStringBuilder} with the text of the
|
||||||
|
* content description
|
||||||
|
* @param relatedInfoService used to search for the term in the correct service
|
||||||
|
*/
|
||||||
|
private static void addClickListenersOnHashtags(
|
||||||
|
@NonNull final Context context,
|
||||||
|
@NonNull final SpannableStringBuilder spannableDescription,
|
||||||
|
@NonNull final StreamingService relatedInfoService) {
|
||||||
|
final String descriptionText = spannableDescription.toString();
|
||||||
|
final Matcher hashtagsMatches = HASHTAGS_PATTERN.matcher(descriptionText);
|
||||||
|
|
||||||
|
while (hashtagsMatches.find()) {
|
||||||
|
final int hashtagStart = hashtagsMatches.start(1);
|
||||||
|
final int hashtagEnd = hashtagsMatches.end(1);
|
||||||
|
final String parsedHashtag = descriptionText.substring(hashtagStart, hashtagEnd);
|
||||||
|
|
||||||
|
// Don't add a LongPressClickableSpan if there is already one, which should be a part
|
||||||
|
// of an URL, already parsed before
|
||||||
|
if (spannableDescription.getSpans(hashtagStart, hashtagEnd,
|
||||||
|
LongPressClickableSpan.class).length == 0) {
|
||||||
|
final int serviceId = relatedInfoService.getServiceId();
|
||||||
|
spannableDescription.setSpan(
|
||||||
|
new HashtagLongPressClickableSpan(context, parsedHashtag, serviceId),
|
||||||
|
hashtagStart, hashtagEnd, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add click listeners which opens the popup player on timestamps in a plain text.
|
||||||
|
*
|
||||||
|
* <p>
|
||||||
|
* This method finds all timestamps in the {@link SpannableStringBuilder} of the description
|
||||||
|
* using a regular expression, adds for each a {@link LongPressClickableSpan} which opens the
|
||||||
|
* popup player at the time indicated in the timestamps and copy the timestamp in clipboard
|
||||||
|
* when long-pressed.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param context the {@link Context} to use
|
||||||
|
* @param spannableDescription the {@link SpannableStringBuilder} with the text of the
|
||||||
|
* content description
|
||||||
|
* @param relatedInfoService the service of the {@code relatedStreamUrl}
|
||||||
|
* @param relatedStreamUrl what to open in the popup player when timestamps are clicked
|
||||||
|
* @param disposables disposables created by the method are added here and their
|
||||||
|
* lifecycle should be handled by the calling class
|
||||||
|
*/
|
||||||
|
private static void addClickListenersOnTimestamps(
|
||||||
|
@NonNull final Context context,
|
||||||
|
@NonNull final SpannableStringBuilder spannableDescription,
|
||||||
|
@NonNull final StreamingService relatedInfoService,
|
||||||
|
@NonNull final String relatedStreamUrl,
|
||||||
|
@NonNull final CompositeDisposable disposables) {
|
||||||
|
final String descriptionText = spannableDescription.toString();
|
||||||
|
final Matcher timestampsMatches = TimestampExtractor.TIMESTAMPS_PATTERN.matcher(
|
||||||
|
descriptionText);
|
||||||
|
|
||||||
|
while (timestampsMatches.find()) {
|
||||||
|
final TimestampExtractor.TimestampMatchDTO timestampMatchDTO =
|
||||||
|
TimestampExtractor.getTimestampFromMatcher(timestampsMatches, descriptionText);
|
||||||
|
|
||||||
|
if (timestampMatchDTO == null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
spannableDescription.setSpan(
|
||||||
|
new TimestampLongPressClickableSpan(context, descriptionText, disposables,
|
||||||
|
relatedInfoService, relatedStreamUrl, timestampMatchDTO),
|
||||||
|
timestampMatchDTO.timestampStart(),
|
||||||
|
timestampMatchDTO.timestampEnd(),
|
||||||
|
0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void setTextViewCharSequence(@NonNull final TextView textView,
|
||||||
|
@Nullable final CharSequence charSequence,
|
||||||
|
@Nullable final Consumer<TextView> onCompletion) {
|
||||||
|
textView.setText(charSequence);
|
||||||
|
textView.setVisibility(View.VISIBLE);
|
||||||
|
if (onCompletion != null) {
|
||||||
|
onCompletion.accept(textView);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,7 @@
|
|||||||
package org.schabi.newpipe.util.external_communication;
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
import java.util.regex.Matcher;
|
import java.util.regex.Matcher;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
@ -15,17 +18,18 @@ public final class TimestampExtractor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get's a single timestamp from a matcher.
|
* Gets a single timestamp from a matcher.
|
||||||
*
|
*
|
||||||
* @param timestampMatches The matcher which was created using {@link #TIMESTAMPS_PATTERN}
|
* @param timestampMatches the matcher which was created using {@link #TIMESTAMPS_PATTERN}
|
||||||
* @param baseText The text where the pattern was applied to /
|
* @param baseText the text where the pattern was applied to / where the matcher is
|
||||||
* where the matcher is based upon
|
* based upon
|
||||||
* @return If a match occurred: a {@link TimestampMatchDTO} filled with information.<br/>
|
* @return if a match occurred, a {@link TimestampMatchDTO} filled with information, otherwise
|
||||||
* If not <code>null</code>.
|
* {@code null}.
|
||||||
*/
|
*/
|
||||||
|
@Nullable
|
||||||
public static TimestampMatchDTO getTimestampFromMatcher(
|
public static TimestampMatchDTO getTimestampFromMatcher(
|
||||||
final Matcher timestampMatches,
|
@NonNull final Matcher timestampMatches,
|
||||||
final String baseText) {
|
@NonNull final String baseText) {
|
||||||
int timestampStart = timestampMatches.start(1);
|
int timestampStart = timestampMatches.start(1);
|
||||||
if (timestampStart == -1) {
|
if (timestampStart == -1) {
|
||||||
timestampStart = timestampMatches.start(2);
|
timestampStart = timestampMatches.start(2);
|
@ -0,0 +1,78 @@
|
|||||||
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.util.text.InternalUrlsHandler.playOnPopup;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.extractor.ServiceList;
|
||||||
|
import org.schabi.newpipe.extractor.StreamingService;
|
||||||
|
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||||
|
|
||||||
|
final class TimestampLongPressClickableSpan extends LongPressClickableSpan {
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private final Context context;
|
||||||
|
@NonNull
|
||||||
|
private final String descriptionText;
|
||||||
|
@NonNull
|
||||||
|
private final CompositeDisposable disposables;
|
||||||
|
@NonNull
|
||||||
|
private final StreamingService relatedInfoService;
|
||||||
|
@NonNull
|
||||||
|
private final String relatedStreamUrl;
|
||||||
|
@NonNull
|
||||||
|
private final TimestampExtractor.TimestampMatchDTO timestampMatchDTO;
|
||||||
|
|
||||||
|
TimestampLongPressClickableSpan(
|
||||||
|
@NonNull final Context context,
|
||||||
|
@NonNull final String descriptionText,
|
||||||
|
@NonNull final CompositeDisposable disposables,
|
||||||
|
@NonNull final StreamingService relatedInfoService,
|
||||||
|
@NonNull final String relatedStreamUrl,
|
||||||
|
@NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) {
|
||||||
|
this.context = context;
|
||||||
|
this.descriptionText = descriptionText;
|
||||||
|
this.disposables = disposables;
|
||||||
|
this.relatedInfoService = relatedInfoService;
|
||||||
|
this.relatedStreamUrl = relatedStreamUrl;
|
||||||
|
this.timestampMatchDTO = timestampMatchDTO;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClick(@NonNull final View view) {
|
||||||
|
playOnPopup(context, relatedStreamUrl, relatedInfoService,
|
||||||
|
timestampMatchDTO.seconds(), disposables);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLongClick(@NonNull final View view) {
|
||||||
|
ShareUtils.copyToClipboard(context, getTimestampTextToCopy(
|
||||||
|
relatedInfoService, relatedStreamUrl, descriptionText, timestampMatchDTO));
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private static String getTimestampTextToCopy(
|
||||||
|
@NonNull final StreamingService relatedInfoService,
|
||||||
|
@NonNull final String relatedStreamUrl,
|
||||||
|
@NonNull final String descriptionText,
|
||||||
|
@NonNull final TimestampExtractor.TimestampMatchDTO timestampMatchDTO) {
|
||||||
|
// TODO: use extractor methods to get timestamps when this feature will be implemented in it
|
||||||
|
if (relatedInfoService == ServiceList.YouTube) {
|
||||||
|
return relatedStreamUrl + "&t=" + timestampMatchDTO.seconds();
|
||||||
|
} else if (relatedInfoService == ServiceList.SoundCloud
|
||||||
|
|| relatedInfoService == ServiceList.MediaCCC) {
|
||||||
|
return relatedStreamUrl + "#t=" + timestampMatchDTO.seconds();
|
||||||
|
} else if (relatedInfoService == ServiceList.PeerTube) {
|
||||||
|
return relatedStreamUrl + "?start=" + timestampMatchDTO.seconds();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return timestamp text for other services
|
||||||
|
return descriptionText.subSequence(timestampMatchDTO.timestampStart(),
|
||||||
|
timestampMatchDTO.timestampEnd()).toString();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,38 @@
|
|||||||
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
|
import android.text.Layout;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
public final class TouchUtils {
|
||||||
|
|
||||||
|
private TouchUtils() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the character offset on the closest line to the position pressed by the user of a
|
||||||
|
* {@link TextView} from a {@link MotionEvent} which was fired on this {@link TextView}.
|
||||||
|
*
|
||||||
|
* @param textView the {@link TextView} on which the {@link MotionEvent} was fired
|
||||||
|
* @param event the {@link MotionEvent} which was fired
|
||||||
|
* @return the character offset on the closest line to the position pressed by the user
|
||||||
|
*/
|
||||||
|
public static int getOffsetForHorizontalLine(@NonNull final TextView textView,
|
||||||
|
@NonNull final MotionEvent event) {
|
||||||
|
|
||||||
|
int x = (int) event.getX();
|
||||||
|
int y = (int) event.getY();
|
||||||
|
|
||||||
|
x -= textView.getTotalPaddingLeft();
|
||||||
|
y -= textView.getTotalPaddingTop();
|
||||||
|
|
||||||
|
x += textView.getScrollX();
|
||||||
|
y += textView.getScrollY();
|
||||||
|
|
||||||
|
final Layout layout = textView.getLayout();
|
||||||
|
final int line = layout.getLineForVertical(y);
|
||||||
|
return layout.getOffsetForHorizontal(line, x);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,41 @@
|
|||||||
|
package org.schabi.newpipe.util.text;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.view.View;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||||
|
|
||||||
|
final class UrlLongPressClickableSpan extends LongPressClickableSpan {
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private final Context context;
|
||||||
|
@NonNull
|
||||||
|
private final CompositeDisposable disposables;
|
||||||
|
@NonNull
|
||||||
|
private final String url;
|
||||||
|
|
||||||
|
UrlLongPressClickableSpan(@NonNull final Context context,
|
||||||
|
@NonNull final CompositeDisposable disposables,
|
||||||
|
@NonNull final String url) {
|
||||||
|
this.context = context;
|
||||||
|
this.disposables = disposables;
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onClick(@NonNull final View view) {
|
||||||
|
if (!InternalUrlsHandler.handleUrlDescriptionTimestamp(
|
||||||
|
disposables, context, url)) {
|
||||||
|
ShareUtils.openUrlInBrowser(context, url, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLongClick(@NonNull final View view) {
|
||||||
|
ShareUtils.copyToClipboard(context, url);
|
||||||
|
}
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user