mirror of
https://github.com/TeamNewPipe/NewPipe
synced 2026-03-28 18:41:23 +00:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da76ddbf19 | ||
|
|
75ba70ab41 | ||
|
|
6d50fe79b8 | ||
|
|
f8c9ab8b11 | ||
|
|
949f0f3448 | ||
|
|
af08ddcf04 | ||
|
|
09c96159d1 | ||
|
|
2dda39205d | ||
|
|
3112eefb99 | ||
|
|
cf0d7016ec | ||
|
|
b0f2d509e6 | ||
|
|
db61af15e8 | ||
|
|
80a47be218 | ||
|
|
179a713561 | ||
|
|
fa6412a9aa | ||
|
|
3f8d26d33e | ||
|
|
2fec3a3c58 | ||
|
|
08326c64cb | ||
|
|
831425a742 | ||
|
|
fbf3b7a905 | ||
|
|
515bb6e94d | ||
|
|
349000857a | ||
|
|
f1c608b396 | ||
|
|
5f1a270ca4 | ||
|
|
aba2a385fd | ||
|
|
71a3bf2855 | ||
|
|
ebb937934a | ||
|
|
223b240299 | ||
|
|
668af4fc3e | ||
|
|
bfcc31ec89 | ||
|
|
6fa97e17f5 | ||
|
|
0d65733e53 | ||
|
|
9cc6f9fd68 | ||
|
|
79767f95f7 | ||
|
|
21b37b5fa4 | ||
|
|
675d15686d | ||
|
|
54021a96ea | ||
|
|
7c88f06196 | ||
|
|
93f9a52e3e | ||
|
|
d9fd1bf003 | ||
|
|
dc2e4aaea5 | ||
|
|
d5f941ff3d | ||
|
|
05f09c94d1 | ||
|
|
816f5f9aba | ||
|
|
47624a575a | ||
|
|
3b3348e7a1 | ||
|
|
521f60af85 | ||
|
|
d497f1d88e | ||
|
|
dda219a9e9 | ||
|
|
b8ec9bf412 | ||
|
|
e173bf4252 | ||
|
|
9f45aa571c | ||
|
|
e6e5fc70f2 | ||
|
|
8fa6f9670d | ||
|
|
0cdf40cd5f | ||
|
|
0020a02a28 | ||
|
|
1fbf9fc025 | ||
|
|
8b500c7b83 | ||
|
|
98a883d377 | ||
|
|
6dddcf3805 | ||
|
|
bf1633265c | ||
|
|
e22b046326 | ||
|
|
56fb31d0fd | ||
|
|
042f9460b0 | ||
|
|
9f1e2c6fd0 | ||
|
|
66237abb3c | ||
|
|
dd65db56a9 | ||
|
|
195a76bb08 | ||
|
|
06e4548c14 | ||
|
|
9a292e33f9 | ||
|
|
e358867da8 | ||
|
|
e6daf45c83 |
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -69,7 +69,7 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- api-level: 23
|
- api-level: 21
|
||||||
target: default
|
target: default
|
||||||
arch: x86
|
arch: x86
|
||||||
- api-level: 35
|
- api-level: 35
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -11,7 +11,6 @@ captures/
|
|||||||
*.class
|
*.class
|
||||||
app/debug/
|
app/debug/
|
||||||
app/release/
|
app/release/
|
||||||
.kotlin/
|
|
||||||
|
|
||||||
# vscode / eclipse files
|
# vscode / eclipse files
|
||||||
*.classpath
|
*.classpath
|
||||||
@@ -20,6 +19,3 @@ app/release/
|
|||||||
bin/
|
bin/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.code-workspace
|
*.code-workspace
|
||||||
|
|
||||||
# logs
|
|
||||||
*.log
|
|
||||||
|
|||||||
@@ -2,21 +2,17 @@
|
|||||||
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
* SPDX-FileCopyrightText: 2025 NewPipe e.V. <https://newpipe-ev.de>
|
||||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
*/
|
*/
|
||||||
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
|
|
||||||
|
|
||||||
import com.android.build.api.dsl.ApplicationExtension
|
import com.android.build.api.dsl.ApplicationExtension
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.jetbrains.kotlin.android)
|
alias(libs.plugins.jetbrains.kotlin.android)
|
||||||
alias(libs.plugins.jetbrains.kotlin.compose)
|
|
||||||
alias(libs.plugins.jetbrains.kotlin.kapt)
|
alias(libs.plugins.jetbrains.kotlin.kapt)
|
||||||
|
alias(libs.plugins.google.ksp)
|
||||||
alias(libs.plugins.jetbrains.kotlin.parcelize)
|
alias(libs.plugins.jetbrains.kotlin.parcelize)
|
||||||
alias(libs.plugins.jetbrains.kotlinx.serialization)
|
alias(libs.plugins.jetbrains.kotlinx.serialization)
|
||||||
alias(libs.plugins.google.ksp)
|
|
||||||
alias(libs.plugins.sonarqube)
|
alias(libs.plugins.sonarqube)
|
||||||
alias(libs.plugins.hilt)
|
|
||||||
alias(libs.plugins.about.libraries)
|
|
||||||
checkstyle
|
checkstyle
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,12 +42,12 @@ configure<ApplicationExtension> {
|
|||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "org.schabi.newpipe"
|
applicationId = "org.schabi.newpipe"
|
||||||
resValue("string", "app_name", "NewPipe")
|
resValue("string", "app_name", "NewPipe")
|
||||||
minSdk = 23
|
minSdk = 21
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
|
|
||||||
versionCode = System.getProperty("versionCodeOverride")?.toInt() ?: 1008
|
versionCode = System.getProperty("versionCodeOverride")?.toInt() ?: 1009
|
||||||
|
|
||||||
versionName = "0.28.3"
|
versionName = "0.28.4"
|
||||||
System.getProperty("versionNameSuffix")?.let { versionNameSuffix = it }
|
System.getProperty("versionNameSuffix")?.let { versionNameSuffix = it }
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
@@ -116,7 +112,6 @@ configure<ApplicationExtension> {
|
|||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding = true
|
viewBinding = true
|
||||||
compose = true
|
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
resValues = true
|
resValues = true
|
||||||
}
|
}
|
||||||
@@ -216,13 +211,6 @@ sonar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
aboutLibraries {
|
|
||||||
// note: offline mode prevents the plugin from fetching licenses at build time, which would be
|
|
||||||
// harmful for reproducible builds
|
|
||||||
offlineMode = true
|
|
||||||
duplicationMode = DuplicateMode.MERGE
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
/** Desugaring **/
|
/** Desugaring **/
|
||||||
coreLibraryDesugaring(libs.android.desugar)
|
coreLibraryDesugaring(libs.android.desugar)
|
||||||
@@ -236,18 +224,16 @@ dependencies {
|
|||||||
checkstyle(libs.puppycrawl.checkstyle)
|
checkstyle(libs.puppycrawl.checkstyle)
|
||||||
ktlint(libs.pinterest.ktlint)
|
ktlint(libs.pinterest.ktlint)
|
||||||
|
|
||||||
/** Kotlin **/
|
|
||||||
implementation(libs.kotlin.stdlib)
|
|
||||||
|
|
||||||
/** AndroidX **/
|
/** AndroidX **/
|
||||||
implementation(libs.androidx.appcompat)
|
implementation(libs.androidx.appcompat)
|
||||||
implementation(libs.androidx.cardview)
|
implementation(libs.androidx.cardview)
|
||||||
implementation(libs.androidx.constraintlayout)
|
implementation(libs.androidx.constraintlayout)
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core)
|
||||||
implementation(libs.androidx.documentfile)
|
implementation(libs.androidx.documentfile)
|
||||||
implementation(libs.androidx.fragment.compose)
|
implementation(libs.androidx.fragment)
|
||||||
implementation(libs.androidx.lifecycle.livedata)
|
implementation(libs.androidx.lifecycle.livedata)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.ktx)
|
implementation(libs.androidx.lifecycle.viewmodel)
|
||||||
|
implementation(libs.androidx.localbroadcastmanager)
|
||||||
implementation(libs.androidx.media)
|
implementation(libs.androidx.media)
|
||||||
implementation(libs.androidx.preference)
|
implementation(libs.androidx.preference)
|
||||||
implementation(libs.androidx.recyclerview)
|
implementation(libs.androidx.recyclerview)
|
||||||
@@ -255,44 +241,19 @@ dependencies {
|
|||||||
implementation(libs.androidx.room.rxjava3)
|
implementation(libs.androidx.room.rxjava3)
|
||||||
ksp(libs.androidx.room.compiler)
|
ksp(libs.androidx.room.compiler)
|
||||||
implementation(libs.androidx.swiperefreshlayout)
|
implementation(libs.androidx.swiperefreshlayout)
|
||||||
implementation(libs.androidx.work.runtime.ktx)
|
implementation(libs.androidx.viewpager2)
|
||||||
|
implementation(libs.androidx.work.runtime)
|
||||||
implementation(libs.androidx.work.rxjava3)
|
implementation(libs.androidx.work.rxjava3)
|
||||||
implementation(libs.google.android.material)
|
implementation(libs.google.android.material)
|
||||||
implementation(libs.androidx.webkit)
|
implementation(libs.androidx.webkit)
|
||||||
|
|
||||||
/** Compose & other modern patterns **/
|
|
||||||
// Jetpack Compose
|
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
|
||||||
implementation(libs.androidx.compose.material3)
|
|
||||||
implementation(libs.androidx.compose.adaptive)
|
|
||||||
implementation(libs.androidx.activity.compose)
|
|
||||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
|
||||||
implementation(libs.androidx.compose.ui.text) // Needed for parsing HTML to AnnotatedString
|
|
||||||
implementation(libs.androidx.compose.material.icons.extended)
|
|
||||||
|
|
||||||
// Jetpack Compose related dependencies
|
|
||||||
implementation(libs.androidx.paging.compose)
|
|
||||||
implementation(libs.androidx.navigation.compose)
|
|
||||||
|
|
||||||
// Coroutines interop
|
// Coroutines interop
|
||||||
implementation(libs.kotlinx.coroutines.rx3)
|
implementation(libs.kotlinx.coroutines.rx3)
|
||||||
|
|
||||||
// Library loading for About screen
|
|
||||||
implementation(libs.about.libraries.compose.m3)
|
|
||||||
|
|
||||||
// Hilt
|
|
||||||
implementation(libs.hilt.android)
|
|
||||||
ksp(libs.hilt.compiler)
|
|
||||||
|
|
||||||
// Scroll
|
|
||||||
implementation(libs.lazy.column.scrollbar)
|
|
||||||
|
|
||||||
// Kotlinx Serialization
|
// Kotlinx Serialization
|
||||||
implementation(libs.kotlinx.serialization.json)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
|
|
||||||
/** Third-party libraries **/
|
/** Third-party libraries **/
|
||||||
// Instance state boilerplate elimination
|
|
||||||
implementation(libs.livefront.bridge)
|
implementation(libs.livefront.bridge)
|
||||||
implementation(libs.evernote.statesaver.core)
|
implementation(libs.evernote.statesaver.core)
|
||||||
kapt(libs.evernote.statesaver.compiler)
|
kapt(libs.evernote.statesaver.compiler)
|
||||||
@@ -351,9 +312,6 @@ dependencies {
|
|||||||
debugImplementation(libs.facebook.stetho.core)
|
debugImplementation(libs.facebook.stetho.core)
|
||||||
debugImplementation(libs.facebook.stetho.okhttp3)
|
debugImplementation(libs.facebook.stetho.okhttp3)
|
||||||
|
|
||||||
// Jetpack Compose
|
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
|
||||||
|
|
||||||
/** Testing **/
|
/** Testing **/
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
testImplementation(libs.mockito.core)
|
testImplementation(libs.mockito.core)
|
||||||
@@ -362,7 +320,4 @@ dependencies {
|
|||||||
androidTestImplementation(libs.androidx.runner)
|
androidTestImplementation(libs.androidx.runner)
|
||||||
androidTestImplementation(libs.androidx.room.testing)
|
androidTestImplementation(libs.androidx.room.testing)
|
||||||
androidTestImplementation(libs.assertj.core)
|
androidTestImplementation(libs.assertj.core)
|
||||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
|
||||||
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
tasks.register('checkDependenciesOrder') {
|
|
||||||
group = 'verification'
|
|
||||||
description = 'Checks that each section in libs.versions.toml is sorted alphabetically'
|
|
||||||
|
|
||||||
def tomlFile = file('../gradle/libs.versions.toml')
|
|
||||||
|
|
||||||
doLast {
|
|
||||||
if (!tomlFile.exists()) {
|
|
||||||
throw new GradleException('TOML file not found')
|
|
||||||
}
|
|
||||||
|
|
||||||
def lines = tomlFile.readLines()
|
|
||||||
def nonSortedBlocks = []
|
|
||||||
def currentBlock = []
|
|
||||||
def prevLine = ''
|
|
||||||
def prevIndex = 0
|
|
||||||
|
|
||||||
lines.eachWithIndex { line, lineIndex ->
|
|
||||||
if (line.trim() && !line.startsWith('#')) {
|
|
||||||
if (line.startsWith('[')) {
|
|
||||||
prevLine = ''
|
|
||||||
} else {
|
|
||||||
def currIndex = lineIndex + 1
|
|
||||||
if (prevLine > line) {
|
|
||||||
if (currentBlock && currentBlock[-1] == "${prevIndex}: ${prevLine}") {
|
|
||||||
currentBlock.add("${currIndex}: ${line}")
|
|
||||||
} else {
|
|
||||||
if (!currentBlock.isEmpty()) {
|
|
||||||
nonSortedBlocks.add(currentBlock)
|
|
||||||
currentBlock = []
|
|
||||||
}
|
|
||||||
currentBlock.add("${prevIndex}: ${prevLine}")
|
|
||||||
currentBlock.add("${currIndex}: ${line}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
prevLine = line
|
|
||||||
prevIndex = lineIndex + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!currentBlock.isEmpty()) {
|
|
||||||
nonSortedBlocks.add(currentBlock)
|
|
||||||
throw new GradleException("The following lines were not sorted:\n" +
|
|
||||||
nonSortedBlocks.collect { it.join("\n") }.join("\n\n"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
10
app/proguard-rules.pro
vendored
10
app/proguard-rules.pro
vendored
@@ -40,6 +40,11 @@
|
|||||||
## For some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml)
|
## For some reason NotificationModeConfigFragment wasn't kept (only referenced in a preference xml)
|
||||||
-keep class org.schabi.newpipe.settings.notifications.** { *; }
|
-keep class org.schabi.newpipe.settings.notifications.** { *; }
|
||||||
|
|
||||||
|
# Prevent R8 from stripping or renaming Protobuf internal fields
|
||||||
|
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
|
||||||
|
<fields>;
|
||||||
|
}
|
||||||
|
|
||||||
## Keep Kotlinx Serialization classes
|
## Keep Kotlinx Serialization classes
|
||||||
-keepclassmembers class kotlinx.serialization.json.** {
|
-keepclassmembers class kotlinx.serialization.json.** {
|
||||||
*** Companion;
|
*** Companion;
|
||||||
@@ -54,8 +59,3 @@
|
|||||||
-keepclasseswithmembers class org.schabi.newpipe.** {
|
-keepclasseswithmembers class org.schabi.newpipe.** {
|
||||||
kotlinx.serialization.KSerializer serializer(...);
|
kotlinx.serialization.KSerializer serializer(...);
|
||||||
}
|
}
|
||||||
|
|
||||||
# Prevent R8 from stripping or renaming Protobuf internal fields
|
|
||||||
-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite {
|
|
||||||
<fields>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package org.schabi.newpipe.error;
|
||||||
|
|
||||||
|
import android.os.Parcel;
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.filters.LargeTest;
|
||||||
|
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.extractor.ServiceList;
|
||||||
|
import org.schabi.newpipe.extractor.exceptions.ParsingException;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented tests for {@link ErrorInfo}.
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
@LargeTest
|
||||||
|
public class ErrorInfoTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param errorInfo the error info to access
|
||||||
|
* @return the private field errorInfo.message.stringRes using reflection
|
||||||
|
*/
|
||||||
|
private int getMessageFromErrorInfo(final ErrorInfo errorInfo)
|
||||||
|
throws NoSuchFieldException, IllegalAccessException {
|
||||||
|
final var message = ErrorInfo.class.getDeclaredField("message");
|
||||||
|
message.setAccessible(true);
|
||||||
|
final var messageValue = (ErrorInfo.Companion.ErrorMessage) message.get(errorInfo);
|
||||||
|
|
||||||
|
final var stringRes = ErrorInfo.Companion.ErrorMessage.class.getDeclaredField("stringRes");
|
||||||
|
stringRes.setAccessible(true);
|
||||||
|
return (int) Objects.requireNonNull(stringRes.get(messageValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void errorInfoTestParcelable() throws NoSuchFieldException, IllegalAccessException {
|
||||||
|
final ErrorInfo info = new ErrorInfo(new ParsingException("Hello"),
|
||||||
|
UserAction.USER_REPORT, "request", ServiceList.YouTube.getServiceId());
|
||||||
|
// Obtain a Parcel object and write the parcelable object to it:
|
||||||
|
final Parcel parcel = Parcel.obtain();
|
||||||
|
info.writeToParcel(parcel, 0);
|
||||||
|
parcel.setDataPosition(0);
|
||||||
|
final ErrorInfo infoFromParcel = (ErrorInfo) ErrorInfo.CREATOR.createFromParcel(parcel);
|
||||||
|
|
||||||
|
assertTrue(Arrays.toString(infoFromParcel.getStackTraces())
|
||||||
|
.contains(ErrorInfoTest.class.getSimpleName()));
|
||||||
|
assertEquals(UserAction.USER_REPORT, infoFromParcel.getUserAction());
|
||||||
|
assertEquals(ServiceList.YouTube.getServiceInfo().getName(),
|
||||||
|
infoFromParcel.getServiceName());
|
||||||
|
assertEquals("request", infoFromParcel.getRequest());
|
||||||
|
assertEquals(R.string.parsing_error, getMessageFromErrorInfo(infoFromParcel));
|
||||||
|
|
||||||
|
parcel.recycle();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
package org.schabi.newpipe.error
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Parcel
|
|
||||||
import android.os.Parcelable
|
|
||||||
import androidx.test.core.app.ApplicationProvider
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import androidx.test.filters.LargeTest
|
|
||||||
import java.io.IOException
|
|
||||||
import java.net.SocketTimeoutException
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Assert.assertTrue
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.schabi.newpipe.R
|
|
||||||
import org.schabi.newpipe.extractor.ServiceList
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.ParsingException
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instrumented tests for {@link ErrorInfo}.
|
|
||||||
*/
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
@LargeTest
|
|
||||||
class ErrorInfoTest {
|
|
||||||
private val context: Context by lazy { ApplicationProvider.getApplicationContext() }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param errorInfo the error info to access
|
|
||||||
* @return the private field errorInfo.message.stringRes using reflection
|
|
||||||
*/
|
|
||||||
@Throws(NoSuchFieldException::class, IllegalAccessException::class)
|
|
||||||
private fun getMessageFromErrorInfo(errorInfo: ErrorInfo): Int {
|
|
||||||
val message = ErrorInfo::class.java.getDeclaredField("message")
|
|
||||||
message.isAccessible = true
|
|
||||||
val messageValue = message.get(errorInfo) as ErrorInfo.Companion.ErrorMessage
|
|
||||||
|
|
||||||
val stringRes = ErrorInfo.Companion.ErrorMessage::class.java.getDeclaredField("stringRes")
|
|
||||||
stringRes.isAccessible = true
|
|
||||||
return stringRes.get(messageValue) as Int
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@Throws(NoSuchFieldException::class, IllegalAccessException::class)
|
|
||||||
fun errorInfoTestParcelable() {
|
|
||||||
val info = ErrorInfo(
|
|
||||||
ParsingException("Hello"),
|
|
||||||
UserAction.USER_REPORT,
|
|
||||||
"request",
|
|
||||||
ServiceList.YouTube.serviceId
|
|
||||||
)
|
|
||||||
// Obtain a Parcel object and write the parcelable object to it:
|
|
||||||
val parcel = Parcel.obtain()
|
|
||||||
info.writeToParcel(parcel, 0)
|
|
||||||
parcel.setDataPosition(0)
|
|
||||||
val creatorField = ErrorInfo::class.java.getDeclaredField("CREATOR")
|
|
||||||
val creator = creatorField.get(null)
|
|
||||||
check(creator is Parcelable.Creator<*>)
|
|
||||||
val infoFromParcel = requireNotNull(
|
|
||||||
creator.createFromParcel(parcel) as? ErrorInfo
|
|
||||||
)
|
|
||||||
assertTrue(
|
|
||||||
infoFromParcel.stackTraces.contentToString()
|
|
||||||
.contains(ErrorInfoTest::class.java.simpleName)
|
|
||||||
)
|
|
||||||
assertEquals(UserAction.USER_REPORT, infoFromParcel.userAction)
|
|
||||||
assertEquals(
|
|
||||||
ServiceList.YouTube.serviceInfo.name,
|
|
||||||
infoFromParcel.getServiceName()
|
|
||||||
)
|
|
||||||
assertEquals("request", infoFromParcel.request)
|
|
||||||
assertEquals(R.string.parsing_error, getMessageFromErrorInfo(infoFromParcel))
|
|
||||||
|
|
||||||
parcel.recycle()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test: Network error on initial load (Resource.Error)
|
|
||||||
*/
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun testInitialCommentNetworkError() {
|
|
||||||
val errorInfo = ErrorInfo(
|
|
||||||
throwable = SocketTimeoutException("Connection timeout"),
|
|
||||||
userAction = UserAction.REQUESTED_COMMENTS,
|
|
||||||
request = "comments"
|
|
||||||
)
|
|
||||||
assertEquals(context.getString(R.string.network_error), errorInfo.getMessage(context))
|
|
||||||
assertTrue(errorInfo.isReportable)
|
|
||||||
assertTrue(errorInfo.isRetryable)
|
|
||||||
assertNull(errorInfo.recaptchaUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test: Network error on paging (LoadState.Error)
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun testPagingNetworkError() {
|
|
||||||
val errorInfo = ErrorInfo(
|
|
||||||
throwable = IOException("Paging failed"),
|
|
||||||
userAction = UserAction.REQUESTED_COMMENTS,
|
|
||||||
request = "comments"
|
|
||||||
)
|
|
||||||
assertEquals(context.getString(R.string.network_error), errorInfo.getMessage(context))
|
|
||||||
assertTrue(errorInfo.isReportable)
|
|
||||||
assertTrue(errorInfo.isRetryable)
|
|
||||||
assertNull(errorInfo.recaptchaUrl)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test: ReCaptcha during comments load
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun testReCaptchaDuringComments() {
|
|
||||||
val url = "https://www.google.com/recaptcha/api/fallback?k=test"
|
|
||||||
val errorInfo = ErrorInfo(
|
|
||||||
throwable = ReCaptchaException("ReCaptcha needed", url),
|
|
||||||
userAction = UserAction.REQUESTED_COMMENTS,
|
|
||||||
request = "comments"
|
|
||||||
)
|
|
||||||
assertEquals(context.getString(R.string.recaptcha_request_toast), errorInfo.getMessage(context))
|
|
||||||
assertEquals(url, errorInfo.recaptchaUrl)
|
|
||||||
assertTrue(errorInfo.isReportable)
|
|
||||||
assertTrue(errorInfo.isRetryable)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,126 +0,0 @@
|
|||||||
package org.schabi.newpipe.ui.components.common
|
|
||||||
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
|
||||||
import androidx.compose.ui.test.performClick
|
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
|
||||||
import java.net.UnknownHostException
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.schabi.newpipe.R
|
|
||||||
import org.schabi.newpipe.error.ErrorInfo
|
|
||||||
import org.schabi.newpipe.error.UserAction
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.UnsupportedContentInCountryException
|
|
||||||
import org.schabi.newpipe.ui.theme.AppTheme
|
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
|
||||||
class ErrorPanelTest {
|
|
||||||
@get:Rule
|
|
||||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
|
||||||
|
|
||||||
private fun setErrorPanel(errorInfo: ErrorInfo, onRetry: (() -> Unit)? = null) {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
ErrorPanel(errorInfo = errorInfo, onRetry = onRetry)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
private fun text(@StringRes id: Int) = composeRule.activity.getString(id)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test Network Error
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun testNetworkErrorShowsRetryWithoutReportButton() {
|
|
||||||
val networkErrorInfo = ErrorInfo(
|
|
||||||
throwable = UnknownHostException("offline"),
|
|
||||||
userAction = UserAction.REQUESTED_STREAM,
|
|
||||||
request = "https://example.com/watch?v=foo"
|
|
||||||
)
|
|
||||||
|
|
||||||
setErrorPanel(networkErrorInfo, onRetry = {})
|
|
||||||
composeRule.onNodeWithText(text(R.string.network_error)).assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true).assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true)
|
|
||||||
.assertDoesNotExist()
|
|
||||||
composeRule.onNodeWithText(text(R.string.recaptcha_solve), ignoreCase = true)
|
|
||||||
.assertDoesNotExist()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test Unexpected Error, Shows Report and Retry buttons
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun unexpectedErrorShowsReportAndRetryButtons() {
|
|
||||||
val unexpectedErrorInfo = ErrorInfo(
|
|
||||||
throwable = RuntimeException("Unexpected error"),
|
|
||||||
userAction = UserAction.REQUESTED_STREAM,
|
|
||||||
request = "https://example.com/watch?v=bar"
|
|
||||||
)
|
|
||||||
|
|
||||||
setErrorPanel(unexpectedErrorInfo, onRetry = {})
|
|
||||||
composeRule.onNodeWithText(text(R.string.error_snackbar_message)).assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true).assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true)
|
|
||||||
.assertIsDisplayed()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test Recaptcha Error shows all buttons: solve, retry, open in browser, report
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun recaptchaErrorShowsAllButtons() {
|
|
||||||
var retryClicked = false
|
|
||||||
val recaptchaErrorInfo = ErrorInfo(
|
|
||||||
throwable = ReCaptchaException(
|
|
||||||
"Recaptcha required",
|
|
||||||
"https://example.com/captcha"
|
|
||||||
),
|
|
||||||
userAction = UserAction.REQUESTED_STREAM,
|
|
||||||
request = "https://example.com/watch?v=baz",
|
|
||||||
openInBrowserUrl = "https://example.com/watch?v=baz"
|
|
||||||
)
|
|
||||||
|
|
||||||
setErrorPanel(
|
|
||||||
errorInfo = recaptchaErrorInfo,
|
|
||||||
onRetry = { retryClicked = true }
|
|
||||||
|
|
||||||
)
|
|
||||||
composeRule.onNodeWithText(text(R.string.recaptcha_solve), ignoreCase = true)
|
|
||||||
.assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true)
|
|
||||||
.assertIsDisplayed()
|
|
||||||
.performClick()
|
|
||||||
composeRule.onNodeWithText(text(R.string.open_in_browser), ignoreCase = true)
|
|
||||||
.assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true)
|
|
||||||
.assertIsDisplayed()
|
|
||||||
assert(retryClicked) { "onRetry callback should have been invoked" }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Test Content Not Available Error hides retry button
|
|
||||||
*/
|
|
||||||
@Test
|
|
||||||
fun testNonRetryableErrorHidesRetryAndReportButtons() {
|
|
||||||
val contentNotAvailable = ErrorInfo(
|
|
||||||
throwable = UnsupportedContentInCountryException("Not available here"),
|
|
||||||
userAction = UserAction.REQUESTED_STREAM,
|
|
||||||
request = "https://example.com/watch?v=qux"
|
|
||||||
)
|
|
||||||
|
|
||||||
setErrorPanel(contentNotAvailable)
|
|
||||||
|
|
||||||
composeRule.onNodeWithText(text(R.string.unsupported_content_in_country))
|
|
||||||
.assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithText(text(R.string.retry), ignoreCase = true)
|
|
||||||
.assertDoesNotExist()
|
|
||||||
composeRule.onNodeWithText(text(R.string.error_snackbar_action), ignoreCase = true)
|
|
||||||
.assertDoesNotExist()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,364 +0,0 @@
|
|||||||
package org.schabi.newpipe.ui.components.video.comment
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
|
||||||
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
|
|
||||||
import androidx.compose.ui.platform.testTag
|
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
|
||||||
import androidx.compose.ui.test.ExperimentalTestApi
|
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
|
||||||
import androidx.compose.ui.test.hasClickAction
|
|
||||||
import androidx.compose.ui.test.hasText
|
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
|
||||||
import androidx.compose.ui.test.performClick
|
|
||||||
import androidx.compose.ui.test.performScrollTo
|
|
||||||
import androidx.compose.ui.test.performScrollToNode
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.paging.LoadState
|
|
||||||
import androidx.paging.LoadStates
|
|
||||||
import androidx.paging.PagingData
|
|
||||||
import androidx.paging.compose.collectAsLazyPagingItems
|
|
||||||
import java.net.UnknownHostException
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Rule
|
|
||||||
import org.junit.Test
|
|
||||||
import org.schabi.newpipe.R
|
|
||||||
import org.schabi.newpipe.error.ErrorInfo
|
|
||||||
import org.schabi.newpipe.error.UserAction
|
|
||||||
import org.schabi.newpipe.extractor.Page
|
|
||||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
|
|
||||||
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
|
|
||||||
import org.schabi.newpipe.extractor.stream.Description
|
|
||||||
import org.schabi.newpipe.ui.components.common.ErrorPanel
|
|
||||||
import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar
|
|
||||||
import org.schabi.newpipe.ui.components.common.LoadingIndicator
|
|
||||||
import org.schabi.newpipe.ui.emptystate.EmptyStateComposable
|
|
||||||
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec
|
|
||||||
import org.schabi.newpipe.ui.theme.AppTheme
|
|
||||||
import org.schabi.newpipe.viewmodels.util.Resource
|
|
||||||
|
|
||||||
class CommentSectionInstrumentedTest {
|
|
||||||
|
|
||||||
@get:Rule
|
|
||||||
val composeRule = createAndroidComposeRule<androidx.activity.ComponentActivity>()
|
|
||||||
|
|
||||||
private val uiStateFlow = MutableStateFlow<Resource<CommentInfo>>(Resource.Loading)
|
|
||||||
private val pagingFlow = MutableStateFlow(PagingData.empty<CommentsInfoItem>())
|
|
||||||
private fun string(@StringRes resId: Int) = composeRule.activity.getString(resId)
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
composeRule.setContent {
|
|
||||||
AppTheme {
|
|
||||||
TestCommentSection(uiStateFlow = uiStateFlow, commentsFlow = pagingFlow)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun successState(commentCount: Int) = Resource.Success(
|
|
||||||
CommentInfo(
|
|
||||||
serviceId = 0,
|
|
||||||
url = "",
|
|
||||||
comments = emptyList(),
|
|
||||||
nextPage = null,
|
|
||||||
commentCount = commentCount,
|
|
||||||
isCommentsDisabled = false
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun commentListLoadsAndScrolls() {
|
|
||||||
val comments = (1..25).map { index ->
|
|
||||||
CommentsInfoItem(
|
|
||||||
commentText = Description("Comment $index", Description.PLAIN_TEXT),
|
|
||||||
uploaderName = "Uploader $index",
|
|
||||||
replies = Page(""),
|
|
||||||
replyCount = 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
uiStateFlow.value = successState(comments.size)
|
|
||||||
pagingFlow.value = PagingData.from(comments)
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
composeRule.onNodeWithText("Comment 1").assertIsDisplayed()
|
|
||||||
composeRule.onNodeWithTag("comment_list")
|
|
||||||
.performScrollToNode(hasText("Comment 25"))
|
|
||||||
composeRule.onNodeWithText("Comment 25").assertIsDisplayed()
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalTestApi::class)
|
|
||||||
@Test
|
|
||||||
fun pagingErrorShowsErrorPanelAndAllowsRetry() {
|
|
||||||
uiStateFlow.value = successState(10)
|
|
||||||
pagingFlow.value = PagingData.from(
|
|
||||||
data = emptyList(),
|
|
||||||
sourceLoadStates = LoadStates(
|
|
||||||
refresh = LoadState.Error(ReCaptchaException("captcha required", "https://example.com")),
|
|
||||||
prepend = LoadState.NotLoading(true),
|
|
||||||
append = LoadState.NotLoading(true)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
val solveMatcher = hasText(string(R.string.recaptcha_solve), ignoreCase = true)
|
|
||||||
.and(hasClickAction())
|
|
||||||
val retryMatcher = hasText(string(R.string.retry), ignoreCase = true)
|
|
||||||
.and(hasClickAction())
|
|
||||||
|
|
||||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
|
||||||
composeRule.onAllNodes(solveMatcher).fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
|
||||||
composeRule.onAllNodes(retryMatcher).fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNode(retryMatcher)
|
|
||||||
.performScrollTo()
|
|
||||||
.performClick()
|
|
||||||
|
|
||||||
val recoveredComment = CommentsInfoItem(
|
|
||||||
commentText = Description("Recovered comment", Description.PLAIN_TEXT),
|
|
||||||
uploaderName = "Uploader",
|
|
||||||
replies = Page(""),
|
|
||||||
replyCount = 0
|
|
||||||
)
|
|
||||||
|
|
||||||
uiStateFlow.value = successState(1)
|
|
||||||
pagingFlow.value = PagingData.from(
|
|
||||||
data = listOf(recoveredComment),
|
|
||||||
sourceLoadStates = LoadStates(
|
|
||||||
refresh = LoadState.NotLoading(false),
|
|
||||||
prepend = LoadState.NotLoading(true),
|
|
||||||
append = LoadState.NotLoading(true)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
|
||||||
composeRule.onAllNodes(hasText("Recovered comment"))
|
|
||||||
.fetchSemanticsNodes()
|
|
||||||
.isNotEmpty()
|
|
||||||
}
|
|
||||||
composeRule.onNodeWithText("Recovered comment").assertIsDisplayed()
|
|
||||||
|
|
||||||
composeRule.onNode(solveMatcher).assertDoesNotExist()
|
|
||||||
composeRule.onNode(retryMatcher).assertDoesNotExist()
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalTestApi::class)
|
|
||||||
@Test
|
|
||||||
fun resourceErrorShowsErrorPanelAndRetry() {
|
|
||||||
uiStateFlow.value = Resource.Error(UnknownHostException("offline"))
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.onNodeWithText(string(R.string.network_error)).assertIsDisplayed()
|
|
||||||
val retryMatcher = hasText(string(R.string.retry), ignoreCase = true)
|
|
||||||
.and(hasClickAction())
|
|
||||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
|
||||||
composeRule.onAllNodes(retryMatcher).fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
|
|
||||||
composeRule.onNode(retryMatcher)
|
|
||||||
.performScrollTo()
|
|
||||||
.performClick()
|
|
||||||
|
|
||||||
val recoveredComment = CommentsInfoItem(
|
|
||||||
commentText = Description("Recovered comment", Description.PLAIN_TEXT),
|
|
||||||
uploaderName = "Uploader",
|
|
||||||
replies = Page(""),
|
|
||||||
replyCount = 0
|
|
||||||
)
|
|
||||||
|
|
||||||
uiStateFlow.value = successState(1)
|
|
||||||
pagingFlow.value = PagingData.from(
|
|
||||||
data = listOf(recoveredComment),
|
|
||||||
sourceLoadStates = LoadStates(
|
|
||||||
refresh = LoadState.NotLoading(false),
|
|
||||||
prepend = LoadState.NotLoading(true),
|
|
||||||
append = LoadState.NotLoading(true)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
|
||||||
composeRule.onAllNodes(hasText("Recovered comment"))
|
|
||||||
.fetchSemanticsNodes()
|
|
||||||
.isNotEmpty()
|
|
||||||
}
|
|
||||||
composeRule.onNodeWithText("Recovered comment").assertIsDisplayed()
|
|
||||||
|
|
||||||
composeRule.onNodeWithText(string(R.string.network_error))
|
|
||||||
.assertDoesNotExist()
|
|
||||||
composeRule.onNode(retryMatcher).assertDoesNotExist()
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalTestApi::class)
|
|
||||||
@Test
|
|
||||||
fun retryAfterErrorRecoversList() {
|
|
||||||
uiStateFlow.value = Resource.Error(RuntimeException("boom"))
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
|
|
||||||
val retryMatcher = hasText(string(R.string.retry), ignoreCase = true)
|
|
||||||
.and(hasClickAction())
|
|
||||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
|
||||||
composeRule.onAllNodes(retryMatcher).fetchSemanticsNodes().isNotEmpty()
|
|
||||||
}
|
|
||||||
composeRule.onNode(retryMatcher)
|
|
||||||
.performScrollTo()
|
|
||||||
.performClick()
|
|
||||||
|
|
||||||
val firstComment = CommentsInfoItem(
|
|
||||||
commentText = Description("First comment", Description.PLAIN_TEXT),
|
|
||||||
uploaderName = "Uploader",
|
|
||||||
replies = Page(""),
|
|
||||||
replyCount = 0
|
|
||||||
)
|
|
||||||
|
|
||||||
uiStateFlow.value = successState(1)
|
|
||||||
pagingFlow.value = PagingData.from(
|
|
||||||
data = listOf(firstComment),
|
|
||||||
sourceLoadStates = LoadStates(
|
|
||||||
refresh = LoadState.NotLoading(false),
|
|
||||||
prepend = LoadState.NotLoading(true),
|
|
||||||
append = LoadState.NotLoading(true)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
composeRule.waitForIdle()
|
|
||||||
composeRule.waitUntil(timeoutMillis = 5_000) {
|
|
||||||
composeRule.onAllNodes(hasText("First comment"))
|
|
||||||
.fetchSemanticsNodes()
|
|
||||||
.isNotEmpty()
|
|
||||||
}
|
|
||||||
composeRule.onNodeWithText("First comment").assertIsDisplayed()
|
|
||||||
|
|
||||||
composeRule.onNodeWithText(string(R.string.network_error))
|
|
||||||
.assertDoesNotExist()
|
|
||||||
composeRule.onNode(retryMatcher).assertDoesNotExist()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TestCommentSection(
|
|
||||||
uiStateFlow: StateFlow<Resource<CommentInfo>>,
|
|
||||||
commentsFlow: Flow<PagingData<CommentsInfoItem>>
|
|
||||||
) {
|
|
||||||
val uiState by uiStateFlow.collectAsState()
|
|
||||||
val comments = commentsFlow.collectAsLazyPagingItems()
|
|
||||||
val nestedScrollInterop = rememberNestedScrollInteropConnection()
|
|
||||||
val listState = rememberLazyListState()
|
|
||||||
val COMMENT_LIST_TAG = "comment_list"
|
|
||||||
|
|
||||||
LazyColumnThemedScrollbar(state = listState) {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier
|
|
||||||
.testTag(COMMENT_LIST_TAG)
|
|
||||||
.nestedScroll(nestedScrollInterop),
|
|
||||||
state = listState
|
|
||||||
) {
|
|
||||||
when (uiState) {
|
|
||||||
is Resource.Loading -> item {
|
|
||||||
LoadingIndicator(modifier = Modifier.padding(top = 8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
is Resource.Success -> {
|
|
||||||
val commentInfo = (uiState as Resource.Success<CommentInfo>).data
|
|
||||||
val count = commentInfo.commentCount
|
|
||||||
|
|
||||||
when {
|
|
||||||
commentInfo.isCommentsDisabled -> item {
|
|
||||||
EmptyStateComposable(
|
|
||||||
spec = EmptyStateSpec.DisabledComments,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(min = 128.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
count == 0 -> item {
|
|
||||||
EmptyStateComposable(
|
|
||||||
spec = EmptyStateSpec.NoComments,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(min = 128.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
if (count >= 0) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(start = 12.dp, end = 12.dp, bottom = 4.dp),
|
|
||||||
text = pluralStringResource(R.plurals.comments, count, count),
|
|
||||||
maxLines = 1,
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
when (val refresh = comments.loadState.refresh) {
|
|
||||||
is LoadState.Loading -> item {
|
|
||||||
LoadingIndicator(modifier = Modifier.padding(top = 8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
is LoadState.Error -> item {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
ErrorPanel(
|
|
||||||
errorInfo = ErrorInfo(
|
|
||||||
throwable = refresh.error,
|
|
||||||
userAction = UserAction.REQUESTED_COMMENTS,
|
|
||||||
request = "comments"
|
|
||||||
),
|
|
||||||
onRetry = { comments.retry() },
|
|
||||||
modifier = Modifier.align(Alignment.Center)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> items(comments.itemCount) { index ->
|
|
||||||
Comment(comment = comments[index]!!) {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
is Resource.Error -> item {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
ErrorPanel(
|
|
||||||
errorInfo = ErrorInfo(
|
|
||||||
throwable = (uiState as Resource.Error).throwable,
|
|
||||||
userAction = UserAction.REQUESTED_COMMENTS,
|
|
||||||
request = "comments"
|
|
||||||
),
|
|
||||||
onRetry = { comments.retry() },
|
|
||||||
modifier = Modifier.align(Alignment.Center)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -91,25 +91,20 @@
|
|||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:label="@string/settings" />
|
android:label="@string/settings" />
|
||||||
|
|
||||||
<activity
|
|
||||||
android:name=".settings.SettingsV2Activity"
|
|
||||||
android:exported="true"
|
|
||||||
android:label="@string/settings" />
|
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".about.AboutActivity"
|
android:name=".about.AboutActivity"
|
||||||
android:exported="false"
|
android:exported="false"
|
||||||
android:label="@string/title_activity_about" />
|
android:label="@string/title_activity_about" />
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".local.feed.service.FeedLoadService"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<service
|
<service
|
||||||
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
android:name="androidx.work.impl.foreground.SystemForegroundService"
|
||||||
android:foregroundServiceType="dataSync"
|
android:foregroundServiceType="dataSync"
|
||||||
tools:node="merge" />
|
tools:node="merge" />
|
||||||
|
|
||||||
<service
|
|
||||||
android:name=".local.feed.service.FeedLoadService"
|
|
||||||
android:foregroundServiceType="dataSync" />
|
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
android:name=".PanicResponderActivity"
|
android:name=".PanicResponderActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -140,8 +135,7 @@
|
|||||||
android:label="@string/app_name"
|
android:label="@string/app_name"
|
||||||
android:launchMode="singleTask" />
|
android:launchMode="singleTask" />
|
||||||
|
|
||||||
<service
|
<service android:name="us.shandian.giga.service.DownloadManagerService"
|
||||||
android:name="us.shandian.giga.service.DownloadManagerService"
|
|
||||||
android:foregroundServiceType="dataSync" />
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import coil3.request.allowRgb565
|
|||||||
import coil3.request.crossfade
|
import coil3.request.crossfade
|
||||||
import coil3.util.DebugLogger
|
import coil3.util.DebugLogger
|
||||||
import com.jakewharton.processphoenix.ProcessPhoenix
|
import com.jakewharton.processphoenix.ProcessPhoenix
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
|
||||||
import io.reactivex.rxjava3.exceptions.CompositeException
|
import io.reactivex.rxjava3.exceptions.CompositeException
|
||||||
import io.reactivex.rxjava3.exceptions.MissingBackpressureException
|
import io.reactivex.rxjava3.exceptions.MissingBackpressureException
|
||||||
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException
|
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException
|
||||||
@@ -59,7 +58,6 @@ import org.schabi.newpipe.util.potoken.PoTokenProviderImpl
|
|||||||
* You should have received a copy of the GNU General Public License
|
* You should have received a copy of the GNU General Public License
|
||||||
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
@HiltAndroidApp
|
|
||||||
open class App :
|
open class App :
|
||||||
Application(),
|
Application(),
|
||||||
SingletonImageLoader.Factory {
|
SingletonImageLoader.Factory {
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
package org.schabi.newpipe
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.preference.PreferenceManager
|
|
||||||
import dagger.Module
|
|
||||||
import dagger.Provides
|
|
||||||
import dagger.hilt.InstallIn
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Module
|
|
||||||
@InstallIn(SingletonComponent::class)
|
|
||||||
class AppModule {
|
|
||||||
|
|
||||||
@Provides
|
|
||||||
@Singleton
|
|
||||||
fun providesSharedPreference(@ApplicationContext context: Context): SharedPreferences {
|
|
||||||
return PreferenceManager.getDefaultSharedPreferences(context)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
|
|
||||||
package org.schabi.newpipe;
|
package org.schabi.newpipe;
|
||||||
|
|
||||||
|
import android.app.AlertDialog;
|
||||||
import android.content.BroadcastReceiver;
|
import android.content.BroadcastReceiver;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
@@ -43,6 +44,7 @@ import android.widget.FrameLayout;
|
|||||||
import android.widget.Spinner;
|
import android.widget.Spinner;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
import androidx.appcompat.app.ActionBar;
|
import androidx.appcompat.app.ActionBar;
|
||||||
import androidx.appcompat.app.ActionBarDrawerToggle;
|
import androidx.appcompat.app.ActionBarDrawerToggle;
|
||||||
import androidx.appcompat.app.AppCompatActivity;
|
import androidx.appcompat.app.AppCompatActivity;
|
||||||
@@ -51,6 +53,7 @@ import androidx.core.content.ContextCompat;
|
|||||||
import androidx.core.view.GravityCompat;
|
import androidx.core.view.GravityCompat;
|
||||||
import androidx.drawerlayout.widget.DrawerLayout;
|
import androidx.drawerlayout.widget.DrawerLayout;
|
||||||
import androidx.fragment.app.Fragment;
|
import androidx.fragment.app.Fragment;
|
||||||
|
import androidx.fragment.app.FragmentContainerView;
|
||||||
import androidx.fragment.app.FragmentManager;
|
import androidx.fragment.app.FragmentManager;
|
||||||
import androidx.preference.PreferenceManager;
|
import androidx.preference.PreferenceManager;
|
||||||
|
|
||||||
@@ -64,11 +67,13 @@ import org.schabi.newpipe.databinding.ToolbarLayoutBinding;
|
|||||||
import org.schabi.newpipe.error.ErrorUtil;
|
import org.schabi.newpipe.error.ErrorUtil;
|
||||||
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.comments.CommentsInfoItem;
|
||||||
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
|
||||||
import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance;
|
import org.schabi.newpipe.extractor.services.peertube.PeertubeInstance;
|
||||||
import org.schabi.newpipe.fragments.BackPressable;
|
import org.schabi.newpipe.fragments.BackPressable;
|
||||||
import org.schabi.newpipe.fragments.MainFragment;
|
import org.schabi.newpipe.fragments.MainFragment;
|
||||||
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
|
import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
|
||||||
|
import org.schabi.newpipe.fragments.list.comments.CommentRepliesFragment;
|
||||||
import org.schabi.newpipe.fragments.list.search.SearchFragment;
|
import org.schabi.newpipe.fragments.list.search.SearchFragment;
|
||||||
import org.schabi.newpipe.local.feed.notifications.NotificationWorker;
|
import org.schabi.newpipe.local.feed.notifications.NotificationWorker;
|
||||||
import org.schabi.newpipe.player.Player;
|
import org.schabi.newpipe.player.Player;
|
||||||
@@ -92,6 +97,8 @@ import org.schabi.newpipe.util.ThemeHelper;
|
|||||||
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||||
import org.schabi.newpipe.views.FocusOverlayView;
|
import org.schabi.newpipe.views.FocusOverlayView;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
@@ -192,6 +199,12 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
UpdateSettingsFragment.askForConsentToUpdateChecks(this);
|
UpdateSettingsFragment.askForConsentToUpdateChecks(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReleaseVersionUtil.INSTANCE.isReleaseApk() will be true only for main official build
|
||||||
|
// We want every release build (nightly, nightly-refactor) to show the popup
|
||||||
|
if (!DEBUG) {
|
||||||
|
showKeepAndroidDialog();
|
||||||
|
}
|
||||||
|
|
||||||
MigrationManager.showUserInfoIfPresent(this);
|
MigrationManager.showUserInfoIfPresent(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -595,27 +608,39 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
// In case bottomSheet is not visible on the screen or collapsed we can assume that the user
|
// In case bottomSheet is not visible on the screen or collapsed we can assume that the user
|
||||||
// interacts with a fragment inside fragment_holder so all back presses should be
|
// interacts with a fragment inside fragment_holder so all back presses should be
|
||||||
// handled by it
|
// handled by it
|
||||||
final var fragmentManager = getSupportFragmentManager();
|
|
||||||
|
|
||||||
if (bottomSheetHiddenOrCollapsed()) {
|
if (bottomSheetHiddenOrCollapsed()) {
|
||||||
final var fragment = fragmentManager.findFragmentById(R.id.fragment_holder);
|
final FragmentManager fm = getSupportFragmentManager();
|
||||||
|
final Fragment fragment = fm.findFragmentById(R.id.fragment_holder);
|
||||||
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
|
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
|
||||||
// delegate the back press to it
|
// delegate the back press to it
|
||||||
if (fragment instanceof BackPressable backPressable && backPressable.onBackPressed()) {
|
if (fragment instanceof BackPressable) {
|
||||||
|
if (((BackPressable) fragment).onBackPressed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (fragment instanceof CommentRepliesFragment) {
|
||||||
|
// expand DetailsFragment if CommentRepliesFragment was opened
|
||||||
|
// to show the top level comments again
|
||||||
|
// Expand DetailsFragment if CommentRepliesFragment was opened
|
||||||
|
// and no other CommentRepliesFragments are on top of the back stack
|
||||||
|
// to show the top level comments again.
|
||||||
|
openDetailFragmentFromCommentReplies(fm, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
final Fragment fragmentPlayer = getSupportFragmentManager()
|
||||||
|
.findFragmentById(R.id.fragment_player_holder);
|
||||||
|
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
|
||||||
|
// delegate the back press to it
|
||||||
|
if (fragmentPlayer instanceof BackPressable) {
|
||||||
|
if (!((BackPressable) fragmentPlayer).onBackPressed()) {
|
||||||
|
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder)
|
||||||
|
.setState(BottomSheetBehavior.STATE_COLLAPSED);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
final var player = fragmentManager.findFragmentById(R.id.fragment_player_holder);
|
|
||||||
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
|
|
||||||
// delegate the back press to it
|
|
||||||
if (player instanceof BackPressable backPressable && !backPressable.onBackPressed()) {
|
|
||||||
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder)
|
|
||||||
.setState(BottomSheetBehavior.STATE_COLLAPSED);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (fragmentManager.getBackStackEntryCount() == 1) {
|
if (getSupportFragmentManager().getBackStackEntryCount() == 1) {
|
||||||
finish();
|
finish();
|
||||||
} else {
|
} else {
|
||||||
super.onBackPressed();
|
super.onBackPressed();
|
||||||
@@ -674,9 +699,15 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
* </pre>
|
* </pre>
|
||||||
*/
|
*/
|
||||||
private void onHomeButtonPressed() {
|
private void onHomeButtonPressed() {
|
||||||
final var fm = getSupportFragmentManager();
|
final FragmentManager fm = getSupportFragmentManager();
|
||||||
|
final Fragment fragment = fm.findFragmentById(R.id.fragment_holder);
|
||||||
|
|
||||||
if (!NavigationHelper.tryGotoSearchFragment(fm)) {
|
if (fragment instanceof CommentRepliesFragment) {
|
||||||
|
// Expand DetailsFragment if CommentRepliesFragment was opened
|
||||||
|
// and no other CommentRepliesFragments are on top of the back stack
|
||||||
|
// to show the top level comments again.
|
||||||
|
openDetailFragmentFromCommentReplies(fm, true);
|
||||||
|
} else if (!NavigationHelper.tryGotoSearchFragment(fm)) {
|
||||||
// If search fragment wasn't found in the backstack go to the main fragment
|
// If search fragment wasn't found in the backstack go to the main fragment
|
||||||
NavigationHelper.gotoMainFragment(fm);
|
NavigationHelper.gotoMainFragment(fm);
|
||||||
}
|
}
|
||||||
@@ -850,7 +881,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (PlayerHolder.INSTANCE.isPlayerOpen()) {
|
if (PlayerHolder.getInstance().isPlayerOpen()) {
|
||||||
// if the player is already open, no need for a broadcast receiver
|
// if the player is already open, no need for a broadcast receiver
|
||||||
openMiniPlayerIfMissing();
|
openMiniPlayerIfMissing();
|
||||||
} else {
|
} else {
|
||||||
@@ -860,7 +891,7 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
public void onReceive(final Context context, final Intent intent) {
|
public void onReceive(final Context context, final Intent intent) {
|
||||||
if (Objects.equals(intent.getAction(),
|
if (Objects.equals(intent.getAction(),
|
||||||
VideoDetailFragment.ACTION_PLAYER_STARTED)
|
VideoDetailFragment.ACTION_PLAYER_STARTED)
|
||||||
&& PlayerHolder.INSTANCE.isPlayerOpen()) {
|
&& PlayerHolder.getInstance().isPlayerOpen()) {
|
||||||
openMiniPlayerIfMissing();
|
openMiniPlayerIfMissing();
|
||||||
// At this point the player is added 100%, we can unregister. Other actions
|
// At this point the player is added 100%, we can unregister. Other actions
|
||||||
// are useless since the fragment will not be removed after that.
|
// are useless since the fragment will not be removed after that.
|
||||||
@@ -876,10 +907,72 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
|
|
||||||
// If the PlayerHolder is not bound yet, but the service is running, try to bind to it.
|
// If the PlayerHolder is not bound yet, but the service is running, try to bind to it.
|
||||||
// Once the connection is established, the ACTION_PLAYER_STARTED will be sent.
|
// Once the connection is established, the ACTION_PLAYER_STARTED will be sent.
|
||||||
PlayerHolder.INSTANCE.tryBindIfNeeded(this);
|
PlayerHolder.getInstance().tryBindIfNeeded(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void openDetailFragmentFromCommentReplies(
|
||||||
|
@NonNull final FragmentManager fm,
|
||||||
|
final boolean popBackStack
|
||||||
|
) {
|
||||||
|
// obtain the name of the fragment under the replies fragment that's going to be popped
|
||||||
|
@Nullable final String fragmentUnderEntryName;
|
||||||
|
if (fm.getBackStackEntryCount() < 2) {
|
||||||
|
fragmentUnderEntryName = null;
|
||||||
|
} else {
|
||||||
|
fragmentUnderEntryName = fm.getBackStackEntryAt(fm.getBackStackEntryCount() - 2)
|
||||||
|
.getName();
|
||||||
|
}
|
||||||
|
|
||||||
|
// the root comment is the comment for which the user opened the replies page
|
||||||
|
@Nullable final CommentRepliesFragment repliesFragment =
|
||||||
|
(CommentRepliesFragment) fm.findFragmentByTag(CommentRepliesFragment.TAG);
|
||||||
|
@Nullable final CommentsInfoItem rootComment =
|
||||||
|
repliesFragment == null ? null : repliesFragment.getCommentsInfoItem();
|
||||||
|
|
||||||
|
// sometimes this function pops the backstack, other times it's handled by the system
|
||||||
|
if (popBackStack) {
|
||||||
|
fm.popBackStackImmediate();
|
||||||
|
}
|
||||||
|
|
||||||
|
// only expand the bottom sheet back if there are no more nested comment replies fragments
|
||||||
|
// stacked under the one that is currently being popped
|
||||||
|
if (CommentRepliesFragment.TAG.equals(fragmentUnderEntryName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final BottomSheetBehavior<FragmentContainerView> behavior = BottomSheetBehavior
|
||||||
|
.from(mainBinding.fragmentPlayerHolder);
|
||||||
|
// do not return to the comment if the details fragment was closed
|
||||||
|
if (behavior.getState() == BottomSheetBehavior.STATE_HIDDEN) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// scroll to the root comment once the bottom sheet expansion animation is finished
|
||||||
|
behavior.addBottomSheetCallback(new BottomSheetBehavior.BottomSheetCallback() {
|
||||||
|
@Override
|
||||||
|
public void onStateChanged(@NonNull final View bottomSheet,
|
||||||
|
final int newState) {
|
||||||
|
if (newState == BottomSheetBehavior.STATE_EXPANDED) {
|
||||||
|
final Fragment detailFragment = fm.findFragmentById(
|
||||||
|
R.id.fragment_player_holder);
|
||||||
|
if (detailFragment instanceof VideoDetailFragment && rootComment != null) {
|
||||||
|
// should always be the case
|
||||||
|
((VideoDetailFragment) detailFragment).scrollToComment(rootComment);
|
||||||
|
}
|
||||||
|
behavior.removeBottomSheetCallback(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSlide(@NonNull final View bottomSheet, final float slideOffset) {
|
||||||
|
// not needed, listener is removed once the sheet is expanded
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
behavior.setState(BottomSheetBehavior.STATE_EXPANDED);
|
||||||
|
}
|
||||||
|
|
||||||
private boolean bottomSheetHiddenOrCollapsed() {
|
private boolean bottomSheetHiddenOrCollapsed() {
|
||||||
final BottomSheetBehavior<FrameLayout> bottomSheetBehavior =
|
final BottomSheetBehavior<FrameLayout> bottomSheetBehavior =
|
||||||
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder);
|
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder);
|
||||||
@@ -889,4 +982,57 @@ public class MainActivity extends AppCompatActivity {
|
|||||||
|| sheetState == BottomSheetBehavior.STATE_COLLAPSED;
|
|| sheetState == BottomSheetBehavior.STATE_COLLAPSED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void showKeepAndroidDialog() {
|
||||||
|
final var prefs = PreferenceManager.getDefaultSharedPreferences(this);
|
||||||
|
|
||||||
|
final var now = Instant.now();
|
||||||
|
final var kaoLastCheck = Instant.ofEpochMilli(prefs.getLong(
|
||||||
|
getString(R.string.kao_last_checked_key),
|
||||||
|
0
|
||||||
|
));
|
||||||
|
|
||||||
|
final var supportedLannguages = List.of("fr", "de", "ca", "es", "id", "it", "pl",
|
||||||
|
"pt", "cs", "sk", "fa", "ar", "tr", "el", "th", "ru", "uk", "ko", "zh", "ja");
|
||||||
|
final var locale = Localization.getAppLocale();
|
||||||
|
final String kaoBaseUrl = "https://keepandroidopen.org/";
|
||||||
|
final String kaoURI;
|
||||||
|
if (supportedLannguages.contains(locale.getLanguage())) {
|
||||||
|
if ("zh".equals(locale.getLanguage())) {
|
||||||
|
kaoURI = kaoBaseUrl + ("TW".equals(locale.getCountry()) ? "zh-TW" : "zh-CN");
|
||||||
|
} else {
|
||||||
|
kaoURI = kaoBaseUrl + locale.getLanguage();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
kaoURI = kaoBaseUrl;
|
||||||
|
}
|
||||||
|
final var solutionURI =
|
||||||
|
"https://github.com/woheller69/FreeDroidWarn?tab=readme-ov-file#solutions";
|
||||||
|
|
||||||
|
if (kaoLastCheck.plus(30, ChronoUnit.DAYS).isBefore(now)) {
|
||||||
|
final var dialog = new AlertDialog.Builder(this)
|
||||||
|
.setTitle("Keep Android Open")
|
||||||
|
.setCancelable(false)
|
||||||
|
.setMessage(this.getString(R.string.kao_dialog_warning))
|
||||||
|
.setPositiveButton(this.getString(android.R.string.ok), (d, w) -> {
|
||||||
|
prefs.edit()
|
||||||
|
.putLong(
|
||||||
|
getString(R.string.kao_last_checked_key),
|
||||||
|
now.toEpochMilli()
|
||||||
|
)
|
||||||
|
.apply();
|
||||||
|
})
|
||||||
|
.setNeutralButton(this.getString(R.string.kao_solution), null)
|
||||||
|
.setNegativeButton(this.getString(R.string.kao_dialog_more_info), null)
|
||||||
|
.show();
|
||||||
|
|
||||||
|
// If we use setNeutralButton and etc. dialog will close after pressing the buttons,
|
||||||
|
// but we want it to close only when positive button is pressed
|
||||||
|
dialog.getButton(AlertDialog.BUTTON_NEGATIVE).setOnClickListener(v ->
|
||||||
|
ShareUtils.openUrlInBrowser(this, kaoURI)
|
||||||
|
);
|
||||||
|
dialog.getButton(AlertDialog.BUTTON_NEUTRAL).setOnClickListener(v ->
|
||||||
|
ShareUtils.openUrlInBrowser(this, solutionURI)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,9 +360,15 @@ public class RouterActivity extends AppCompatActivity {
|
|||||||
// Default / Ask always
|
// Default / Ask always
|
||||||
final List<AdapterChoiceItem> availableChoices = choiceChecker.getAvailableChoices();
|
final List<AdapterChoiceItem> availableChoices = choiceChecker.getAvailableChoices();
|
||||||
switch (availableChoices.size()) {
|
switch (availableChoices.size()) {
|
||||||
case 1 -> handleChoice(availableChoices.get(0).key);
|
case 1:
|
||||||
case 0 -> handleChoice(getString(R.string.show_info_key));
|
handleChoice(availableChoices.get(0).key);
|
||||||
default -> showDialog(availableChoices);
|
break;
|
||||||
|
case 0:
|
||||||
|
handleChoice(getString(R.string.show_info_key));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
showDialog(availableChoices);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -535,7 +541,7 @@ public class RouterActivity extends AppCompatActivity {
|
|||||||
// Enqueue is only shown if the current queue is not empty.
|
// Enqueue is only shown if the current queue is not empty.
|
||||||
// However, if the playqueue or the player is cleared after this item was chosen and
|
// However, if the playqueue or the player is cleared after this item was chosen and
|
||||||
// while the item is extracted, it will automatically fall back to background player.
|
// while the item is extracted, it will automatically fall back to background player.
|
||||||
if (PlayerHolder.INSTANCE.getQueueSize() > 0) {
|
if (PlayerHolder.getInstance().getQueueSize() > 0) {
|
||||||
returnedItems.add(new AdapterChoiceItem(getString(R.string.enqueue_key),
|
returnedItems.add(new AdapterChoiceItem(getString(R.string.enqueue_key),
|
||||||
getString(R.string.enqueue_stream), R.drawable.ic_add));
|
getString(R.string.enqueue_stream), R.drawable.ic_add));
|
||||||
}
|
}
|
||||||
@@ -678,7 +684,7 @@ public class RouterActivity extends AppCompatActivity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ...the player is not running or in normal Video-mode/type
|
// ...the player is not running or in normal Video-mode/type
|
||||||
final PlayerType playerType = PlayerHolder.INSTANCE.getType();
|
final PlayerType playerType = PlayerHolder.getInstance().getType();
|
||||||
return playerType == null || playerType == PlayerType.MAIN;
|
return playerType == null || playerType == PlayerType.MAIN;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,29 +1,260 @@
|
|||||||
package org.schabi.newpipe.about
|
package org.schabi.newpipe.about
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.compose.setContent
|
import android.view.LayoutInflater
|
||||||
import androidx.activity.enableEdgeToEdge
|
import android.view.MenuItem
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.Button
|
||||||
|
import androidx.annotation.StringRes
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
import androidx.appcompat.app.AppCompatActivity
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.fragment.app.Fragment
|
||||||
|
import androidx.fragment.app.FragmentActivity
|
||||||
|
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||||
|
import com.google.android.material.tabs.TabLayoutMediator
|
||||||
|
import org.schabi.newpipe.BuildConfig
|
||||||
import org.schabi.newpipe.R
|
import org.schabi.newpipe.R
|
||||||
import org.schabi.newpipe.ui.components.common.ScaffoldWithToolbar
|
import org.schabi.newpipe.databinding.ActivityAboutBinding
|
||||||
import org.schabi.newpipe.ui.screens.AboutScreen
|
import org.schabi.newpipe.databinding.FragmentAboutBinding
|
||||||
import org.schabi.newpipe.ui.theme.AppTheme
|
import org.schabi.newpipe.util.ThemeHelper
|
||||||
|
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||||
|
|
||||||
class AboutActivity : AppCompatActivity() {
|
class AboutActivity : AppCompatActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
enableEdgeToEdge()
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
setContent {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
AppTheme {
|
super.onCreate(savedInstanceState)
|
||||||
ScaffoldWithToolbar(
|
ThemeHelper.setTheme(this)
|
||||||
title = stringResource(R.string.title_activity_about),
|
title = getString(R.string.title_activity_about)
|
||||||
onBackClick = { onBackPressedDispatcher.onBackPressed() }
|
|
||||||
) { padding ->
|
val aboutBinding = ActivityAboutBinding.inflate(layoutInflater)
|
||||||
AboutScreen(padding)
|
setContentView(aboutBinding.root)
|
||||||
}
|
setSupportActionBar(aboutBinding.aboutToolbar)
|
||||||
|
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||||
|
|
||||||
|
// Create the adapter that will return a fragment for each of the three
|
||||||
|
// primary sections of the activity.
|
||||||
|
val mAboutStateAdapter = AboutStateAdapter(this)
|
||||||
|
// Set up the ViewPager with the sections adapter.
|
||||||
|
aboutBinding.aboutViewPager2.adapter = mAboutStateAdapter
|
||||||
|
TabLayoutMediator(
|
||||||
|
aboutBinding.aboutTabLayout,
|
||||||
|
aboutBinding.aboutViewPager2
|
||||||
|
) { tab, position ->
|
||||||
|
tab.setText(mAboutStateAdapter.getPageTitle(position))
|
||||||
|
}.attach()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
|
if (item.itemId == android.R.id.home) {
|
||||||
|
finish()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return super.onOptionsItemSelected(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A placeholder fragment containing a simple view.
|
||||||
|
*/
|
||||||
|
class AboutFragment : Fragment() {
|
||||||
|
private fun Button.openLink(@StringRes url: Int) {
|
||||||
|
setOnClickListener {
|
||||||
|
ShareUtils.openUrlInApp(context, requireContext().getString(url))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
FragmentAboutBinding.inflate(inflater, container, false).apply {
|
||||||
|
aboutAppVersion.text = BuildConfig.VERSION_NAME
|
||||||
|
aboutGithubLink.openLink(R.string.github_url)
|
||||||
|
aboutDonationLink.openLink(R.string.donation_url)
|
||||||
|
aboutWebsiteLink.openLink(R.string.website_url)
|
||||||
|
aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url)
|
||||||
|
faqLink.openLink(R.string.faq_url)
|
||||||
|
return root
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [FragmentStateAdapter] that returns a fragment corresponding to
|
||||||
|
* one of the sections/tabs/pages.
|
||||||
|
*/
|
||||||
|
private class AboutStateAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
|
||||||
|
private val posAbout = 0
|
||||||
|
private val posLicense = 1
|
||||||
|
private val totalCount = 2
|
||||||
|
|
||||||
|
override fun createFragment(position: Int): Fragment {
|
||||||
|
return when (position) {
|
||||||
|
posAbout -> AboutFragment()
|
||||||
|
posLicense -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS)
|
||||||
|
else -> error("Unknown position for ViewPager2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemCount(): Int {
|
||||||
|
// Show 2 total pages.
|
||||||
|
return totalCount
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPageTitle(position: Int): Int {
|
||||||
|
return when (position) {
|
||||||
|
posAbout -> R.string.tab_about
|
||||||
|
posLicense -> R.string.tab_licenses
|
||||||
|
else -> error("Unknown position for ViewPager2")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* List of all software components.
|
||||||
|
*/
|
||||||
|
private val SOFTWARE_COMPONENTS = arrayListOf(
|
||||||
|
SoftwareComponent(
|
||||||
|
"ACRA",
|
||||||
|
"2013",
|
||||||
|
"Kevin Gaudin",
|
||||||
|
"https://github.com/ACRA/acra",
|
||||||
|
StandardLicenses.APACHE2
|
||||||
|
),
|
||||||
|
SoftwareComponent(
|
||||||
|
"AndroidX",
|
||||||
|
"2005 - 2011",
|
||||||
|
"The Android Open Source Project",
|
||||||
|
"https://developer.android.com/jetpack",
|
||||||
|
StandardLicenses.APACHE2
|
||||||
|
),
|
||||||
|
SoftwareComponent(
|
||||||
|
"ExoPlayer",
|
||||||
|
"2014 - 2020",
|
||||||
|
"Google, Inc.",
|
||||||
|
"https://github.com/google/ExoPlayer",
|
||||||
|
StandardLicenses.APACHE2
|
||||||
|
),
|
||||||
|
SoftwareComponent(
|
||||||
|
"GigaGet",
|
||||||
|
"2014 - 2015",
|
||||||
|
"Peter Cai",
|
||||||
|
"https://github.com/PaperAirplane-Dev-Team/GigaGet",
|
||||||
|
StandardLicenses.GPL3
|
||||||
|
),
|
||||||
|
SoftwareComponent(
|
||||||
|
"Groupie",
|
||||||
|
"2016",
|
||||||
|
"Lisa Wray",
|
||||||
|
"https://github.com/lisawray/groupie",
|
||||||
|
StandardLicenses.MIT
|
||||||
|
),
|
||||||
|
SoftwareComponent(
|
||||||
|
"Android-State",
|
||||||
|
"2018",
|
||||||
|
"Evernote",
|
||||||
|
"https://github.com/Evernote/android-state",
|
||||||
|
StandardLicenses.EPL1
|
||||||
|
),
|
||||||
|
SoftwareComponent(
|
||||||
|
"Bridge",
|
||||||
|
"2021",
|
||||||
|
"Livefront",
|
||||||
|
"https://github.com/livefront/bridge",
|
||||||
|
StandardLicenses.APACHE2
|
||||||
|
),
|
||||||
|
SoftwareComponent(
|
||||||
|
"Jsoup",
|
||||||
|
"2009 - 2020",
|
||||||
|
"Jonathan Hedley",
|
||||||
|
"https://github.com/jhy/jsoup",
|
||||||
|
StandardLicenses.MIT
|
||||||
|
),
|
||||||
|
SoftwareComponent(
|
||||||
|
"Markwon",
|
||||||
|
"2019",
|
||||||
|
"Dimitry Ivanov",
|
||||||
|
"https://github.com/noties/Markwon",
|
||||||
|
StandardLicenses.APACHE2
|
||||||
|
),
|
||||||
|
SoftwareComponent(
|
||||||
|
"Material Components for Android",
|
||||||
|
"2016 - 2020",
|
||||||
|
"Google, Inc.",
|
||||||
|
"https://github.com/material-components/material-components-android",
|
||||||
|
StandardLicenses.APACHE2
|
||||||
|
),
|
||||||
|
SoftwareComponent(
|
||||||
|
"NewPipe Extractor",
|
||||||
|
"2017 - 2020",
|
||||||
|
"Christian Schabesberger",
|
||||||
|
"https://github.com/TeamNewPipe/NewPipeExtractor",
|
||||||
|
StandardLicenses.GPL3
|
||||||
|
),
|
||||||
|
SoftwareComponent(
|
||||||
|
"NoNonsense-FilePicker",
|
||||||
|
"2016",
|
||||||
|
"Jonas Kalderstam",
|
||||||
|
"https://github.com/spacecowboy/NoNonsense-FilePicker",
|
||||||
|
StandardLicenses.MPL2
|
||||||
|
),
|
||||||
|
SoftwareComponent(
|
||||||
|
"OkHttp",
|
||||||
|
"2019",
|
||||||
|
"Square, Inc.",
|
||||||
|
"https://square.github.io/okhttp/",
|
||||||
|
StandardLicenses.APACHE2
|
||||||
|
),
|
||||||
|
SoftwareComponent(
|
||||||
|
"Coil",
|
||||||
|
"2023",
|
||||||
|
"Coil Contributors",
|
||||||
|
"https://coil-kt.github.io/coil/",
|
||||||
|
StandardLicenses.APACHE2
|
||||||
|
),
|
||||||
|
SoftwareComponent(
|
||||||
|
"PrettyTime",
|
||||||
|
"2012 - 2020",
|
||||||
|
"Lincoln Baxter, III",
|
||||||
|
"https://github.com/ocpsoft/prettytime",
|
||||||
|
StandardLicenses.APACHE2
|
||||||
|
),
|
||||||
|
SoftwareComponent(
|
||||||
|
"ProcessPhoenix",
|
||||||
|
"2015",
|
||||||
|
"Jake Wharton",
|
||||||
|
"https://github.com/JakeWharton/ProcessPhoenix",
|
||||||
|
StandardLicenses.APACHE2
|
||||||
|
),
|
||||||
|
SoftwareComponent(
|
||||||
|
"RxAndroid",
|
||||||
|
"2015",
|
||||||
|
"The RxAndroid authors",
|
||||||
|
"https://github.com/ReactiveX/RxAndroid",
|
||||||
|
StandardLicenses.APACHE2
|
||||||
|
),
|
||||||
|
SoftwareComponent(
|
||||||
|
"RxBinding",
|
||||||
|
"2015",
|
||||||
|
"Jake Wharton",
|
||||||
|
"https://github.com/JakeWharton/RxBinding",
|
||||||
|
StandardLicenses.APACHE2
|
||||||
|
),
|
||||||
|
SoftwareComponent(
|
||||||
|
"RxJava",
|
||||||
|
"2016 - 2020",
|
||||||
|
"RxJava Contributors",
|
||||||
|
"https://github.com/ReactiveX/RxJava",
|
||||||
|
StandardLicenses.APACHE2
|
||||||
|
),
|
||||||
|
SoftwareComponent(
|
||||||
|
"SearchPreference",
|
||||||
|
"2018",
|
||||||
|
"ByteHamster",
|
||||||
|
"https://github.com/ByteHamster/SearchPreference",
|
||||||
|
StandardLicenses.MIT
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
app/src/main/java/org/schabi/newpipe/about/License.kt
Normal file
11
app/src/main/java/org/schabi/newpipe/about/License.kt
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package org.schabi.newpipe.about
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import java.io.Serializable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class for storing information about a software license.
|
||||||
|
*/
|
||||||
|
@Parcelize
|
||||||
|
class License(val name: String, val abbreviation: String, val filename: String) : Parcelable, Serializable
|
||||||
142
app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt
Normal file
142
app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package org.schabi.newpipe.about
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.util.Base64
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.appcompat.app.AlertDialog
|
||||||
|
import androidx.core.os.BundleCompat
|
||||||
|
import androidx.core.os.bundleOf
|
||||||
|
import androidx.fragment.app.Fragment
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
||||||
|
import io.reactivex.rxjava3.core.Observable
|
||||||
|
import io.reactivex.rxjava3.disposables.CompositeDisposable
|
||||||
|
import io.reactivex.rxjava3.disposables.Disposable
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import org.schabi.newpipe.BuildConfig
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.databinding.FragmentLicensesBinding
|
||||||
|
import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding
|
||||||
|
import org.schabi.newpipe.ktx.parcelableArrayList
|
||||||
|
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragment containing the software licenses.
|
||||||
|
*/
|
||||||
|
class LicenseFragment : Fragment() {
|
||||||
|
private lateinit var softwareComponents: List<SoftwareComponent>
|
||||||
|
private var activeSoftwareComponent: SoftwareComponent? = null
|
||||||
|
private val compositeDisposable = CompositeDisposable()
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
softwareComponents = arguments?.parcelableArrayList<SoftwareComponent>(ARG_COMPONENTS)!!
|
||||||
|
.sortedBy { it.name } // Sort components by name
|
||||||
|
activeSoftwareComponent = savedInstanceState?.let {
|
||||||
|
BundleCompat.getSerializable(it, SOFTWARE_COMPONENT_KEY, SoftwareComponent::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
compositeDisposable.dispose()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreateView(
|
||||||
|
inflater: LayoutInflater,
|
||||||
|
container: ViewGroup?,
|
||||||
|
savedInstanceState: Bundle?
|
||||||
|
): View {
|
||||||
|
val binding = FragmentLicensesBinding.inflate(inflater, container, false)
|
||||||
|
binding.licensesAppReadLicense.setOnClickListener {
|
||||||
|
compositeDisposable.add(
|
||||||
|
showLicense(NEWPIPE_SOFTWARE_COMPONENT)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
for (component in softwareComponents) {
|
||||||
|
val componentBinding = ItemSoftwareComponentBinding
|
||||||
|
.inflate(inflater, container, false)
|
||||||
|
componentBinding.name.text = component.name
|
||||||
|
componentBinding.copyright.text = getString(
|
||||||
|
R.string.copyright,
|
||||||
|
component.years,
|
||||||
|
component.copyrightOwner,
|
||||||
|
component.license.abbreviation
|
||||||
|
)
|
||||||
|
val root: View = componentBinding.root
|
||||||
|
root.tag = component
|
||||||
|
root.setOnClickListener {
|
||||||
|
compositeDisposable.add(
|
||||||
|
showLicense(component)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
binding.licensesSoftwareComponents.addView(root)
|
||||||
|
registerForContextMenu(root)
|
||||||
|
}
|
||||||
|
activeSoftwareComponent?.let { compositeDisposable.add(showLicense(it)) }
|
||||||
|
return binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(savedInstanceState: Bundle) {
|
||||||
|
super.onSaveInstanceState(savedInstanceState)
|
||||||
|
activeSoftwareComponent?.let { savedInstanceState.putSerializable(SOFTWARE_COMPONENT_KEY, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showLicense(
|
||||||
|
softwareComponent: SoftwareComponent
|
||||||
|
): Disposable {
|
||||||
|
return if (context == null) {
|
||||||
|
Disposable.empty()
|
||||||
|
} else {
|
||||||
|
val context = requireContext()
|
||||||
|
activeSoftwareComponent = softwareComponent
|
||||||
|
Observable.fromCallable { getFormattedLicense(context, softwareComponent.license) }
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.subscribe { formattedLicense ->
|
||||||
|
val webViewData = Base64.encodeToString(
|
||||||
|
formattedLicense.toByteArray(),
|
||||||
|
Base64.NO_PADDING
|
||||||
|
)
|
||||||
|
val webView = WebView(context)
|
||||||
|
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
|
||||||
|
|
||||||
|
val builder = AlertDialog.Builder(requireContext())
|
||||||
|
.setTitle(softwareComponent.name)
|
||||||
|
.setView(webView)
|
||||||
|
.setOnCancelListener { activeSoftwareComponent = null }
|
||||||
|
.setOnDismissListener { activeSoftwareComponent = null }
|
||||||
|
.setPositiveButton(R.string.done) { dialog, _ -> dialog.dismiss() }
|
||||||
|
|
||||||
|
if (softwareComponent != NEWPIPE_SOFTWARE_COMPONENT) {
|
||||||
|
builder.setNeutralButton(R.string.open_website_license) { _, _ ->
|
||||||
|
ShareUtils.openUrlInApp(requireContext(), softwareComponent.link)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ARG_COMPONENTS = "components"
|
||||||
|
private const val SOFTWARE_COMPONENT_KEY = "ACTIVE_SOFTWARE_COMPONENT"
|
||||||
|
private val NEWPIPE_SOFTWARE_COMPONENT = SoftwareComponent(
|
||||||
|
"NewPipe",
|
||||||
|
"2014-2023",
|
||||||
|
"Team NewPipe",
|
||||||
|
"https://newpipe.net/",
|
||||||
|
StandardLicenses.GPL3,
|
||||||
|
BuildConfig.VERSION_NAME
|
||||||
|
)
|
||||||
|
|
||||||
|
fun newInstance(softwareComponents: ArrayList<SoftwareComponent>): LicenseFragment {
|
||||||
|
val fragment = LicenseFragment()
|
||||||
|
fragment.arguments = bundleOf(ARG_COMPONENTS to softwareComponents)
|
||||||
|
return fragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package org.schabi.newpipe.about
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import java.io.IOException
|
||||||
|
import org.schabi.newpipe.R
|
||||||
|
import org.schabi.newpipe.util.ThemeHelper
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param context the context to use
|
||||||
|
* @param license the license
|
||||||
|
* @return String which contains a HTML formatted license page
|
||||||
|
* styled according to the context's theme
|
||||||
|
*/
|
||||||
|
fun getFormattedLicense(context: Context, license: License): String {
|
||||||
|
try {
|
||||||
|
return context.assets.open(license.filename).bufferedReader().use { it.readText() }
|
||||||
|
// split the HTML file and insert the stylesheet into the HEAD of the file
|
||||||
|
.replace("</head>", "<style>${getLicenseStylesheet(context)}</style></head>")
|
||||||
|
} catch (e: IOException) {
|
||||||
|
throw IllegalArgumentException("Could not get license file: ${license.filename}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param context the Android context
|
||||||
|
* @return String which is a CSS stylesheet according to the context's theme
|
||||||
|
*/
|
||||||
|
fun getLicenseStylesheet(context: Context): String {
|
||||||
|
val isLightTheme = ThemeHelper.isLightThemeSelected(context)
|
||||||
|
val licenseBackgroundColor = getHexRGBColor(
|
||||||
|
context,
|
||||||
|
if (isLightTheme) R.color.light_license_background_color else R.color.dark_license_background_color
|
||||||
|
)
|
||||||
|
val licenseTextColor = getHexRGBColor(
|
||||||
|
context,
|
||||||
|
if (isLightTheme) R.color.light_license_text_color else R.color.dark_license_text_color
|
||||||
|
)
|
||||||
|
val youtubePrimaryColor = getHexRGBColor(
|
||||||
|
context,
|
||||||
|
if (isLightTheme) R.color.light_youtube_primary_color else R.color.dark_youtube_primary_color
|
||||||
|
)
|
||||||
|
return "body{padding:12px 15px;margin:0;background:#$licenseBackgroundColor;color:#$licenseTextColor}" +
|
||||||
|
"a[href]{color:#$youtubePrimaryColor}pre{white-space:pre-wrap}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cast R.color to a hexadecimal color value.
|
||||||
|
*
|
||||||
|
* @param context the context to use
|
||||||
|
* @param color the color number from R.color
|
||||||
|
* @return a six characters long String with hexadecimal RGB values
|
||||||
|
*/
|
||||||
|
fun getHexRGBColor(context: Context, color: Int): String {
|
||||||
|
return context.getString(color).substring(3)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package org.schabi.newpipe.about
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import java.io.Serializable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
class SoftwareComponent
|
||||||
|
@JvmOverloads
|
||||||
|
constructor(
|
||||||
|
val name: String,
|
||||||
|
val years: String,
|
||||||
|
val copyrightOwner: String,
|
||||||
|
val link: String,
|
||||||
|
val license: License,
|
||||||
|
val version: String? = null
|
||||||
|
) : Parcelable, Serializable
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package org.schabi.newpipe.about
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class containing information about standard software licenses.
|
||||||
|
*/
|
||||||
|
object StandardLicenses {
|
||||||
|
@JvmField
|
||||||
|
val GPL3 = License("GNU General Public License, Version 3.0", "GPLv3", "gpl_3.html")
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
val APACHE2 = License("Apache License, Version 2.0", "ALv2", "apache2.html")
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
val MPL2 = License("Mozilla Public License, Version 2.0", "MPL 2.0", "mpl2.html")
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
val MIT = License("MIT License", "MIT", "mit.html")
|
||||||
|
|
||||||
|
@JvmField
|
||||||
|
val EPL1 = License("Eclipse Public License, Version 1.0", "EPL 1.0", "epl1.html")
|
||||||
|
}
|
||||||
@@ -8,7 +8,6 @@ import androidx.room.Query
|
|||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
import io.reactivex.rxjava3.core.Completable
|
import io.reactivex.rxjava3.core.Completable
|
||||||
import io.reactivex.rxjava3.core.Flowable
|
import io.reactivex.rxjava3.core.Flowable
|
||||||
import io.reactivex.rxjava3.core.Maybe
|
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
import org.schabi.newpipe.database.BasicDAO
|
import org.schabi.newpipe.database.BasicDAO
|
||||||
import org.schabi.newpipe.database.stream.model.StreamEntity
|
import org.schabi.newpipe.database.stream.model.StreamEntity
|
||||||
@@ -28,7 +27,7 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
|
|||||||
abstract override fun listByService(serviceId: Int): Flowable<List<StreamEntity>>
|
abstract override fun listByService(serviceId: Int): Flowable<List<StreamEntity>>
|
||||||
|
|
||||||
@Query("SELECT * FROM streams WHERE url = :url AND service_id = :serviceId")
|
@Query("SELECT * FROM streams WHERE url = :url AND service_id = :serviceId")
|
||||||
abstract fun getStream(serviceId: Long, url: String): Maybe<StreamEntity>
|
abstract fun getStream(serviceId: Long, url: String): Flowable<List<StreamEntity>>
|
||||||
|
|
||||||
@Query("UPDATE streams SET uploader_url = :uploaderUrl WHERE url = :url AND service_id = :serviceId")
|
@Query("UPDATE streams SET uploader_url = :uploaderUrl WHERE url = :url AND service_id = :serviceId")
|
||||||
abstract fun setUploaderUrl(serviceId: Long, url: String, uploaderUrl: String): Completable
|
abstract fun setUploaderUrl(serviceId: Long, url: String, uploaderUrl: String): Completable
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import androidx.room.OnConflictStrategy
|
|||||||
import androidx.room.Query
|
import androidx.room.Query
|
||||||
import androidx.room.Transaction
|
import androidx.room.Transaction
|
||||||
import io.reactivex.rxjava3.core.Flowable
|
import io.reactivex.rxjava3.core.Flowable
|
||||||
import io.reactivex.rxjava3.core.Maybe
|
|
||||||
import org.schabi.newpipe.database.BasicDAO
|
import org.schabi.newpipe.database.BasicDAO
|
||||||
import org.schabi.newpipe.database.stream.model.StreamStateEntity
|
import org.schabi.newpipe.database.stream.model.StreamStateEntity
|
||||||
|
|
||||||
@@ -30,12 +29,12 @@ interface StreamStateDAO : BasicDAO<StreamStateEntity> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
|
@Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
|
||||||
fun getState(streamId: Long): Maybe<StreamStateEntity>
|
fun getState(streamId: Long): Flowable<MutableList<StreamStateEntity>>
|
||||||
|
|
||||||
@Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
|
@Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
|
||||||
fun deleteState(streamId: Long): Int
|
fun deleteState(streamId: Long): Int
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.IGNORE)
|
@Insert(onConflict = OnConflictStrategy.Companion.IGNORE)
|
||||||
fun silentInsertInternal(streamState: StreamStateEntity)
|
fun silentInsertInternal(streamState: StreamStateEntity)
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
|
|||||||
internal abstract fun silentInsertAllInternal(entities: List<SubscriptionEntity>): List<Long>
|
internal abstract fun silentInsertAllInternal(entities: List<SubscriptionEntity>): List<Long>
|
||||||
|
|
||||||
@Transaction
|
@Transaction
|
||||||
open fun upsertAll(entities: List<SubscriptionEntity>) {
|
open fun upsertAll(entities: List<SubscriptionEntity>): List<SubscriptionEntity> {
|
||||||
val insertUidList = silentInsertAllInternal(entities)
|
val insertUidList = silentInsertAllInternal(entities)
|
||||||
|
|
||||||
insertUidList.forEachIndexed { index: Int, uidFromInsert: Long ->
|
insertUidList.forEachIndexed { index: Int, uidFromInsert: Long ->
|
||||||
@@ -106,5 +106,7 @@ abstract class SubscriptionDAO : BasicDAO<SubscriptionEntity> {
|
|||||||
update(entity)
|
update(entity)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return entities
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ public class DownloadDialog extends DialogFragment
|
|||||||
toolbar.setNavigationOnClickListener(v -> dismiss());
|
toolbar.setNavigationOnClickListener(v -> dismiss());
|
||||||
toolbar.setNavigationContentDescription(R.string.cancel);
|
toolbar.setNavigationContentDescription(R.string.cancel);
|
||||||
|
|
||||||
okButton = toolbar.findViewById(R.id.okay);
|
okButton = toolbar.getMenu().findItem(R.id.okay);
|
||||||
okButton.setEnabled(false); // disable until the download service connection is done
|
okButton.setEnabled(false); // disable until the download service connection is done
|
||||||
|
|
||||||
toolbar.setOnMenuItemClickListener(item -> {
|
toolbar.setOnMenuItemClickListener(item -> {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import com.google.auto.service.AutoService;
|
|||||||
import org.acra.config.CoreConfiguration;
|
import org.acra.config.CoreConfiguration;
|
||||||
import org.acra.sender.ReportSender;
|
import org.acra.sender.ReportSender;
|
||||||
import org.acra.sender.ReportSenderFactory;
|
import org.acra.sender.ReportSenderFactory;
|
||||||
|
import org.schabi.newpipe.App;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Created by Christian Schabesberger on 13.09.16.
|
* Created by Christian Schabesberger on 13.09.16.
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import org.schabi.newpipe.databinding.ActivityErrorBinding
|
|||||||
import org.schabi.newpipe.util.Localization
|
import org.schabi.newpipe.util.Localization
|
||||||
import org.schabi.newpipe.util.ThemeHelper
|
import org.schabi.newpipe.util.ThemeHelper
|
||||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||||
|
import org.schabi.newpipe.util.text.setTextWithLinks
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This activity is used to show error details and allow reporting them in various ways.
|
* This activity is used to show error details and allow reporting them in various ways.
|
||||||
@@ -100,7 +101,7 @@ class ErrorActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
// normal bugreport
|
// normal bugreport
|
||||||
buildInfo(errorInfo)
|
buildInfo(errorInfo)
|
||||||
binding.errorMessageView.text = errorInfo.getMessage(this)
|
binding.errorMessageView.setTextWithLinks(errorInfo.getMessage(this))
|
||||||
binding.errorView.text = formErrorText(errorInfo.stackTraces)
|
binding.errorView.text = formErrorText(errorInfo.stackTraces)
|
||||||
|
|
||||||
// print stack trace once again for debugging:
|
// print stack trace once again for debugging:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package org.schabi.newpipe.error
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import com.google.android.exoplayer2.ExoPlaybackException
|
import com.google.android.exoplayer2.ExoPlaybackException
|
||||||
import com.google.android.exoplayer2.upstream.HttpDataSource
|
import com.google.android.exoplayer2.upstream.HttpDataSource
|
||||||
import com.google.android.exoplayer2.upstream.Loader
|
import com.google.android.exoplayer2.upstream.Loader
|
||||||
@@ -28,7 +29,7 @@ import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentExcepti
|
|||||||
import org.schabi.newpipe.ktx.isNetworkRelated
|
import org.schabi.newpipe.ktx.isNetworkRelated
|
||||||
import org.schabi.newpipe.player.mediasource.FailedMediaSource
|
import org.schabi.newpipe.player.mediasource.FailedMediaSource
|
||||||
import org.schabi.newpipe.player.resolver.PlaybackResolver
|
import org.schabi.newpipe.player.resolver.PlaybackResolver
|
||||||
import org.schabi.newpipe.util.Localization
|
import org.schabi.newpipe.util.text.getText
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* An error has occurred in the app. This class contains plain old parcelable data that can be used
|
* An error has occurred in the app. This class contains plain old parcelable data that can be used
|
||||||
@@ -135,8 +136,8 @@ class ErrorInfo private constructor(
|
|||||||
return getServiceName(serviceId)
|
return getServiceName(serviceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getMessage(context: Context): String {
|
fun getMessage(context: Context): CharSequence {
|
||||||
return message.getString(context)
|
return message.getText(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -146,21 +147,26 @@ class ErrorInfo private constructor(
|
|||||||
private val stringRes: Int,
|
private val stringRes: Int,
|
||||||
private vararg val formatArgs: String
|
private vararg val formatArgs: String
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
fun getString(context: Context): String {
|
fun getText(context: Context): CharSequence {
|
||||||
// use Localization.compatGetString() just in case context is not AppCompatActivity
|
// Ensure locale aware context via ContextCompat.getContextForLanguage() (just in case context is not AppCompatActivity)
|
||||||
|
val ctx = ContextCompat.getContextForLanguage(context)
|
||||||
return if (formatArgs.isEmpty()) {
|
return if (formatArgs.isEmpty()) {
|
||||||
Localization.compatGetString(context, stringRes)
|
ctx.getText(stringRes)
|
||||||
} else {
|
} else {
|
||||||
Localization.compatGetString(context, stringRes, *formatArgs)
|
// ContextCompat.getString() with formatArgs does not exist, so we just
|
||||||
|
// replicate its source code but with formatArgs
|
||||||
|
ctx.resources.getText(stringRes, *formatArgs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const val SERVICE_NONE = "<unknown_service>"
|
const val SERVICE_NONE = "<unknown_service>"
|
||||||
|
|
||||||
|
const val YOUTUBE_IP_BAN_FAQ_URL = "https://newpipe.net/FAQ/#ip-banned-youtube"
|
||||||
|
|
||||||
private fun getServiceName(serviceId: Int?) = // not using getNameOfServiceById since we want to accept a nullable serviceId and we
|
private fun getServiceName(serviceId: Int?) = // not using getNameOfServiceById since we want to accept a nullable serviceId and we
|
||||||
// want to default to SERVICE_NONE
|
// want to default to SERVICE_NONE
|
||||||
ServiceList.all()?.firstOrNull { it.serviceId == serviceId }?.serviceInfo?.name
|
ServiceList.all().firstOrNull { it.serviceId == serviceId }?.serviceInfo?.name
|
||||||
?: SERVICE_NONE
|
?: SERVICE_NONE
|
||||||
|
|
||||||
fun throwableToStringList(throwable: Throwable) = arrayOf(throwable.stackTraceToString())
|
fun throwableToStringList(throwable: Throwable) = arrayOf(throwable.stackTraceToString())
|
||||||
@@ -245,7 +251,11 @@ class ErrorInfo private constructor(
|
|||||||
ErrorMessage(R.string.youtube_music_premium_content)
|
ErrorMessage(R.string.youtube_music_premium_content)
|
||||||
|
|
||||||
throwable is SignInConfirmNotBotException ->
|
throwable is SignInConfirmNotBotException ->
|
||||||
ErrorMessage(R.string.sign_in_confirm_not_bot_error, getServiceName(serviceId))
|
ErrorMessage(
|
||||||
|
R.string.sign_in_confirm_not_bot_error,
|
||||||
|
getServiceName(serviceId),
|
||||||
|
YOUTUBE_IP_BAN_FAQ_URL
|
||||||
|
)
|
||||||
|
|
||||||
throwable is ContentNotAvailableException ->
|
throwable is ContentNotAvailableException ->
|
||||||
ErrorMessage(R.string.content_not_available)
|
ErrorMessage(R.string.content_not_available)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import org.schabi.newpipe.MainActivity
|
|||||||
import org.schabi.newpipe.R
|
import org.schabi.newpipe.R
|
||||||
import org.schabi.newpipe.ktx.animate
|
import org.schabi.newpipe.ktx.animate
|
||||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
import org.schabi.newpipe.util.external_communication.ShareUtils
|
||||||
|
import org.schabi.newpipe.util.text.setTextWithLinks
|
||||||
|
|
||||||
class ErrorPanelHelper(
|
class ErrorPanelHelper(
|
||||||
private val fragment: Fragment,
|
private val fragment: Fragment,
|
||||||
@@ -64,7 +65,7 @@ class ErrorPanelHelper(
|
|||||||
|
|
||||||
fun showError(errorInfo: ErrorInfo) {
|
fun showError(errorInfo: ErrorInfo) {
|
||||||
ensureDefaultVisibility()
|
ensureDefaultVisibility()
|
||||||
errorTextView.text = errorInfo.getMessage(context)
|
errorTextView.setTextWithLinks(errorInfo.getMessage(context))
|
||||||
|
|
||||||
if (errorInfo.recaptchaUrl != null) {
|
if (errorInfo.recaptchaUrl != null) {
|
||||||
showAndSetErrorButtonAction(R.string.recaptcha_solve) {
|
showAndSetErrorButtonAction(R.string.recaptcha_solve) {
|
||||||
@@ -109,7 +110,7 @@ class ErrorPanelHelper(
|
|||||||
fun showTextError(errorString: String) {
|
fun showTextError(errorString: String) {
|
||||||
ensureDefaultVisibility()
|
ensureDefaultVisibility()
|
||||||
|
|
||||||
errorTextView.text = errorString
|
errorTextView.setTextWithLinks(errorString)
|
||||||
|
|
||||||
setRootVisible()
|
setRootVisible()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,9 @@ import android.view.View;
|
|||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.compose.ui.platform.ComposeView;
|
|
||||||
|
|
||||||
import org.schabi.newpipe.BaseFragment;
|
import org.schabi.newpipe.BaseFragment;
|
||||||
import org.schabi.newpipe.R;
|
import org.schabi.newpipe.R;
|
||||||
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
|
|
||||||
|
|
||||||
public class EmptyFragment extends BaseFragment {
|
public class EmptyFragment extends BaseFragment {
|
||||||
private static final String SHOW_MESSAGE = "SHOW_MESSAGE";
|
private static final String SHOW_MESSAGE = "SHOW_MESSAGE";
|
||||||
@@ -28,10 +26,8 @@ public class EmptyFragment extends BaseFragment {
|
|||||||
final Bundle savedInstanceState) {
|
final Bundle savedInstanceState) {
|
||||||
final boolean showMessage = getArguments().getBoolean(SHOW_MESSAGE);
|
final boolean showMessage = getArguments().getBoolean(SHOW_MESSAGE);
|
||||||
final View view = inflater.inflate(R.layout.fragment_empty, container, false);
|
final View view = inflater.inflate(R.layout.fragment_empty, container, false);
|
||||||
|
view.findViewById(R.id.empty_state_view).setVisibility(
|
||||||
final ComposeView composeView = view.findViewById(R.id.empty_state_view);
|
showMessage ? View.VISIBLE : View.GONE);
|
||||||
EmptyStateUtil.setEmptyStateComposable(composeView);
|
|
||||||
composeView.setVisibility(showMessage ? View.VISIBLE : View.GONE);
|
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,9 +216,9 @@ public abstract class BaseDescriptionFragment extends BaseFragment {
|
|||||||
|| image.getWidth() != Image.WIDTH_UNKNOWN
|
|| image.getWidth() != Image.WIDTH_UNKNOWN
|
||||||
// if even the resolution level is unknown, ?x? will be shown
|
// if even the resolution level is unknown, ?x? will be shown
|
||||||
|| image.getEstimatedResolutionLevel() == Image.ResolutionLevel.UNKNOWN) {
|
|| image.getEstimatedResolutionLevel() == Image.ResolutionLevel.UNKNOWN) {
|
||||||
urls.append(imageSizeToText(image.getHeight()));
|
|
||||||
urls.append('x');
|
|
||||||
urls.append(imageSizeToText(image.getWidth()));
|
urls.append(imageSizeToText(image.getWidth()));
|
||||||
|
urls.append('x');
|
||||||
|
urls.append(imageSizeToText(image.getHeight()));
|
||||||
} else {
|
} else {
|
||||||
switch (image.getEstimatedResolutionLevel()) {
|
switch (image.getEstimatedResolutionLevel()) {
|
||||||
case LOW -> urls.append(getString(R.string.image_quality_low));
|
case LOW -> urls.append(getString(R.string.image_quality_low));
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,6 @@ package org.schabi.newpipe.fragments.list.channel;
|
|||||||
import static org.schabi.newpipe.ktx.TextViewUtils.animateTextColor;
|
import static org.schabi.newpipe.ktx.TextViewUtils.animateTextColor;
|
||||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||||
import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor;
|
import static org.schabi.newpipe.ktx.ViewUtils.animateBackgroundColor;
|
||||||
import static org.schabi.newpipe.ui.emptystate.EmptyStateUtil.setEmptyStateComposable;
|
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
@@ -11,6 +10,7 @@ import android.graphics.Color;
|
|||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.text.TextUtils;
|
import android.text.TextUtils;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
import android.util.TypedValue;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
import android.view.Menu;
|
import android.view.Menu;
|
||||||
import android.view.MenuInflater;
|
import android.view.MenuInflater;
|
||||||
@@ -45,7 +45,6 @@ import org.schabi.newpipe.fragments.detail.TabAdapter;
|
|||||||
import org.schabi.newpipe.ktx.AnimationType;
|
import org.schabi.newpipe.ktx.AnimationType;
|
||||||
import org.schabi.newpipe.local.feed.notifications.NotificationHelper;
|
import org.schabi.newpipe.local.feed.notifications.NotificationHelper;
|
||||||
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||||
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
|
|
||||||
import org.schabi.newpipe.util.ChannelTabHelper;
|
import org.schabi.newpipe.util.ChannelTabHelper;
|
||||||
import org.schabi.newpipe.util.Constants;
|
import org.schabi.newpipe.util.Constants;
|
||||||
import org.schabi.newpipe.util.ExtractorHelper;
|
import org.schabi.newpipe.util.ExtractorHelper;
|
||||||
@@ -195,8 +194,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
|||||||
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);
|
||||||
|
|
||||||
setEmptyStateComposable(binding.emptyStateView, EmptyStateSpec.ContentNotSupported);
|
|
||||||
|
|
||||||
tabAdapter = new TabAdapter(getChildFragmentManager());
|
tabAdapter = new TabAdapter(getChildFragmentManager());
|
||||||
binding.viewPager.setAdapter(tabAdapter);
|
binding.viewPager.setAdapter(tabAdapter);
|
||||||
binding.tabLayout.setupWithViewPager(binding.viewPager);
|
binding.tabLayout.setupWithViewPager(binding.viewPager);
|
||||||
@@ -650,6 +647,8 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
binding.emptyStateView.setVisibility(View.VISIBLE);
|
binding.errorContentNotSupported.setVisibility(View.VISIBLE);
|
||||||
|
binding.channelKaomoji.setText("(︶︹︺)");
|
||||||
|
binding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
|||||||
import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
|
import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
|
||||||
import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue;
|
import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue;
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||||
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
|
|
||||||
import org.schabi.newpipe.util.ChannelTabHelper;
|
import org.schabi.newpipe.util.ChannelTabHelper;
|
||||||
import org.schabi.newpipe.util.ExtractorHelper;
|
import org.schabi.newpipe.util.ExtractorHelper;
|
||||||
import org.schabi.newpipe.util.PlayButtonHelper;
|
import org.schabi.newpipe.util.PlayButtonHelper;
|
||||||
@@ -80,12 +79,6 @@ public class ChannelTabFragment extends BaseListInfoFragment<InfoItem, ChannelTa
|
|||||||
return inflater.inflate(R.layout.fragment_channel_tab, container, false);
|
return inflater.inflate(R.layout.fragment_channel_tab, container, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
|
|
||||||
super.onViewCreated(rootView, savedInstanceState);
|
|
||||||
EmptyStateUtil.setEmptyStateComposable(rootView.findViewById(R.id.empty_state_view));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onDestroyView() {
|
public void onDestroyView() {
|
||||||
super.onDestroyView();
|
super.onDestroyView();
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package org.schabi.newpipe.fragments.list.comments;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.constraintlayout.widget.ConstraintLayout;
|
||||||
|
import androidx.core.text.HtmlCompat;
|
||||||
|
|
||||||
|
import com.evernote.android.state.State;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.databinding.CommentRepliesHeaderBinding;
|
||||||
|
import org.schabi.newpipe.error.UserAction;
|
||||||
|
import org.schabi.newpipe.extractor.ListExtractor;
|
||||||
|
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||||
|
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||||
|
import org.schabi.newpipe.info_list.ItemViewMode;
|
||||||
|
import org.schabi.newpipe.util.DeviceUtils;
|
||||||
|
import org.schabi.newpipe.util.ExtractorHelper;
|
||||||
|
import org.schabi.newpipe.util.Localization;
|
||||||
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
|
import org.schabi.newpipe.util.image.CoilHelper;
|
||||||
|
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||||
|
import org.schabi.newpipe.util.text.TextLinkifier;
|
||||||
|
import org.schabi.newpipe.util.text.LongPressLinkMovementMethod;
|
||||||
|
|
||||||
|
import java.util.Queue;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.core.Single;
|
||||||
|
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||||
|
|
||||||
|
public final class CommentRepliesFragment
|
||||||
|
extends BaseListInfoFragment<CommentsInfoItem, CommentRepliesInfo> {
|
||||||
|
|
||||||
|
public static final String TAG = CommentRepliesFragment.class.getSimpleName();
|
||||||
|
|
||||||
|
@State
|
||||||
|
CommentsInfoItem commentsInfoItem; // the comment to show replies of
|
||||||
|
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||||
|
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Constructors and lifecycle
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
// only called by the Android framework, after which readFrom is called and restores all data
|
||||||
|
public CommentRepliesFragment() {
|
||||||
|
super(UserAction.REQUESTED_COMMENT_REPLIES);
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommentRepliesFragment(@NonNull final CommentsInfoItem commentsInfoItem) {
|
||||||
|
this();
|
||||||
|
this.commentsInfoItem = commentsInfoItem;
|
||||||
|
// setting "" as title since the title will be properly set right after
|
||||||
|
setInitialData(commentsInfoItem.getServiceId(), commentsInfoItem.getUrl(), "");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
@Override
|
||||||
|
public View onCreateView(@NonNull final LayoutInflater inflater,
|
||||||
|
@Nullable final ViewGroup container,
|
||||||
|
@Nullable final Bundle savedInstanceState) {
|
||||||
|
return inflater.inflate(R.layout.fragment_comments, container, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroyView() {
|
||||||
|
disposables.clear();
|
||||||
|
super.onDestroyView();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Supplier<View> getListHeaderSupplier() {
|
||||||
|
return () -> {
|
||||||
|
final CommentRepliesHeaderBinding binding = CommentRepliesHeaderBinding
|
||||||
|
.inflate(activity.getLayoutInflater(), itemsList, false);
|
||||||
|
final CommentsInfoItem item = commentsInfoItem;
|
||||||
|
|
||||||
|
// load the author avatar
|
||||||
|
CoilHelper.INSTANCE.loadAvatar(binding.authorAvatar, item.getUploaderAvatars());
|
||||||
|
binding.authorAvatar.setVisibility(ImageStrategy.shouldLoadImages()
|
||||||
|
? View.VISIBLE : View.GONE);
|
||||||
|
|
||||||
|
// setup author name and comment date
|
||||||
|
binding.authorName.setText(item.getUploaderName());
|
||||||
|
binding.uploadDate.setText(Localization.relativeTimeOrTextual(
|
||||||
|
getContext(), item.getUploadDate(), item.getTextualUploadDate()));
|
||||||
|
binding.authorTouchArea.setOnClickListener(
|
||||||
|
v -> NavigationHelper.openCommentAuthorIfPresent(requireActivity(), item));
|
||||||
|
|
||||||
|
// setup like count, hearted and pinned
|
||||||
|
binding.thumbsUpCount.setText(
|
||||||
|
Localization.likeCount(requireContext(), item.getLikeCount()));
|
||||||
|
// for heartImage goneMarginEnd was used, but there is no way to tell ConstraintLayout
|
||||||
|
// not to use a different margin only when both the next two views are gone
|
||||||
|
((ConstraintLayout.LayoutParams) binding.thumbsUpCount.getLayoutParams())
|
||||||
|
.setMarginEnd(DeviceUtils.dpToPx(
|
||||||
|
(item.isHeartedByUploader() || item.isPinned() ? 8 : 16),
|
||||||
|
requireContext()));
|
||||||
|
binding.heartImage.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE);
|
||||||
|
binding.pinnedImage.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
|
||||||
|
|
||||||
|
// setup comment content
|
||||||
|
TextLinkifier.fromDescription(binding.commentContent, item.getCommentText(),
|
||||||
|
HtmlCompat.FROM_HTML_MODE_LEGACY, getServiceById(item.getServiceId()),
|
||||||
|
item.getUrl(), disposables, null);
|
||||||
|
binding.commentContent.setMovementMethod(LongPressLinkMovementMethod.getInstance());
|
||||||
|
return binding.getRoot();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// State saving
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void writeTo(final Queue<Object> objectsToSave) {
|
||||||
|
super.writeTo(objectsToSave);
|
||||||
|
objectsToSave.add(commentsInfoItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void readFrom(@NonNull final Queue<Object> savedObjects) throws Exception {
|
||||||
|
super.readFrom(savedObjects);
|
||||||
|
commentsInfoItem = (CommentsInfoItem) savedObjects.poll();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Data loading
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Single<CommentRepliesInfo> loadResult(final boolean forceLoad) {
|
||||||
|
return Single.fromCallable(() -> new CommentRepliesInfo(commentsInfoItem,
|
||||||
|
// the reply count string will be shown as the activity title
|
||||||
|
Localization.replyCount(requireContext(), commentsInfoItem.getReplyCount())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Single<ListExtractor.InfoItemsPage<CommentsInfoItem>> loadMoreItemsLogic() {
|
||||||
|
// commentsInfoItem.getUrl() should contain the url of the original
|
||||||
|
// ListInfo<CommentsInfoItem>, which should be the stream url
|
||||||
|
return ExtractorHelper.getMoreCommentItems(
|
||||||
|
serviceId, commentsInfoItem.getUrl(), currentNextPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Utils
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ItemViewMode getItemViewMode() {
|
||||||
|
return ItemViewMode.LIST;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the comment to which the replies are shown
|
||||||
|
*/
|
||||||
|
public CommentsInfoItem getCommentsInfoItem() {
|
||||||
|
return commentsInfoItem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package org.schabi.newpipe.fragments.list.comments;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.extractor.ListInfo;
|
||||||
|
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||||
|
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
|
||||||
|
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
public final class CommentRepliesInfo extends ListInfo<CommentsInfoItem> {
|
||||||
|
/**
|
||||||
|
* This class is used to wrap the comment replies page into a ListInfo object.
|
||||||
|
*
|
||||||
|
* @param comment the comment from which to get replies
|
||||||
|
* @param name will be shown as the fragment title
|
||||||
|
*/
|
||||||
|
public CommentRepliesInfo(final CommentsInfoItem comment, final String name) {
|
||||||
|
super(comment.getServiceId(),
|
||||||
|
new ListLinkHandler("", "", "", Collections.emptyList(), null), name);
|
||||||
|
setNextPage(comment.getReplies());
|
||||||
|
setRelatedItems(Collections.emptyList()); // since it must be non-null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package org.schabi.newpipe.fragments.list.comments;
|
||||||
|
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.Menu;
|
||||||
|
import android.view.MenuInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.error.UserAction;
|
||||||
|
import org.schabi.newpipe.extractor.ListExtractor;
|
||||||
|
import org.schabi.newpipe.extractor.comments.CommentsInfo;
|
||||||
|
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||||
|
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||||
|
import org.schabi.newpipe.info_list.ItemViewMode;
|
||||||
|
import org.schabi.newpipe.ktx.ViewUtils;
|
||||||
|
import org.schabi.newpipe.util.ExtractorHelper;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.core.Single;
|
||||||
|
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||||
|
|
||||||
|
public class CommentsFragment extends BaseListInfoFragment<CommentsInfoItem, CommentsInfo> {
|
||||||
|
private final CompositeDisposable disposables = new CompositeDisposable();
|
||||||
|
|
||||||
|
private TextView emptyStateDesc;
|
||||||
|
|
||||||
|
public static CommentsFragment getInstance(final int serviceId, final String url,
|
||||||
|
final String name) {
|
||||||
|
final CommentsFragment instance = new CommentsFragment();
|
||||||
|
instance.setInitialData(serviceId, url, name);
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CommentsFragment() {
|
||||||
|
super(UserAction.REQUESTED_COMMENTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void initViews(final View rootView, final Bundle savedInstanceState) {
|
||||||
|
super.initViews(rootView, savedInstanceState);
|
||||||
|
|
||||||
|
emptyStateDesc = rootView.findViewById(R.id.empty_state_desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// LifeCycle
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public View onCreateView(@NonNull final LayoutInflater inflater,
|
||||||
|
@Nullable final ViewGroup container,
|
||||||
|
@Nullable final Bundle savedInstanceState) {
|
||||||
|
return inflater.inflate(R.layout.fragment_comments, container, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
super.onDestroy();
|
||||||
|
disposables.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Load and handle
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Single<ListExtractor.InfoItemsPage<CommentsInfoItem>> loadMoreItemsLogic() {
|
||||||
|
return ExtractorHelper.getMoreCommentItems(serviceId, currentInfo, currentNextPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Single<CommentsInfo> loadResult(final boolean forceLoad) {
|
||||||
|
return ExtractorHelper.getCommentsInfo(serviceId, url, forceLoad);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Contract
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleResult(@NonNull final CommentsInfo result) {
|
||||||
|
super.handleResult(result);
|
||||||
|
|
||||||
|
emptyStateDesc.setText(
|
||||||
|
result.isCommentsDisabled()
|
||||||
|
? R.string.comments_are_disabled
|
||||||
|
: R.string.no_comments);
|
||||||
|
|
||||||
|
ViewUtils.slideUp(requireView(), 120, 150, 0.06f);
|
||||||
|
disposables.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Utils
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setTitle(final String title) { }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreateOptionsMenu(@NonNull final Menu menu,
|
||||||
|
@NonNull final MenuInflater inflater) { }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ItemViewMode getItemViewMode() {
|
||||||
|
return ItemViewMode.LIST;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean scrollToComment(final CommentsInfoItem comment) {
|
||||||
|
final int position = infoListAdapter.getItemsList().indexOf(comment);
|
||||||
|
if (position < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
itemsList.scrollToPosition(position);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
package org.schabi.newpipe.fragments.list.comments
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.core.os.bundleOf
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.fragment.compose.content
|
|
||||||
import org.schabi.newpipe.ui.components.video.comment.CommentSection
|
|
||||||
import org.schabi.newpipe.ui.theme.AppTheme
|
|
||||||
import org.schabi.newpipe.util.KEY_SERVICE_ID
|
|
||||||
import org.schabi.newpipe.util.KEY_URL
|
|
||||||
|
|
||||||
class CommentsFragment : Fragment() {
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
) = content {
|
|
||||||
AppTheme {
|
|
||||||
Surface {
|
|
||||||
CommentSection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
@JvmStatic
|
|
||||||
fun getInstance(serviceId: Int, url: String?) = CommentsFragment().apply {
|
|
||||||
arguments = bundleOf(KEY_SERVICE_ID to serviceId, KEY_URL to url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ package org.schabi.newpipe.fragments.list.search;
|
|||||||
import static androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags;
|
import static androidx.recyclerview.widget.ItemTouchHelper.Callback.makeMovementFlags;
|
||||||
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
|
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
|
||||||
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
import static org.schabi.newpipe.ktx.ViewUtils.animate;
|
||||||
import static org.schabi.newpipe.ui.emptystate.EmptyStateUtil.setEmptyStateComposable;
|
|
||||||
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
|
import static org.schabi.newpipe.util.ExtractorHelper.showMetaInfoInTextView;
|
||||||
import static java.util.Arrays.asList;
|
import static java.util.Arrays.asList;
|
||||||
|
|
||||||
@@ -66,7 +65,6 @@ import org.schabi.newpipe.ktx.AnimationType;
|
|||||||
import org.schabi.newpipe.ktx.ExceptionUtils;
|
import org.schabi.newpipe.ktx.ExceptionUtils;
|
||||||
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||||
import org.schabi.newpipe.settings.NewPipeSettings;
|
import org.schabi.newpipe.settings.NewPipeSettings;
|
||||||
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
|
|
||||||
import org.schabi.newpipe.util.Constants;
|
import org.schabi.newpipe.util.Constants;
|
||||||
import org.schabi.newpipe.util.DeviceUtils;
|
import org.schabi.newpipe.util.DeviceUtils;
|
||||||
import org.schabi.newpipe.util.ExtractorHelper;
|
import org.schabi.newpipe.util.ExtractorHelper;
|
||||||
@@ -357,8 +355,6 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
|
|||||||
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);
|
||||||
|
|
||||||
setEmptyStateComposable(searchBinding.emptyStateView, EmptyStateSpec.NoSearchResult);
|
|
||||||
|
|
||||||
searchBinding.suggestionsList.setAdapter(suggestionListAdapter);
|
searchBinding.suggestionsList.setAdapter(suggestionListAdapter);
|
||||||
// animations are just strange and useless, since the suggestions keep changing too much
|
// animations are just strange and useless, since the suggestions keep changing too much
|
||||||
searchBinding.suggestionsList.setItemAnimator(null);
|
searchBinding.suggestionsList.setItemAnimator(null);
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package org.schabi.newpipe.fragments.list.videos;
|
||||||
|
|
||||||
|
import android.content.SharedPreferences;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.view.LayoutInflater;
|
||||||
|
import android.view.Menu;
|
||||||
|
import android.view.MenuInflater;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.fragment.app.Fragment;
|
||||||
|
import androidx.preference.PreferenceManager;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.databinding.RelatedItemsHeaderBinding;
|
||||||
|
import org.schabi.newpipe.error.UserAction;
|
||||||
|
import org.schabi.newpipe.extractor.InfoItem;
|
||||||
|
import org.schabi.newpipe.extractor.ListExtractor;
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||||
|
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
|
||||||
|
import org.schabi.newpipe.info_list.ItemViewMode;
|
||||||
|
import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
|
||||||
|
import org.schabi.newpipe.ktx.ViewUtils;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.core.Single;
|
||||||
|
|
||||||
|
public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, RelatedItemsInfo>
|
||||||
|
implements SharedPreferences.OnSharedPreferenceChangeListener {
|
||||||
|
private static final String INFO_KEY = "related_info_key";
|
||||||
|
|
||||||
|
private RelatedItemsInfo relatedItemsInfo;
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Views
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
private RelatedItemsHeaderBinding headerBinding;
|
||||||
|
|
||||||
|
public static RelatedItemsFragment getInstance(final StreamInfo info) {
|
||||||
|
final RelatedItemsFragment instance = new RelatedItemsFragment();
|
||||||
|
instance.setInitialData(info);
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public RelatedItemsFragment() {
|
||||||
|
super(UserAction.REQUESTED_STREAM);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// LifeCycle
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public View onCreateView(@NonNull final LayoutInflater inflater,
|
||||||
|
@Nullable final ViewGroup container,
|
||||||
|
@Nullable final Bundle savedInstanceState) {
|
||||||
|
return inflater.inflate(R.layout.fragment_related_items, container, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroyView() {
|
||||||
|
headerBinding = null;
|
||||||
|
super.onDestroyView();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Supplier<View> getListHeaderSupplier() {
|
||||||
|
if (relatedItemsInfo == null || relatedItemsInfo.getRelatedItems() == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
headerBinding = RelatedItemsHeaderBinding
|
||||||
|
.inflate(activity.getLayoutInflater(), itemsList, false);
|
||||||
|
|
||||||
|
final SharedPreferences pref = PreferenceManager
|
||||||
|
.getDefaultSharedPreferences(requireContext());
|
||||||
|
final boolean autoplay = pref.getBoolean(getString(R.string.auto_queue_key), false);
|
||||||
|
headerBinding.autoplaySwitch.setChecked(autoplay);
|
||||||
|
headerBinding.autoplaySwitch.setOnCheckedChangeListener((compoundButton, b) ->
|
||||||
|
PreferenceManager.getDefaultSharedPreferences(requireContext()).edit()
|
||||||
|
.putBoolean(getString(R.string.auto_queue_key), b).apply());
|
||||||
|
|
||||||
|
return headerBinding::getRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Single<ListExtractor.InfoItemsPage<InfoItem>> loadMoreItemsLogic() {
|
||||||
|
return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Contract
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected Single<RelatedItemsInfo> loadResult(final boolean forceLoad) {
|
||||||
|
return Single.fromCallable(() -> relatedItemsInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void showLoading() {
|
||||||
|
super.showLoading();
|
||||||
|
if (headerBinding != null) {
|
||||||
|
headerBinding.getRoot().setVisibility(View.INVISIBLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleResult(@NonNull final RelatedItemsInfo result) {
|
||||||
|
super.handleResult(result);
|
||||||
|
|
||||||
|
if (headerBinding != null) {
|
||||||
|
headerBinding.getRoot().setVisibility(View.VISIBLE);
|
||||||
|
}
|
||||||
|
ViewUtils.slideUp(requireView(), 120, 96, 0.06f);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Utils
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setTitle(final String title) {
|
||||||
|
// Nothing to do - override parent
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreateOptionsMenu(@NonNull final Menu menu,
|
||||||
|
@NonNull final MenuInflater inflater) {
|
||||||
|
// Nothing to do - override parent
|
||||||
|
}
|
||||||
|
|
||||||
|
private void setInitialData(final StreamInfo info) {
|
||||||
|
super.setInitialData(info.getServiceId(), info.getUrl(), info.getName());
|
||||||
|
if (this.relatedItemsInfo == null) {
|
||||||
|
this.relatedItemsInfo = new RelatedItemsInfo(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSaveInstanceState(@NonNull final Bundle outState) {
|
||||||
|
super.onSaveInstanceState(outState);
|
||||||
|
outState.putSerializable(INFO_KEY, relatedItemsInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onRestoreInstanceState(@NonNull final Bundle savedState) {
|
||||||
|
super.onRestoreInstanceState(savedState);
|
||||||
|
final Serializable serializable = savedState.getSerializable(INFO_KEY);
|
||||||
|
if (serializable instanceof RelatedItemsInfo) {
|
||||||
|
this.relatedItemsInfo = (RelatedItemsInfo) serializable;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
|
||||||
|
final String key) {
|
||||||
|
if (headerBinding != null && getString(R.string.auto_queue_key).equals(key)) {
|
||||||
|
headerBinding.autoplaySwitch.setChecked(sharedPreferences.getBoolean(key, false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ItemViewMode getItemViewMode() {
|
||||||
|
ItemViewMode mode = super.getItemViewMode();
|
||||||
|
// Only list mode is supported. Either List or card will be used.
|
||||||
|
if (mode != ItemViewMode.LIST && mode != ItemViewMode.CARD) {
|
||||||
|
mode = ItemViewMode.LIST;
|
||||||
|
}
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void showInfoItemDialog(final StreamInfoItem item) {
|
||||||
|
// Try and attach the InfoItemDialog to the parent fragment of the RelatedItemsFragment
|
||||||
|
// so that its context is not lost when the RelatedItemsFragment is reinitialized,
|
||||||
|
// e.g. when a new stream is loaded in a parent VideoDetailFragment.
|
||||||
|
final Fragment parentFragment = getParentFragment();
|
||||||
|
if (parentFragment != null) {
|
||||||
|
try {
|
||||||
|
new InfoItemDialog.Builder(
|
||||||
|
parentFragment.getActivity(),
|
||||||
|
parentFragment.getContext(),
|
||||||
|
parentFragment,
|
||||||
|
item
|
||||||
|
).create().show();
|
||||||
|
} catch (final IllegalArgumentException e) {
|
||||||
|
InfoItemDialog.Builder.reportErrorDuringInitialization(e, item);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
super.showInfoItemDialog(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package org.schabi.newpipe.fragments.list.videos
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.ViewGroup
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.core.os.bundleOf
|
|
||||||
import androidx.fragment.app.Fragment
|
|
||||||
import androidx.fragment.compose.content
|
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfo
|
|
||||||
import org.schabi.newpipe.ktx.serializable
|
|
||||||
import org.schabi.newpipe.ui.components.video.RelatedItems
|
|
||||||
import org.schabi.newpipe.ui.theme.AppTheme
|
|
||||||
import org.schabi.newpipe.util.KEY_INFO
|
|
||||||
|
|
||||||
class RelatedItemsFragment : Fragment() {
|
|
||||||
override fun onCreateView(
|
|
||||||
inflater: LayoutInflater,
|
|
||||||
container: ViewGroup?,
|
|
||||||
savedInstanceState: Bundle?
|
|
||||||
) = content {
|
|
||||||
AppTheme {
|
|
||||||
Surface {
|
|
||||||
RelatedItems(requireArguments().serializable<StreamInfo>(KEY_INFO)!!)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
@JvmStatic
|
|
||||||
fun getInstance(info: StreamInfo) = RelatedItemsFragment().apply {
|
|
||||||
arguments = bundleOf(KEY_INFO to info)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package org.schabi.newpipe.fragments.list.videos;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.extractor.InfoItem;
|
||||||
|
import org.schabi.newpipe.extractor.ListInfo;
|
||||||
|
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
|
||||||
|
public final class RelatedItemsInfo extends ListInfo<InfoItem> {
|
||||||
|
/**
|
||||||
|
* This class is used to wrap the related items of a StreamInfo into a ListInfo object.
|
||||||
|
*
|
||||||
|
* @param info the stream info from which to get related items
|
||||||
|
*/
|
||||||
|
public RelatedItemsInfo(final StreamInfo info) {
|
||||||
|
super(info.getServiceId(), new ListLinkHandler(info.getOriginalUrl(), info.getUrl(),
|
||||||
|
info.getId(), Collections.emptyList(), null), info.getName());
|
||||||
|
setRelatedItems(new ArrayList<>(info.getRelatedItems()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import org.schabi.newpipe.info_list.holder.ChannelCardInfoItemHolder;
|
|||||||
import org.schabi.newpipe.info_list.holder.ChannelGridInfoItemHolder;
|
import org.schabi.newpipe.info_list.holder.ChannelGridInfoItemHolder;
|
||||||
import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder;
|
import org.schabi.newpipe.info_list.holder.ChannelInfoItemHolder;
|
||||||
import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder;
|
import org.schabi.newpipe.info_list.holder.ChannelMiniInfoItemHolder;
|
||||||
|
import org.schabi.newpipe.info_list.holder.CommentInfoItemHolder;
|
||||||
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.PlaylistCardInfoItemHolder;
|
||||||
import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder;
|
import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder;
|
||||||
@@ -282,32 +283,46 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
|
|||||||
Log.d(TAG, "onCreateViewHolder() called with: "
|
Log.d(TAG, "onCreateViewHolder() called with: "
|
||||||
+ "parent = [" + parent + "], type = [" + type + "]");
|
+ "parent = [" + parent + "], type = [" + type + "]");
|
||||||
}
|
}
|
||||||
return switch (type) {
|
switch (type) {
|
||||||
// #4475 and #3368
|
// #4475 and #3368
|
||||||
// Always create a new instance otherwise the same instance
|
// Always create a new instance otherwise the same instance
|
||||||
// is sometimes reused which causes a crash
|
// is sometimes reused which causes a crash
|
||||||
case HEADER_TYPE -> new HFHolder(headerSupplier.get());
|
case HEADER_TYPE:
|
||||||
case FOOTER_TYPE -> new HFHolder(PignateFooterBinding
|
return new HFHolder(headerSupplier.get());
|
||||||
.inflate(layoutInflater, parent, false)
|
case FOOTER_TYPE:
|
||||||
.getRoot()
|
return new HFHolder(PignateFooterBinding
|
||||||
);
|
.inflate(layoutInflater, parent, false)
|
||||||
case MINI_STREAM_HOLDER_TYPE -> new StreamMiniInfoItemHolder(infoItemBuilder, parent);
|
.getRoot()
|
||||||
case STREAM_HOLDER_TYPE -> new StreamInfoItemHolder(infoItemBuilder, parent);
|
);
|
||||||
case GRID_STREAM_HOLDER_TYPE -> new StreamGridInfoItemHolder(infoItemBuilder, parent);
|
case MINI_STREAM_HOLDER_TYPE:
|
||||||
case CARD_STREAM_HOLDER_TYPE -> new StreamCardInfoItemHolder(infoItemBuilder, parent);
|
return new StreamMiniInfoItemHolder(infoItemBuilder, parent);
|
||||||
case MINI_CHANNEL_HOLDER_TYPE -> new ChannelMiniInfoItemHolder(infoItemBuilder, parent);
|
case STREAM_HOLDER_TYPE:
|
||||||
case CHANNEL_HOLDER_TYPE -> new ChannelInfoItemHolder(infoItemBuilder, parent);
|
return new StreamInfoItemHolder(infoItemBuilder, parent);
|
||||||
case CARD_CHANNEL_HOLDER_TYPE -> new ChannelCardInfoItemHolder(infoItemBuilder, parent);
|
case GRID_STREAM_HOLDER_TYPE:
|
||||||
case GRID_CHANNEL_HOLDER_TYPE -> new ChannelGridInfoItemHolder(infoItemBuilder, parent);
|
return new StreamGridInfoItemHolder(infoItemBuilder, parent);
|
||||||
case MINI_PLAYLIST_HOLDER_TYPE ->
|
case CARD_STREAM_HOLDER_TYPE:
|
||||||
new PlaylistMiniInfoItemHolder(infoItemBuilder, parent);
|
return new StreamCardInfoItemHolder(infoItemBuilder, parent);
|
||||||
case PLAYLIST_HOLDER_TYPE -> new PlaylistInfoItemHolder(infoItemBuilder, parent);
|
case MINI_CHANNEL_HOLDER_TYPE:
|
||||||
case GRID_PLAYLIST_HOLDER_TYPE ->
|
return new ChannelMiniInfoItemHolder(infoItemBuilder, parent);
|
||||||
new PlaylistGridInfoItemHolder(infoItemBuilder, parent);
|
case CHANNEL_HOLDER_TYPE:
|
||||||
case CARD_PLAYLIST_HOLDER_TYPE ->
|
return new ChannelInfoItemHolder(infoItemBuilder, parent);
|
||||||
new PlaylistCardInfoItemHolder(infoItemBuilder, parent);
|
case CARD_CHANNEL_HOLDER_TYPE:
|
||||||
default -> new FallbackViewHolder(new View(parent.getContext()));
|
return new ChannelCardInfoItemHolder(infoItemBuilder, parent);
|
||||||
};
|
case GRID_CHANNEL_HOLDER_TYPE:
|
||||||
|
return new ChannelGridInfoItemHolder(infoItemBuilder, parent);
|
||||||
|
case MINI_PLAYLIST_HOLDER_TYPE:
|
||||||
|
return new PlaylistMiniInfoItemHolder(infoItemBuilder, parent);
|
||||||
|
case PLAYLIST_HOLDER_TYPE:
|
||||||
|
return new PlaylistInfoItemHolder(infoItemBuilder, parent);
|
||||||
|
case GRID_PLAYLIST_HOLDER_TYPE:
|
||||||
|
return new PlaylistGridInfoItemHolder(infoItemBuilder, parent);
|
||||||
|
case CARD_PLAYLIST_HOLDER_TYPE:
|
||||||
|
return new PlaylistCardInfoItemHolder(infoItemBuilder, parent);
|
||||||
|
case COMMENT_HOLDER_TYPE:
|
||||||
|
return new CommentInfoItemHolder(infoItemBuilder, parent);
|
||||||
|
default:
|
||||||
|
return new FallbackViewHolder(new View(parent.getContext()));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -252,7 +252,7 @@ public final class InfoItemDialog {
|
|||||||
* @return the current {@link Builder} instance
|
* @return the current {@link Builder} instance
|
||||||
*/
|
*/
|
||||||
public Builder addEnqueueEntriesIfNeeded() {
|
public Builder addEnqueueEntriesIfNeeded() {
|
||||||
final PlayerHolder holder = PlayerHolder.INSTANCE;
|
final PlayerHolder holder = PlayerHolder.getInstance();
|
||||||
if (holder.isPlayQueueReady()) {
|
if (holder.isPlayQueueReady()) {
|
||||||
addEntry(StreamDialogDefaultEntry.ENQUEUE);
|
addEntry(StreamDialogDefaultEntry.ENQUEUE);
|
||||||
|
|
||||||
|
|||||||
@@ -44,11 +44,10 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
|||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
public enum StreamDialogDefaultEntry {
|
public enum StreamDialogDefaultEntry {
|
||||||
SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) -> {
|
SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) ->
|
||||||
final var activity = fragment.requireActivity();
|
fetchUploaderUrlIfSparse(fragment.requireContext(), item.getServiceId(), item.getUrl(),
|
||||||
fetchUploaderUrlIfSparse(activity, item.getServiceId(), item.getUrl(),
|
item.getUploaderUrl(), url -> openChannelFragment(fragment, item, url))
|
||||||
item.getUploaderUrl(), url -> openChannelFragment(activity, item, url));
|
),
|
||||||
}),
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Enqueues the stream automatically to the current PlayerType.
|
* Enqueues the stream automatically to the current PlayerType.
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package org.schabi.newpipe.info_list.holder;
|
||||||
|
|
||||||
|
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
|
||||||
|
import static org.schabi.newpipe.util.text.TouchUtils.getOffsetForHorizontalLine;
|
||||||
|
|
||||||
|
import android.text.Spanned;
|
||||||
|
import android.text.method.LinkMovementMethod;
|
||||||
|
import android.text.style.ClickableSpan;
|
||||||
|
import android.text.style.URLSpan;
|
||||||
|
import android.view.MotionEvent;
|
||||||
|
import android.view.View;
|
||||||
|
import android.view.ViewGroup;
|
||||||
|
import android.widget.Button;
|
||||||
|
import android.widget.ImageView;
|
||||||
|
import android.widget.RelativeLayout;
|
||||||
|
import android.widget.TextView;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.fragment.app.FragmentActivity;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.R;
|
||||||
|
import org.schabi.newpipe.extractor.InfoItem;
|
||||||
|
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
|
||||||
|
import org.schabi.newpipe.info_list.InfoItemBuilder;
|
||||||
|
import org.schabi.newpipe.local.history.HistoryRecordManager;
|
||||||
|
import org.schabi.newpipe.util.DeviceUtils;
|
||||||
|
import org.schabi.newpipe.util.Localization;
|
||||||
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
|
import org.schabi.newpipe.util.external_communication.ShareUtils;
|
||||||
|
import org.schabi.newpipe.util.image.CoilHelper;
|
||||||
|
import org.schabi.newpipe.util.image.ImageStrategy;
|
||||||
|
import org.schabi.newpipe.util.text.TextEllipsizer;
|
||||||
|
|
||||||
|
public class CommentInfoItemHolder extends InfoItemHolder {
|
||||||
|
|
||||||
|
private static final int COMMENT_DEFAULT_LINES = 2;
|
||||||
|
private final int commentHorizontalPadding;
|
||||||
|
private final int commentVerticalPadding;
|
||||||
|
|
||||||
|
private final RelativeLayout itemRoot;
|
||||||
|
private final ImageView itemThumbnailView;
|
||||||
|
private final TextView itemContentView;
|
||||||
|
private final ImageView itemThumbsUpView;
|
||||||
|
private final TextView itemLikesCountView;
|
||||||
|
private final TextView itemTitleView;
|
||||||
|
private final ImageView itemHeartView;
|
||||||
|
private final ImageView itemPinnedView;
|
||||||
|
private final Button repliesButton;
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private final TextEllipsizer textEllipsizer;
|
||||||
|
|
||||||
|
public CommentInfoItemHolder(final InfoItemBuilder infoItemBuilder,
|
||||||
|
final ViewGroup parent) {
|
||||||
|
super(infoItemBuilder, R.layout.list_comment_item, parent);
|
||||||
|
|
||||||
|
itemRoot = itemView.findViewById(R.id.itemRoot);
|
||||||
|
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
|
||||||
|
itemContentView = itemView.findViewById(R.id.itemCommentContentView);
|
||||||
|
itemThumbsUpView = itemView.findViewById(R.id.detail_thumbs_up_img_view);
|
||||||
|
itemLikesCountView = itemView.findViewById(R.id.detail_thumbs_up_count_view);
|
||||||
|
itemTitleView = itemView.findViewById(R.id.itemTitleView);
|
||||||
|
itemHeartView = itemView.findViewById(R.id.detail_heart_image_view);
|
||||||
|
itemPinnedView = itemView.findViewById(R.id.detail_pinned_view);
|
||||||
|
repliesButton = itemView.findViewById(R.id.replies_button);
|
||||||
|
|
||||||
|
commentHorizontalPadding = (int) infoItemBuilder.getContext()
|
||||||
|
.getResources().getDimension(R.dimen.comments_horizontal_padding);
|
||||||
|
commentVerticalPadding = (int) infoItemBuilder.getContext()
|
||||||
|
.getResources().getDimension(R.dimen.comments_vertical_padding);
|
||||||
|
|
||||||
|
textEllipsizer = new TextEllipsizer(itemContentView, COMMENT_DEFAULT_LINES, null);
|
||||||
|
textEllipsizer.setStateChangeListener(isEllipsized -> {
|
||||||
|
if (Boolean.TRUE.equals(isEllipsized)) {
|
||||||
|
denyLinkFocus();
|
||||||
|
} else {
|
||||||
|
determineMovementMethod();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updateFromItem(final InfoItem infoItem,
|
||||||
|
final HistoryRecordManager historyRecordManager) {
|
||||||
|
if (!(infoItem instanceof CommentsInfoItem item)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// load the author avatar
|
||||||
|
CoilHelper.INSTANCE.loadAvatar(itemThumbnailView, item.getUploaderAvatars());
|
||||||
|
if (ImageStrategy.shouldLoadImages()) {
|
||||||
|
itemThumbnailView.setVisibility(View.VISIBLE);
|
||||||
|
itemRoot.setPadding(commentVerticalPadding, commentVerticalPadding,
|
||||||
|
commentVerticalPadding, commentVerticalPadding);
|
||||||
|
} else {
|
||||||
|
itemThumbnailView.setVisibility(View.GONE);
|
||||||
|
itemRoot.setPadding(commentHorizontalPadding, commentVerticalPadding,
|
||||||
|
commentHorizontalPadding, commentVerticalPadding);
|
||||||
|
}
|
||||||
|
itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item));
|
||||||
|
|
||||||
|
// setup the top row, with pinned icon, author name and comment date
|
||||||
|
itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
|
||||||
|
final String uploaderName = Localization.localizeUserName(item.getUploaderName());
|
||||||
|
itemTitleView.setText(Localization.concatenateStrings(
|
||||||
|
uploaderName,
|
||||||
|
Localization.relativeTimeOrTextual(
|
||||||
|
itemBuilder.getContext(),
|
||||||
|
item.getUploadDate(),
|
||||||
|
item.getTextualUploadDate())));
|
||||||
|
|
||||||
|
// setup bottom row, with likes, heart and replies button
|
||||||
|
itemLikesCountView.setText(
|
||||||
|
Localization.likeCount(itemBuilder.getContext(), item.getLikeCount()));
|
||||||
|
|
||||||
|
itemHeartView.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE);
|
||||||
|
|
||||||
|
final boolean hasReplies = item.getReplies() != null;
|
||||||
|
repliesButton.setOnClickListener(hasReplies ? v -> openCommentReplies(item) : null);
|
||||||
|
repliesButton.setVisibility(hasReplies ? View.VISIBLE : View.GONE);
|
||||||
|
repliesButton.setText(hasReplies
|
||||||
|
? Localization.replyCount(itemBuilder.getContext(), item.getReplyCount()) : "");
|
||||||
|
((RelativeLayout.LayoutParams) itemThumbsUpView.getLayoutParams()).topMargin =
|
||||||
|
hasReplies ? 0 : DeviceUtils.dpToPx(6, itemBuilder.getContext());
|
||||||
|
|
||||||
|
|
||||||
|
// setup comment content and click listeners to expand/ellipsize it
|
||||||
|
textEllipsizer.setStreamingService(getServiceById(item.getServiceId()));
|
||||||
|
textEllipsizer.setStreamUrl(item.getUrl());
|
||||||
|
textEllipsizer.setContent(item.getCommentText());
|
||||||
|
textEllipsizer.ellipsize();
|
||||||
|
|
||||||
|
//noinspection ClickableViewAccessibility
|
||||||
|
itemContentView.setOnTouchListener((v, event) -> {
|
||||||
|
final CharSequence text = itemContentView.getText();
|
||||||
|
if (text instanceof Spanned buffer) {
|
||||||
|
final int action = event.getAction();
|
||||||
|
|
||||||
|
if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
|
||||||
|
final int offset = getOffsetForHorizontalLine(itemContentView, event);
|
||||||
|
final var links = buffer.getSpans(offset, offset, ClickableSpan.class);
|
||||||
|
|
||||||
|
if (links.length != 0) {
|
||||||
|
if (action == MotionEvent.ACTION_UP) {
|
||||||
|
links[0].onClick(itemContentView);
|
||||||
|
}
|
||||||
|
// we handle events that intersect links, so return true
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
itemView.setOnClickListener(view -> {
|
||||||
|
textEllipsizer.toggle();
|
||||||
|
if (itemBuilder.getOnCommentsSelectedListener() != null) {
|
||||||
|
itemBuilder.getOnCommentsSelectedListener().selected(item);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
itemView.setOnLongClickListener(view -> {
|
||||||
|
if (DeviceUtils.isTv(itemBuilder.getContext())) {
|
||||||
|
openCommentAuthor(item);
|
||||||
|
} else {
|
||||||
|
final CharSequence text = itemContentView.getText();
|
||||||
|
if (text != null) {
|
||||||
|
ShareUtils.copyToClipboard(itemBuilder.getContext(), text.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openCommentAuthor(@NonNull final CommentsInfoItem item) {
|
||||||
|
NavigationHelper.openCommentAuthorIfPresent((FragmentActivity) itemBuilder.getContext(),
|
||||||
|
item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void openCommentReplies(@NonNull final CommentsInfoItem item) {
|
||||||
|
NavigationHelper.openCommentRepliesFragment((FragmentActivity) itemBuilder.getContext(),
|
||||||
|
item);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void allowLinkFocus() {
|
||||||
|
itemContentView.setMovementMethod(LinkMovementMethod.getInstance());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void denyLinkFocus() {
|
||||||
|
itemContentView.setMovementMethod(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean shouldFocusLinks() {
|
||||||
|
if (itemView.isInTouchMode()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final URLSpan[] urls = itemContentView.getUrls();
|
||||||
|
|
||||||
|
return urls != null && urls.length != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void determineMovementMethod() {
|
||||||
|
if (shouldFocusLinks()) {
|
||||||
|
allowLinkFocus();
|
||||||
|
} else {
|
||||||
|
denyLinkFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -64,7 +64,8 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
|||||||
StreamStateEntity state2 = null;
|
StreamStateEntity state2 = null;
|
||||||
if (DependentPreferenceHelper
|
if (DependentPreferenceHelper
|
||||||
.getPositionsInListsEnabled(itemProgressView.getContext())) {
|
.getPositionsInListsEnabled(itemProgressView.getContext())) {
|
||||||
state2 = historyRecordManager.loadStreamState(infoItem).blockingGet();
|
state2 = historyRecordManager.loadStreamState(infoItem)
|
||||||
|
.blockingGet()[0];
|
||||||
}
|
}
|
||||||
if (state2 != null) {
|
if (state2 != null) {
|
||||||
itemProgressView.setVisibility(View.VISIBLE);
|
itemProgressView.setVisibility(View.VISIBLE);
|
||||||
@@ -119,7 +120,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
|
|||||||
if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())) {
|
if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())) {
|
||||||
state = historyRecordManager
|
state = historyRecordManager
|
||||||
.loadStreamState(infoItem)
|
.loadStreamState(infoItem)
|
||||||
.blockingGet();
|
.blockingGet()[0];
|
||||||
}
|
}
|
||||||
if (state != null && item.getDuration() > 0
|
if (state != null && item.getDuration() > 0
|
||||||
&& !StreamTypeUtil.isLiveStream(item.getStreamType())) {
|
&& !StreamTypeUtil.isLiveStream(item.getStreamType())) {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
package org.schabi.newpipe.ktx
|
package org.schabi.newpipe.ktx
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Parcelable
|
||||||
import androidx.core.os.BundleCompat
|
import androidx.core.os.BundleCompat
|
||||||
import java.io.Serializable
|
|
||||||
|
|
||||||
inline fun <reified T : Serializable> Bundle.serializable(key: String?): T? {
|
inline fun <reified T : Parcelable> Bundle.parcelableArrayList(key: String?): ArrayList<T>? {
|
||||||
return BundleCompat.getSerializable(this, key, T::class.java)
|
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun Bundle?.toDebugString(): String {
|
fun Bundle?.toDebugString(): String {
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
package org.schabi.newpipe.ktx
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.ContextWrapper
|
|
||||||
import androidx.fragment.app.FragmentActivity
|
|
||||||
|
|
||||||
tailrec fun Context.findFragmentActivity(): FragmentActivity {
|
|
||||||
return when (this) {
|
|
||||||
is FragmentActivity -> this
|
|
||||||
is ContextWrapper -> baseContext.findFragmentActivity()
|
|
||||||
else -> throw IllegalStateException("Unable to find FragmentActivity")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ import android.view.ViewGroup;
|
|||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.appcompat.app.AlertDialog;
|
import androidx.appcompat.app.AlertDialog;
|
||||||
import androidx.compose.ui.platform.ComposeView;
|
|
||||||
import androidx.fragment.app.FragmentManager;
|
import androidx.fragment.app.FragmentManager;
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper;
|
import androidx.recyclerview.widget.ItemTouchHelper;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
@@ -40,8 +39,6 @@ import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder;
|
|||||||
import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder;
|
import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder;
|
||||||
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
|
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
|
||||||
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
|
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
|
||||||
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
|
|
||||||
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
|
|
||||||
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 org.schabi.newpipe.util.debounce.DebounceSavable;
|
import org.schabi.newpipe.util.debounce.DebounceSavable;
|
||||||
@@ -127,8 +124,6 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
|
|||||||
super.initViews(rootView, savedInstanceState);
|
super.initViews(rootView, savedInstanceState);
|
||||||
|
|
||||||
itemListAdapter.setUseItemHandle(true);
|
itemListAdapter.setUseItemHandle(true);
|
||||||
final ComposeView emptyView = rootView.findViewById(R.id.empty_state_view);
|
|
||||||
EmptyStateUtil.setEmptyStateComposable(emptyView, EmptyStateSpec.NoBookmarkedPlaylist);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ import org.schabi.newpipe.ktx.slideUp
|
|||||||
import org.schabi.newpipe.local.feed.item.StreamItem
|
import org.schabi.newpipe.local.feed.item.StreamItem
|
||||||
import org.schabi.newpipe.local.feed.service.FeedLoadService
|
import org.schabi.newpipe.local.feed.service.FeedLoadService
|
||||||
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
import org.schabi.newpipe.local.subscription.SubscriptionManager
|
||||||
import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable
|
|
||||||
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
|
||||||
@@ -136,7 +135,6 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||||||
override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) {
|
override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) {
|
||||||
// super.onViewCreated() calls initListeners() which require the binding to be initialized
|
// super.onViewCreated() calls initListeners() which require the binding to be initialized
|
||||||
_feedBinding = FragmentFeedBinding.bind(rootView)
|
_feedBinding = FragmentFeedBinding.bind(rootView)
|
||||||
feedBinding.emptyStateView.setEmptyStateComposable()
|
|
||||||
super.onViewCreated(rootView, savedInstanceState)
|
super.onViewCreated(rootView, savedInstanceState)
|
||||||
|
|
||||||
val factory = FeedViewModel.getFactory(requireContext(), groupId)
|
val factory = FeedViewModel.getFactory(requireContext(), groupId)
|
||||||
@@ -206,7 +204,6 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||||||
// Menu
|
// Menu
|
||||||
// /////////////////////////////////////////////////////////////////////////
|
// /////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
@Deprecated("Deprecated in Java")
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
super.onCreateOptionsMenu(menu, inflater)
|
super.onCreateOptionsMenu(menu, inflater)
|
||||||
|
|
||||||
@@ -217,7 +214,6 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||||||
inflater.inflate(R.menu.menu_feed_fragment, menu)
|
inflater.inflate(R.menu.menu_feed_fragment, menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("Deprecated in Java")
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
if (item.itemId == R.id.menu_item_feed_help) {
|
if (item.itemId == R.id.menu_item_feed_help) {
|
||||||
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext())
|
||||||
@@ -273,7 +269,6 @@ class FeedFragment : BaseStateFragment<FeedState>() {
|
|||||||
.show()
|
.show()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("Deprecated in Java")
|
|
||||||
override fun onDestroyOptionsMenu() {
|
override fun onDestroyOptionsMenu() {
|
||||||
super.onDestroyOptionsMenu()
|
super.onDestroyOptionsMenu()
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -76,7 +76,6 @@ class NotificationHelper(val context: Context) {
|
|||||||
|
|
||||||
val avatarIcon =
|
val avatarIcon =
|
||||||
CoilHelper.loadBitmapBlocking(context, data.avatarUrl, R.drawable.ic_newpipe_triangle_white)
|
CoilHelper.loadBitmapBlocking(context, data.avatarUrl, R.drawable.ic_newpipe_triangle_white)
|
||||||
|
|
||||||
summaryBuilder.setLargeIcon(avatarIcon)
|
summaryBuilder.setLargeIcon(avatarIcon)
|
||||||
|
|
||||||
// Show individual stream notifications, set channel icon only if there is actually one
|
// Show individual stream notifications, set channel icon only if there is actually one
|
||||||
|
|||||||
@@ -18,13 +18,10 @@ package org.schabi.newpipe.local.history;
|
|||||||
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import static org.schabi.newpipe.util.ExtractorHelper.getStreamInfo;
|
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.collection.LongLongPair;
|
|
||||||
import androidx.preference.PreferenceManager;
|
import androidx.preference.PreferenceManager;
|
||||||
|
|
||||||
import org.schabi.newpipe.NewPipeDatabase;
|
import org.schabi.newpipe.NewPipeDatabase;
|
||||||
@@ -48,6 +45,7 @@ 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.local.feed.FeedViewModel;
|
import org.schabi.newpipe.local.feed.FeedViewModel;
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
|
||||||
|
import org.schabi.newpipe.util.ExtractorHelper;
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
@@ -93,39 +91,47 @@ public class HistoryRecordManager {
|
|||||||
* @param info the item to mark as watched
|
* @param info the item to mark as watched
|
||||||
* @return a Maybe containing the ID of the item if successful
|
* @return a Maybe containing the ID of the item if successful
|
||||||
*/
|
*/
|
||||||
public Completable markAsWatched(final StreamInfoItem info) {
|
public Maybe<Long> markAsWatched(final StreamInfoItem info) {
|
||||||
if (!isStreamHistoryEnabled()) {
|
if (!isStreamHistoryEnabled()) {
|
||||||
return Completable.complete();
|
return Maybe.empty();
|
||||||
}
|
}
|
||||||
|
|
||||||
final var remoteInfo = getStreamInfo(info.getServiceId(), info.getUrl(), false)
|
final OffsetDateTime currentTime = OffsetDateTime.now(ZoneOffset.UTC);
|
||||||
.map(item ->
|
return Maybe.fromCallable(() -> database.runInTransaction(() -> {
|
||||||
new LongLongPair(item.getDuration(), streamTable.upsert(new StreamEntity(item))));
|
final long streamId;
|
||||||
|
final long duration;
|
||||||
return Single.just(info)
|
// Duration will not exist if the item was loaded with fast mode, so fetch it if empty
|
||||||
.filter(item -> item.getDuration() >= 0)
|
if (info.getDuration() < 0) {
|
||||||
.map(item ->
|
final StreamInfo completeInfo = ExtractorHelper.getStreamInfo(
|
||||||
new LongLongPair(item.getDuration(), streamTable.upsert(new StreamEntity(item)))
|
info.getServiceId(),
|
||||||
|
info.getUrl(),
|
||||||
|
false
|
||||||
)
|
)
|
||||||
.switchIfEmpty(remoteInfo)
|
.subscribeOn(Schedulers.io())
|
||||||
.flatMapCompletable(pair -> Completable.fromRunnable(() -> {
|
.blockingGet();
|
||||||
final long duration = pair.getFirst();
|
duration = completeInfo.getDuration();
|
||||||
final long streamId = pair.getSecond();
|
streamId = streamTable.upsert(new StreamEntity(completeInfo));
|
||||||
|
} else {
|
||||||
|
duration = info.getDuration();
|
||||||
|
streamId = streamTable.upsert(new StreamEntity(info));
|
||||||
|
}
|
||||||
|
|
||||||
// Update the stream progress to the full duration of the video
|
// Update the stream progress to the full duration of the video
|
||||||
final var entity = new StreamStateEntity(streamId, duration * 1000);
|
final StreamStateEntity entity = new StreamStateEntity(
|
||||||
streamStateTable.upsert(entity);
|
streamId,
|
||||||
|
duration * 1000
|
||||||
|
);
|
||||||
|
streamStateTable.upsert(entity);
|
||||||
|
|
||||||
// Add a history entry
|
// Add a history entry
|
||||||
final var latestEntry = streamHistoryTable.getLatestEntry(streamId);
|
final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId);
|
||||||
if (latestEntry == null) {
|
if (latestEntry == null) {
|
||||||
final var currentTime = OffsetDateTime.now(ZoneOffset.UTC);
|
// never actually viewed: add history entry but with 0 views
|
||||||
// never actually viewed: add history entry but with 0 views
|
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 0));
|
||||||
final var entry = new StreamHistoryEntity(streamId, currentTime, 0);
|
} else {
|
||||||
streamHistoryTable.insert(entry);
|
return 0L;
|
||||||
}
|
}
|
||||||
}))
|
})).subscribeOn(Schedulers.io());
|
||||||
.subscribeOn(Schedulers.io());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Maybe<Long> onViewed(final StreamInfo info) {
|
public Maybe<Long> onViewed(final StreamInfo info) {
|
||||||
@@ -215,7 +221,7 @@ public class HistoryRecordManager {
|
|||||||
public Flowable<List<String>> getRelatedSearches(final String query,
|
public Flowable<List<String>> getRelatedSearches(final String query,
|
||||||
final int similarQueryLimit,
|
final int similarQueryLimit,
|
||||||
final int uniqueQueryLimit) {
|
final int uniqueQueryLimit) {
|
||||||
return !query.isEmpty()
|
return query.length() > 0
|
||||||
? searchHistoryTable.getSimilarEntries(query, similarQueryLimit)
|
? searchHistoryTable.getSimilarEntries(query, similarQueryLimit)
|
||||||
: searchHistoryTable.getUniqueEntries(uniqueQueryLimit);
|
: searchHistoryTable.getUniqueEntries(uniqueQueryLimit);
|
||||||
}
|
}
|
||||||
@@ -230,31 +236,47 @@ public class HistoryRecordManager {
|
|||||||
|
|
||||||
public Maybe<StreamStateEntity> loadStreamState(final PlayQueueItem queueItem) {
|
public Maybe<StreamStateEntity> loadStreamState(final PlayQueueItem queueItem) {
|
||||||
return queueItem.getStream()
|
return queueItem.getStream()
|
||||||
.flatMapMaybe(this::loadStreamState)
|
.map(info -> streamTable.upsert(new StreamEntity(info)))
|
||||||
|
.flatMapPublisher(streamStateTable::getState)
|
||||||
|
.firstElement()
|
||||||
|
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0)))
|
||||||
.filter(state -> state.isValid(queueItem.getDuration()))
|
.filter(state -> state.isValid(queueItem.getDuration()))
|
||||||
.subscribeOn(Schedulers.io());
|
.subscribeOn(Schedulers.io());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Maybe<StreamStateEntity> loadStreamState(final StreamInfo info) {
|
public Maybe<StreamStateEntity> loadStreamState(final StreamInfo info) {
|
||||||
return Single.fromCallable(() -> streamTable.upsert(new StreamEntity(info)))
|
return Single.fromCallable(() -> streamTable.upsert(new StreamEntity(info)))
|
||||||
.flatMapMaybe(streamStateTable::getState)
|
.flatMapPublisher(streamStateTable::getState)
|
||||||
|
.firstElement()
|
||||||
|
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0)))
|
||||||
|
.filter(state -> state.isValid(info.getDuration()))
|
||||||
.subscribeOn(Schedulers.io());
|
.subscribeOn(Schedulers.io());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Completable saveStreamState(@NonNull final StreamInfo info, final long progressMillis) {
|
public Completable saveStreamState(@NonNull final StreamInfo info, final long progressMillis) {
|
||||||
return Completable.fromAction(() -> database.runInTransaction(() -> {
|
return Completable.fromAction(() -> database.runInTransaction(() -> {
|
||||||
final long streamId = streamTable.upsert(new StreamEntity(info));
|
final long streamId = streamTable.upsert(new StreamEntity(info));
|
||||||
final var state = new StreamStateEntity(streamId, progressMillis);
|
final StreamStateEntity state = new StreamStateEntity(streamId, progressMillis);
|
||||||
if (state.isValid(info.getDuration())) {
|
if (state.isValid(info.getDuration())) {
|
||||||
streamStateTable.upsert(state);
|
streamStateTable.upsert(state);
|
||||||
}
|
}
|
||||||
})).subscribeOn(Schedulers.io());
|
})).subscribeOn(Schedulers.io());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Maybe<StreamStateEntity> loadStreamState(final InfoItem info) {
|
public Single<StreamStateEntity[]> loadStreamState(final InfoItem info) {
|
||||||
return streamTable.getStream(info.getServiceId(), info.getUrl())
|
return Single.fromCallable(() -> {
|
||||||
.flatMap(entity -> streamStateTable.getState(entity.getUid()))
|
final List<StreamEntity> entities = streamTable
|
||||||
.subscribeOn(Schedulers.io());
|
.getStream(info.getServiceId(), info.getUrl()).blockingFirst();
|
||||||
|
if (entities.isEmpty()) {
|
||||||
|
return new StreamStateEntity[]{null};
|
||||||
|
}
|
||||||
|
final List<StreamStateEntity> states = streamStateTable
|
||||||
|
.getState(entities.get(0).getUid()).blockingFirst();
|
||||||
|
if (states.isEmpty()) {
|
||||||
|
return new StreamStateEntity[]{null};
|
||||||
|
}
|
||||||
|
return new StreamStateEntity[]{states.get(0)};
|
||||||
|
}).subscribeOn(Schedulers.io());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Single<List<StreamStateEntity>> loadLocalStreamStateBatch(
|
public Single<List<StreamStateEntity>> loadLocalStreamStateBatch(
|
||||||
@@ -273,7 +295,13 @@ public class HistoryRecordManager {
|
|||||||
result.add(null);
|
result.add(null);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
result.add(streamStateTable.getState(streamId).blockingGet());
|
final List<StreamStateEntity> states = streamStateTable.getState(streamId)
|
||||||
|
.blockingFirst();
|
||||||
|
if (states.isEmpty()) {
|
||||||
|
result.add(null);
|
||||||
|
} else {
|
||||||
|
result.add(states.get(0));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}).subscribeOn(Schedulers.io());
|
}).subscribeOn(Schedulers.io());
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ 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.item.ImportSubscriptionsHintPlaceholderItem
|
||||||
import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable
|
|
||||||
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 org.schabi.newpipe.util.ServiceHelper
|
import org.schabi.newpipe.util.ServiceHelper
|
||||||
@@ -114,7 +113,6 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
|||||||
// Menu
|
// Menu
|
||||||
// ////////////////////////////////////////////////////////////////////////
|
// ////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
@Deprecated("Deprecated in Java")
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
super.onCreateOptionsMenu(menu, inflater)
|
super.onCreateOptionsMenu(menu, inflater)
|
||||||
|
|
||||||
@@ -201,8 +199,6 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
|
|||||||
binding.itemsList.adapter = groupAdapter
|
binding.itemsList.adapter = groupAdapter
|
||||||
binding.itemsList.itemAnimator = null
|
binding.itemsList.itemAnimator = null
|
||||||
|
|
||||||
binding.emptyStateView.setEmptyStateComposable()
|
|
||||||
|
|
||||||
viewModel = ViewModelProvider(this)[SubscriptionViewModel::class.java]
|
viewModel = ViewModelProvider(this)[SubscriptionViewModel::class.java]
|
||||||
viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(this::handleResult) }
|
viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(this::handleResult) }
|
||||||
viewModel.feedGroupsLiveData.observe(viewLifecycleOwner) {
|
viewModel.feedGroupsLiveData.observe(viewLifecycleOwner) {
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
|
|||||||
|
|
||||||
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
|
||||||
return object : Dialog(requireActivity(), theme) {
|
return object : Dialog(requireActivity(), theme) {
|
||||||
@Deprecated("Deprecated in Java")
|
|
||||||
override fun onBackPressed() {
|
override fun onBackPressed() {
|
||||||
if (!this@FeedGroupDialog.onBackPressed()) {
|
if (!this@FeedGroupDialog.onBackPressed()) {
|
||||||
super.onBackPressed()
|
super.onBackPressed()
|
||||||
|
|||||||
@@ -3,18 +3,14 @@ package org.schabi.newpipe.local.subscription.item
|
|||||||
import android.view.View
|
import android.view.View
|
||||||
import com.xwray.groupie.viewbinding.BindableItem
|
import com.xwray.groupie.viewbinding.BindableItem
|
||||||
import org.schabi.newpipe.R
|
import org.schabi.newpipe.R
|
||||||
import org.schabi.newpipe.databinding.ListEmptyViewSubscriptionsBinding
|
import org.schabi.newpipe.databinding.ListEmptyViewBinding
|
||||||
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec
|
|
||||||
import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* When there are no subscriptions, show a hint to the user about how to import subscriptions
|
* When there are no subscriptions, show a hint to the user about how to import subscriptions
|
||||||
*/
|
*/
|
||||||
class ImportSubscriptionsHintPlaceholderItem : BindableItem<ListEmptyViewSubscriptionsBinding>() {
|
class ImportSubscriptionsHintPlaceholderItem : BindableItem<ListEmptyViewBinding>() {
|
||||||
override fun getLayout(): Int = R.layout.list_empty_view_subscriptions
|
override fun getLayout(): Int = R.layout.list_empty_view_subscriptions
|
||||||
override fun bind(viewBinding: ListEmptyViewSubscriptionsBinding, position: Int) {
|
override fun bind(viewBinding: ListEmptyViewBinding, position: Int) {}
|
||||||
viewBinding.root.setEmptyStateComposable(EmptyStateSpec.NoSubscriptionsHint)
|
|
||||||
}
|
|
||||||
override fun getSpanSize(spanCount: Int, position: Int): Int = spanCount
|
override fun getSpanSize(spanCount: Int, position: Int): Int = spanCount
|
||||||
override fun initializeViewBinding(view: View) = ListEmptyViewSubscriptionsBinding.bind(view)
|
override fun initializeViewBinding(view: View) = ListEmptyViewBinding.bind(view)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
package org.schabi.newpipe.paging
|
|
||||||
|
|
||||||
import androidx.paging.PagingSource
|
|
||||||
import androidx.paging.PagingState
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.schabi.newpipe.extractor.NewPipe
|
|
||||||
import org.schabi.newpipe.extractor.Page
|
|
||||||
import org.schabi.newpipe.extractor.comments.CommentsInfo
|
|
||||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
|
|
||||||
|
|
||||||
class CommentRepliesSource(
|
|
||||||
private val commentInfo: CommentsInfoItem
|
|
||||||
) : PagingSource<Page, CommentsInfoItem>() {
|
|
||||||
private val service = NewPipe.getService(commentInfo.serviceId)
|
|
||||||
|
|
||||||
override suspend fun load(params: LoadParams<Page>): LoadResult<Page, CommentsInfoItem> {
|
|
||||||
// params.key is null the first time load() is called, and we need to return the first page
|
|
||||||
val repliesPage = params.key ?: commentInfo.replies
|
|
||||||
val info = withContext(Dispatchers.IO) {
|
|
||||||
CommentsInfo.getMoreItems(service, commentInfo.url, repliesPage)
|
|
||||||
}
|
|
||||||
return LoadResult.Page(info.items, null, info.nextPage)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getRefreshKey(state: PagingState<Page, CommentsInfoItem>) = null
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
package org.schabi.newpipe.paging
|
|
||||||
|
|
||||||
import androidx.paging.PagingSource
|
|
||||||
import androidx.paging.PagingState
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.schabi.newpipe.extractor.NewPipe
|
|
||||||
import org.schabi.newpipe.extractor.Page
|
|
||||||
import org.schabi.newpipe.extractor.comments.CommentsInfo
|
|
||||||
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
|
|
||||||
import org.schabi.newpipe.ui.components.video.comment.CommentInfo
|
|
||||||
|
|
||||||
class CommentsSource(private val commentInfo: CommentInfo) : PagingSource<Page, CommentsInfoItem>() {
|
|
||||||
private val service = NewPipe.getService(commentInfo.serviceId)
|
|
||||||
|
|
||||||
override suspend fun load(params: LoadParams<Page>): LoadResult<Page, CommentsInfoItem> {
|
|
||||||
// params.key is null the first time the load() function is called, so we need to return the
|
|
||||||
// first batch of already-loaded comments
|
|
||||||
|
|
||||||
if (params.key == null) {
|
|
||||||
return LoadResult.Page(commentInfo.comments, null, commentInfo.nextPage)
|
|
||||||
} else {
|
|
||||||
val info = withContext(Dispatchers.IO) {
|
|
||||||
CommentsInfo.getMoreItems(service, commentInfo.url, params.key)
|
|
||||||
}
|
|
||||||
|
|
||||||
return LoadResult.Page(info.items, null, info.nextPage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun getRefreshKey(state: PagingState<Page, CommentsInfoItem>) = null
|
|
||||||
}
|
|
||||||
@@ -95,48 +95,8 @@ public final class PlayQueueActivity extends AppCompatActivity
|
|||||||
getSupportActionBar().setTitle(R.string.title_activity_play_queue);
|
getSupportActionBar().setTitle(R.string.title_activity_play_queue);
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceConnection = new ServiceConnection() {
|
serviceConnection = getServiceConnection();
|
||||||
@Override
|
bind();
|
||||||
public void onServiceDisconnected(final ComponentName name) {
|
|
||||||
Log.d(TAG, "Player service is disconnected");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onServiceConnected(final ComponentName name, final IBinder binder) {
|
|
||||||
Log.d(TAG, "Player service is connected");
|
|
||||||
|
|
||||||
if (binder instanceof PlayerService.LocalBinder) {
|
|
||||||
@Nullable final PlayerService s =
|
|
||||||
((PlayerService.LocalBinder) binder).getService();
|
|
||||||
if (s == null) {
|
|
||||||
throw new IllegalArgumentException(
|
|
||||||
"PlayerService.LocalBinder.getService() must never be"
|
|
||||||
+ "null after the service connects");
|
|
||||||
}
|
|
||||||
player = s.getPlayer();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) {
|
|
||||||
unbind();
|
|
||||||
} else {
|
|
||||||
onQueueUpdate(player.getPlayQueue());
|
|
||||||
buildComponents();
|
|
||||||
if (player != null) {
|
|
||||||
player.setActivityListener(PlayQueueActivity.this);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Note: this code should not really exist, and PlayerHolder should be used instead, but
|
|
||||||
// it will be rewritten when NewPlayer will replace the current player.
|
|
||||||
final Intent bindIntent = new Intent(this, PlayerService.class);
|
|
||||||
bindIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION);
|
|
||||||
final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE);
|
|
||||||
if (!success) {
|
|
||||||
unbindService(serviceConnection);
|
|
||||||
}
|
|
||||||
serviceBound = success;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -218,6 +178,19 @@ public final class PlayQueueActivity extends AppCompatActivity
|
|||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
// Service Connection
|
// Service Connection
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
private void bind() {
|
||||||
|
// Note: this code should not really exist, and PlayerHolder should be used instead, but
|
||||||
|
// it will be rewritten when NewPlayer will replace the current player.
|
||||||
|
final Intent bindIntent = new Intent(this, PlayerService.class);
|
||||||
|
bindIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION);
|
||||||
|
final boolean success = bindService(bindIntent, serviceConnection, BIND_AUTO_CREATE);
|
||||||
|
if (!success) {
|
||||||
|
unbindService(serviceConnection);
|
||||||
|
}
|
||||||
|
serviceBound = success;
|
||||||
|
}
|
||||||
|
|
||||||
private void unbind() {
|
private void unbind() {
|
||||||
if (serviceBound) {
|
if (serviceBound) {
|
||||||
@@ -237,6 +210,34 @@ public final class PlayQueueActivity extends AppCompatActivity
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ServiceConnection getServiceConnection() {
|
||||||
|
return new ServiceConnection() {
|
||||||
|
@Override
|
||||||
|
public void onServiceDisconnected(final ComponentName name) {
|
||||||
|
Log.d(TAG, "Player service is disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServiceConnected(final ComponentName name, final IBinder service) {
|
||||||
|
Log.d(TAG, "Player service is connected");
|
||||||
|
|
||||||
|
if (service instanceof PlayerService.LocalBinder) {
|
||||||
|
player = ((PlayerService.LocalBinder) service).getService().getPlayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player == null || player.getPlayQueue() == null || player.exoPlayerIsNull()) {
|
||||||
|
unbind();
|
||||||
|
} else {
|
||||||
|
onQueueUpdate(player.getPlayQueue());
|
||||||
|
buildComponents();
|
||||||
|
if (player != null) {
|
||||||
|
player.setActivityListener(PlayQueueActivity.this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
// Component Building
|
// Component Building
|
||||||
////////////////////////////////////////////////////////////////////////////
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import static org.schabi.newpipe.player.notification.NotificationConstants.ACTIO
|
|||||||
import static org.schabi.newpipe.util.ListHelper.getPopupResolutionIndex;
|
import static org.schabi.newpipe.util.ListHelper.getPopupResolutionIndex;
|
||||||
import static org.schabi.newpipe.util.ListHelper.getResolutionIndex;
|
import static org.schabi.newpipe.util.ListHelper.getResolutionIndex;
|
||||||
import static java.util.concurrent.TimeUnit.MILLISECONDS;
|
import static java.util.concurrent.TimeUnit.MILLISECONDS;
|
||||||
|
|
||||||
import static coil3.Image_androidKt.toBitmap;
|
import static coil3.Image_androidKt.toBitmap;
|
||||||
|
|
||||||
import android.content.BroadcastReceiver;
|
import android.content.BroadcastReceiver;
|
||||||
@@ -140,10 +141,6 @@ import io.reactivex.rxjava3.disposables.Disposable;
|
|||||||
import io.reactivex.rxjava3.disposables.SerialDisposable;
|
import io.reactivex.rxjava3.disposables.SerialDisposable;
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||||
|
|
||||||
/**
|
|
||||||
* The ExoPlayer wrapper & Player business logic.
|
|
||||||
* Only instantiated once, from {@link PlayerService}.
|
|
||||||
*/
|
|
||||||
public final class Player implements PlaybackListener, Listener {
|
public final class Player implements PlaybackListener, Listener {
|
||||||
public static final boolean DEBUG = MainActivity.DEBUG;
|
public static final boolean DEBUG = MainActivity.DEBUG;
|
||||||
public static final String TAG = Player.class.getSimpleName();
|
public static final String TAG = Player.class.getSimpleName();
|
||||||
@@ -411,11 +408,12 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
.subscribe(info -> {
|
.subscribe(info -> {
|
||||||
final @Nullable PlayQueue oldPlayQueue = playQueue;
|
final @Nullable PlayQueue oldPlayQueue = playQueue;
|
||||||
info.setStartPosition(data.getSeconds());
|
info.setStartPosition(data.getSeconds());
|
||||||
final PlayQueueItem item = new PlayQueueItem(info);
|
final PlayQueueItem playQueueItem = new PlayQueueItem(info);
|
||||||
|
|
||||||
// If the stream is already playing,
|
// If the stream is already playing,
|
||||||
// we can just seek to the appropriate timestamp
|
// we can just seek to the appropriate timestamp
|
||||||
if (oldPlayQueue != null && item.equals(oldPlayQueue.getItem())) {
|
if (oldPlayQueue != null
|
||||||
|
&& playQueueItem.isSameItem(oldPlayQueue.getItem())) {
|
||||||
// Player can have state = IDLE when playback is stopped or failed
|
// Player can have state = IDLE when playback is stopped or failed
|
||||||
// and we should retry in this case
|
// and we should retry in this case
|
||||||
if (simpleExoPlayer.getPlaybackState()
|
if (simpleExoPlayer.getPlaybackState()
|
||||||
@@ -431,12 +429,12 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
|
|
||||||
// If there is no queue yet, just add our item
|
// If there is no queue yet, just add our item
|
||||||
if (oldPlayQueue == null) {
|
if (oldPlayQueue == null) {
|
||||||
newPlayQueue = new SinglePlayQueue(item);
|
newPlayQueue = new SinglePlayQueue(playQueueItem);
|
||||||
|
|
||||||
// else we add the timestamped stream behind the current video
|
// else we add the timestamped stream behind the current video
|
||||||
// and start playing it.
|
// and start playing it.
|
||||||
} else {
|
} else {
|
||||||
oldPlayQueue.enqueueNext(item, true);
|
oldPlayQueue.enqueueNext(playQueueItem, true);
|
||||||
oldPlayQueue.offsetIndex(1);
|
oldPlayQueue.offsetIndex(1);
|
||||||
newPlayQueue = oldPlayQueue;
|
newPlayQueue = oldPlayQueue;
|
||||||
}
|
}
|
||||||
@@ -479,7 +477,7 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
if (!exoPlayerIsNull()
|
if (!exoPlayerIsNull()
|
||||||
&& newQueue.size() == 1 && newQueue.getItem() != null
|
&& newQueue.size() == 1 && newQueue.getItem() != null
|
||||||
&& playQueue != null && playQueue.size() == 1 && playQueue.getItem() != null
|
&& playQueue != null && playQueue.size() == 1 && playQueue.getItem() != null
|
||||||
&& newQueue.getItem().equals(playQueue.getItem())
|
&& newQueue.getItem().isSameItem(playQueue.getItem())
|
||||||
&& newQueue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) {
|
&& newQueue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) {
|
||||||
// Player can have state = IDLE when playback is stopped or failed
|
// Player can have state = IDLE when playback is stopped or failed
|
||||||
// and we should retry in this case
|
// and we should retry in this case
|
||||||
@@ -566,38 +564,36 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void initUIsForCurrentPlayerType() {
|
private void initUIsForCurrentPlayerType() {
|
||||||
if ((UIs.get(MainPlayerUi.class) != null && playerType == PlayerType.MAIN)
|
if ((UIs.get(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN)
|
||||||
|| (UIs.get(BackgroundPlayerUi.class) != null && playerType == PlayerType.AUDIO)
|
|| (UIs.get(BackgroundPlayerUi.class).isPresent() && playerType == PlayerType.AUDIO)
|
||||||
|| (UIs.get(PopupPlayerUi.class) != null && playerType == PlayerType.POPUP)) {
|
|| (UIs.get(PopupPlayerUi.class).isPresent() && playerType == PlayerType.POPUP)) {
|
||||||
// correct UI already in place
|
// correct UI already in place
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// try to reuse binding if possible
|
// try to reuse binding if possible
|
||||||
@Nullable final VideoPlayerUi ui = UIs.get(VideoPlayerUi.class);
|
final PlayerBinding binding = UIs.get(VideoPlayerUi.class).map(VideoPlayerUi::getBinding)
|
||||||
final PlayerBinding binding;
|
.orElseGet(() -> {
|
||||||
if (ui != null) {
|
if (playerType == PlayerType.AUDIO) {
|
||||||
binding = ui.getBinding();
|
return null;
|
||||||
} else if (playerType == PlayerType.AUDIO) {
|
} else {
|
||||||
binding = null;
|
return PlayerBinding.inflate(LayoutInflater.from(context));
|
||||||
} else {
|
}
|
||||||
binding = PlayerBinding.inflate(LayoutInflater.from(context));
|
});
|
||||||
}
|
|
||||||
|
|
||||||
switch (playerType) {
|
switch (playerType) {
|
||||||
case MAIN:
|
case MAIN:
|
||||||
UIs.destroyAllOfType(PopupPlayerUi.class);
|
UIs.destroyAll(PopupPlayerUi.class);
|
||||||
UIs.destroyAllOfType(BackgroundPlayerUi.class);
|
UIs.destroyAll(BackgroundPlayerUi.class);
|
||||||
UIs.addAndPrepare(new MainPlayerUi(this, binding));
|
UIs.addAndPrepare(new MainPlayerUi(this, binding));
|
||||||
break;
|
break;
|
||||||
case POPUP:
|
case POPUP:
|
||||||
UIs.destroyAllOfType(MainPlayerUi.class);
|
UIs.destroyAll(MainPlayerUi.class);
|
||||||
UIs.destroyAllOfType(BackgroundPlayerUi.class);
|
UIs.destroyAll(BackgroundPlayerUi.class);
|
||||||
UIs.addAndPrepare(new PopupPlayerUi(this, binding));
|
UIs.addAndPrepare(new PopupPlayerUi(this, binding));
|
||||||
break;
|
break;
|
||||||
case AUDIO:
|
case AUDIO:
|
||||||
// destroys both MainPlayerUi and PopupPlayerUi
|
UIs.destroyAll(VideoPlayerUi.class); // destroys both MainPlayerUi and PopupPlayerUi
|
||||||
UIs.destroyAllOfType(VideoPlayerUi.class);
|
|
||||||
UIs.addAndPrepare(new BackgroundPlayerUi(this));
|
UIs.addAndPrepare(new BackgroundPlayerUi(this));
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -686,15 +682,9 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void destroy() {
|
||||||
/**
|
|
||||||
* Shut down this player.
|
|
||||||
* Saves the stream progress, sets recovery.
|
|
||||||
* Then destroys the player in all UIs and destroys the UIs as well.
|
|
||||||
*/
|
|
||||||
public void saveAndShutdown() {
|
|
||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
Log.d(TAG, "saveAndShutdown() called");
|
Log.d(TAG, "destroy() called");
|
||||||
}
|
}
|
||||||
|
|
||||||
saveStreamProgressState();
|
saveStreamProgressState();
|
||||||
@@ -708,7 +698,7 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
progressUpdateDisposable.set(null);
|
progressUpdateDisposable.set(null);
|
||||||
streamItemDisposable.clear();
|
streamItemDisposable.clear();
|
||||||
|
|
||||||
UIs.destroyAllOfType(null);
|
UIs.destroyAll(Object.class); // destroy every UI: obviously every UI extends Object
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setRecovery() {
|
public void setRecovery() {
|
||||||
@@ -933,6 +923,7 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
.loadScaledDownThumbnail(context, thumbnails, thumbnailTarget);
|
.loadScaledDownThumbnail(context, thumbnails, thumbnailTarget);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private void onThumbnailLoaded(@Nullable final Bitmap bitmap) {
|
private void onThumbnailLoaded(@Nullable final Bitmap bitmap) {
|
||||||
// Avoid useless thumbnail updates, if the thumbnail has not actually changed. Based on the
|
// Avoid useless thumbnail updates, if the thumbnail has not actually changed. Based on the
|
||||||
// thumbnail loading code, this if would be skipped only when both bitmaps are `null`, since
|
// thumbnail loading code, this if would be skipped only when both bitmaps are `null`, since
|
||||||
@@ -2113,10 +2104,6 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
triggerProgressUpdate();
|
triggerProgressUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove the listener, if it was set.
|
|
||||||
* @param listener listener to remove
|
|
||||||
* */
|
|
||||||
public void removeFragmentListener(final PlayerServiceEventListener listener) {
|
public void removeFragmentListener(final PlayerServiceEventListener listener) {
|
||||||
if (fragmentListener == listener) {
|
if (fragmentListener == listener) {
|
||||||
fragmentListener = null;
|
fragmentListener = null;
|
||||||
@@ -2131,10 +2118,6 @@ public final class Player implements PlaybackListener, Listener {
|
|||||||
triggerProgressUpdate();
|
triggerProgressUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove the listener, if it was set.
|
|
||||||
* @param listener listener to remove
|
|
||||||
* */
|
|
||||||
void removeActivityListener(final PlayerEventListener listener) {
|
void removeActivityListener(final PlayerEventListener listener) {
|
||||||
if (activityListener == listener) {
|
if (activityListener == listener) {
|
||||||
activityListener = null;
|
activityListener = null;
|
||||||
|
|||||||
348
app/src/main/java/org/schabi/newpipe/player/PlayerService.java
Normal file
348
app/src/main/java/org/schabi/newpipe/player/PlayerService.java
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
/*
|
||||||
|
* Copyright 2017 Mauricio Colli <mauriciocolli@outlook.com>
|
||||||
|
* Part of NewPipe
|
||||||
|
*
|
||||||
|
* License: GPL-3.0+
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.schabi.newpipe.player;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.os.Binder;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.os.IBinder;
|
||||||
|
import android.support.v4.media.MediaBrowserCompat;
|
||||||
|
import android.support.v4.media.session.MediaSessionCompat;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.core.app.ServiceCompat;
|
||||||
|
import androidx.media.MediaBrowserServiceCompat;
|
||||||
|
|
||||||
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.ktx.BundleKt;
|
||||||
|
import org.schabi.newpipe.player.mediabrowser.MediaBrowserImpl;
|
||||||
|
import org.schabi.newpipe.player.mediabrowser.MediaBrowserPlaybackPreparer;
|
||||||
|
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
|
||||||
|
import org.schabi.newpipe.player.notification.NotificationPlayerUi;
|
||||||
|
import org.schabi.newpipe.player.notification.NotificationUtil;
|
||||||
|
import org.schabi.newpipe.util.ThemeHelper;
|
||||||
|
|
||||||
|
import java.lang.ref.WeakReference;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One service for all players.
|
||||||
|
*/
|
||||||
|
public final class PlayerService extends MediaBrowserServiceCompat {
|
||||||
|
private static final String TAG = PlayerService.class.getSimpleName();
|
||||||
|
private static final boolean DEBUG = Player.DEBUG;
|
||||||
|
|
||||||
|
public static final String SHOULD_START_FOREGROUND_EXTRA = "should_start_foreground_extra";
|
||||||
|
public static final String BIND_PLAYER_HOLDER_ACTION = "bind_player_holder_action";
|
||||||
|
|
||||||
|
// These objects are used to cleanly separate the Service implementation (in this file) and the
|
||||||
|
// media browser and playback preparer implementations. At the moment the playback preparer is
|
||||||
|
// only used in conjunction with the media browser.
|
||||||
|
private MediaBrowserImpl mediaBrowserImpl;
|
||||||
|
private MediaBrowserPlaybackPreparer mediaBrowserPlaybackPreparer;
|
||||||
|
|
||||||
|
// these are instantiated in onCreate() as per
|
||||||
|
// https://developer.android.com/training/cars/media#browser_workflow
|
||||||
|
private MediaSessionCompat mediaSession;
|
||||||
|
private MediaSessionConnector sessionConnector;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private Player player;
|
||||||
|
|
||||||
|
private final IBinder mBinder = new PlayerService.LocalBinder(this);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The parameter taken by this {@link Consumer} can be null to indicate the player is being
|
||||||
|
* stopped.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
private Consumer<Player> onPlayerStartedOrStopped = null;
|
||||||
|
|
||||||
|
|
||||||
|
//region Service lifecycle
|
||||||
|
@Override
|
||||||
|
public void onCreate() {
|
||||||
|
super.onCreate();
|
||||||
|
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "onCreate() called");
|
||||||
|
}
|
||||||
|
ThemeHelper.setTheme(this);
|
||||||
|
|
||||||
|
mediaBrowserImpl = new MediaBrowserImpl(this, this::notifyChildrenChanged);
|
||||||
|
|
||||||
|
// see https://developer.android.com/training/cars/media#browser_workflow
|
||||||
|
mediaSession = new MediaSessionCompat(this, "MediaSessionPlayerServ");
|
||||||
|
setSessionToken(mediaSession.getSessionToken());
|
||||||
|
sessionConnector = new MediaSessionConnector(mediaSession);
|
||||||
|
sessionConnector.setMetadataDeduplicationEnabled(true);
|
||||||
|
|
||||||
|
mediaBrowserPlaybackPreparer = new MediaBrowserPlaybackPreparer(
|
||||||
|
this,
|
||||||
|
sessionConnector::setCustomErrorMessage,
|
||||||
|
() -> sessionConnector.setCustomErrorMessage(null),
|
||||||
|
(playWhenReady) -> {
|
||||||
|
if (player != null) {
|
||||||
|
player.onPrepare();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
sessionConnector.setPlaybackPreparer(mediaBrowserPlaybackPreparer);
|
||||||
|
|
||||||
|
// Note: you might be tempted to create the player instance and call startForeground here,
|
||||||
|
// but be aware that the Android system might start the service just to perform media
|
||||||
|
// queries. In those cases creating a player instance is a waste of resources, and calling
|
||||||
|
// startForeground means creating a useless empty notification. In case it's really needed
|
||||||
|
// the player instance can be created here, but startForeground() should definitely not be
|
||||||
|
// called here unless the service is actually starting in the foreground, to avoid the
|
||||||
|
// useless notification.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int onStartCommand(final Intent intent, final int flags, final int startId) {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "onStartCommand() called with: intent = [" + intent
|
||||||
|
+ "], extras = [" + BundleKt.toDebugString(intent.getExtras())
|
||||||
|
+ "], flags = [" + flags + "], startId = [" + startId + "]");
|
||||||
|
}
|
||||||
|
|
||||||
|
// All internal NewPipe intents used to interact with the player, that are sent to the
|
||||||
|
// PlayerService using startForegroundService(), will have SHOULD_START_FOREGROUND_EXTRA,
|
||||||
|
// to ensure startForeground() is called (otherwise Android will force-crash the app).
|
||||||
|
if (intent.getBooleanExtra(SHOULD_START_FOREGROUND_EXTRA, false)) {
|
||||||
|
final boolean playerWasNull = (player == null);
|
||||||
|
if (playerWasNull) {
|
||||||
|
// make sure the player exists, in case the service was resumed
|
||||||
|
player = new Player(this, mediaSession, sessionConnector);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Be sure that the player notification is set and the service is started in foreground,
|
||||||
|
// otherwise, the app may crash on Android 8+ as the service would never be put in the
|
||||||
|
// foreground while we said to the system we would do so. The service is always
|
||||||
|
// requested to be started in foreground, so always creating a notification if there is
|
||||||
|
// no one already and starting the service in foreground should not create any issues.
|
||||||
|
// If the service is already started in foreground, requesting it to be started
|
||||||
|
// shouldn't do anything.
|
||||||
|
player.UIs().get(NotificationPlayerUi.class)
|
||||||
|
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
|
||||||
|
|
||||||
|
if (playerWasNull && onPlayerStartedOrStopped != null) {
|
||||||
|
// notify that a new player was created (but do it after creating the foreground
|
||||||
|
// notification just to make sure we don't incur, due to slowness, in
|
||||||
|
// "Context.startForegroundService() did not then call Service.startForeground()")
|
||||||
|
onPlayerStartedOrStopped.accept(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player == null) {
|
||||||
|
// No need to process media button's actions or other system intents if the player is
|
||||||
|
// not running. However, since the current intent might have been issued by the system
|
||||||
|
// with `startForegroundService()` (for unknown reasons), we need to ensure that we post
|
||||||
|
// a (dummy) foreground notification, otherwise we'd incur in
|
||||||
|
// "Context.startForegroundService() did not then call Service.startForeground()". Then
|
||||||
|
// we stop the service again.
|
||||||
|
Log.d(TAG, "onStartCommand() got a useless intent, closing the service");
|
||||||
|
NotificationUtil.startForegroundWithDummyNotification(this);
|
||||||
|
destroyPlayerAndStopService();
|
||||||
|
return START_NOT_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
final PlayerType oldPlayerType = player.getPlayerType();
|
||||||
|
player.handleIntent(intent);
|
||||||
|
player.handleIntentPost(oldPlayerType);
|
||||||
|
player.UIs().get(MediaSessionPlayerUi.class)
|
||||||
|
.ifPresent(ui -> ui.handleMediaButtonIntent(intent));
|
||||||
|
|
||||||
|
return START_NOT_STICKY;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stopForImmediateReusing() {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "stopForImmediateReusing() called");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (player != null && !player.exoPlayerIsNull()) {
|
||||||
|
// Releases wifi & cpu, disables keepScreenOn, etc.
|
||||||
|
// We can't just pause the player here because it will make transition
|
||||||
|
// from one stream to a new stream not smooth
|
||||||
|
player.smoothStopForImmediateReusing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onTaskRemoved(final Intent rootIntent) {
|
||||||
|
super.onTaskRemoved(rootIntent);
|
||||||
|
if (player != null && !player.videoPlayerSelected()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onDestroy();
|
||||||
|
// Unload from memory completely
|
||||||
|
Runtime.getRuntime().halt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onDestroy() {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "destroy() called");
|
||||||
|
}
|
||||||
|
super.onDestroy();
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
mediaBrowserPlaybackPreparer.dispose();
|
||||||
|
mediaSession.release();
|
||||||
|
mediaBrowserImpl.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanup() {
|
||||||
|
if (player != null) {
|
||||||
|
if (onPlayerStartedOrStopped != null) {
|
||||||
|
// notify that the player is being destroyed
|
||||||
|
onPlayerStartedOrStopped.accept(null);
|
||||||
|
}
|
||||||
|
player.destroy();
|
||||||
|
player = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Should already be handled by MediaSessionPlayerUi, but just to be sure.
|
||||||
|
mediaSession.setActive(false);
|
||||||
|
|
||||||
|
// Should already be handled by NotificationUtil.cancelNotificationAndStopForeground() in
|
||||||
|
// NotificationPlayerUi, but let's make sure that the foreground service is stopped.
|
||||||
|
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys the player and allows the player instance to be garbage collected. Sets the media
|
||||||
|
* session to inactive. Stops the foreground service and removes the player notification
|
||||||
|
* associated with it. Tries to stop the {@link PlayerService} completely, but this step will
|
||||||
|
* have no effect in case some service connection still uses the service (e.g. the Android Auto
|
||||||
|
* system accesses the media browser even when no player is running).
|
||||||
|
*/
|
||||||
|
public void destroyPlayerAndStopService() {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "destroyPlayerAndStopService() called");
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
// This only really stops the service if there are no other service connections (see docs):
|
||||||
|
// for example the (Android Auto) media browser binder will block stopService().
|
||||||
|
// This is why we also stopForeground() above, to make sure the notification is removed.
|
||||||
|
// If we were to call stopSelf(), then the service would be surely stopped (regardless of
|
||||||
|
// other service connections), but this would be a waste of resources since the service
|
||||||
|
// would be immediately restarted by those same connections to perform the queries.
|
||||||
|
stopService(new Intent(this, PlayerService.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void attachBaseContext(final Context base) {
|
||||||
|
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base));
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region Bind
|
||||||
|
@Override
|
||||||
|
public IBinder onBind(final Intent intent) {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "onBind() called with: intent = [" + intent
|
||||||
|
+ "], extras = [" + BundleKt.toDebugString(intent.getExtras()) + "]");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (BIND_PLAYER_HOLDER_ACTION.equals(intent.getAction())) {
|
||||||
|
// Note that this binder might be reused multiple times while the service is alive, even
|
||||||
|
// after unbind() has been called: https://stackoverflow.com/a/8794930 .
|
||||||
|
return mBinder;
|
||||||
|
|
||||||
|
} else if (MediaBrowserServiceCompat.SERVICE_INTERFACE.equals(intent.getAction())) {
|
||||||
|
// MediaBrowserService also uses its own binder, so for actions related to the media
|
||||||
|
// browser service, pass the onBind to the superclass.
|
||||||
|
return super.onBind(intent);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// This is an unknown request, avoid returning any binder to not leak objects.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class LocalBinder extends Binder {
|
||||||
|
private final WeakReference<PlayerService> playerService;
|
||||||
|
|
||||||
|
LocalBinder(final PlayerService playerService) {
|
||||||
|
this.playerService = new WeakReference<>(playerService);
|
||||||
|
}
|
||||||
|
|
||||||
|
public PlayerService getService() {
|
||||||
|
return playerService.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the current active player instance. May be null, since the player service can outlive
|
||||||
|
* the player e.g. to respond to Android Auto media browser queries.
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public Player getPlayer() {
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the listener that will be called when the player is started or stopped. If a
|
||||||
|
* {@code null} listener is passed, then the current listener will be unset. The parameter taken
|
||||||
|
* by the {@link Consumer} can be null to indicate that the player is stopping.
|
||||||
|
* @param listener the listener to set or unset
|
||||||
|
*/
|
||||||
|
public void setPlayerListener(@Nullable final Consumer<Player> listener) {
|
||||||
|
this.onPlayerStartedOrStopped = listener;
|
||||||
|
if (listener != null) {
|
||||||
|
// if there is no player, then `null` will be sent here, to ensure the state is synced
|
||||||
|
listener.accept(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
|
||||||
|
//region Media browser
|
||||||
|
@Override
|
||||||
|
public BrowserRoot onGetRoot(@NonNull final String clientPackageName,
|
||||||
|
final int clientUid,
|
||||||
|
@Nullable final Bundle rootHints) {
|
||||||
|
return mediaBrowserImpl.onGetRoot(clientPackageName, clientUid, rootHints);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onLoadChildren(@NonNull final String parentId,
|
||||||
|
@NonNull final Result<List<MediaBrowserCompat.MediaItem>> result) {
|
||||||
|
mediaBrowserImpl.onLoadChildren(parentId, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onSearch(@NonNull final String query,
|
||||||
|
final Bundle extras,
|
||||||
|
@NonNull final Result<List<MediaBrowserCompat.MediaItem>> result) {
|
||||||
|
mediaBrowserImpl.onSearch(query, result);
|
||||||
|
}
|
||||||
|
//endregion
|
||||||
|
}
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2017 Mauricio Colli <mauriciocolli@outlook.com>
|
|
||||||
* Part of NewPipe
|
|
||||||
*
|
|
||||||
* License: GPL-3.0+
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU General Public License
|
|
||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
package org.schabi.newpipe.player
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.os.Binder
|
|
||||||
import android.os.Bundle
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.support.v4.media.MediaBrowserCompat
|
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.app.ServiceCompat
|
|
||||||
import androidx.media.MediaBrowserServiceCompat
|
|
||||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
|
||||||
import java.lang.ref.WeakReference
|
|
||||||
import java.util.function.Consumer
|
|
||||||
import org.schabi.newpipe.ktx.toDebugString
|
|
||||||
import org.schabi.newpipe.player.mediabrowser.MediaBrowserImpl
|
|
||||||
import org.schabi.newpipe.player.mediabrowser.MediaBrowserPlaybackPreparer
|
|
||||||
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi
|
|
||||||
import org.schabi.newpipe.player.notification.NotificationPlayerUi
|
|
||||||
import org.schabi.newpipe.player.notification.NotificationUtil
|
|
||||||
import org.schabi.newpipe.util.ThemeHelper
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One service for all players.
|
|
||||||
*/
|
|
||||||
class PlayerService : MediaBrowserServiceCompat() {
|
|
||||||
// These objects are used to cleanly separate the Service implementation (in this file) and the
|
|
||||||
// media browser and playback preparer implementations. At the moment the playback preparer is
|
|
||||||
// only used in conjunction with the media browser.
|
|
||||||
private lateinit var mediaBrowserImpl: MediaBrowserImpl
|
|
||||||
private lateinit var mediaBrowserPlaybackPreparer: MediaBrowserPlaybackPreparer
|
|
||||||
|
|
||||||
// these are instantiated in onCreate() as per
|
|
||||||
// https://developer.android.com/training/cars/media#browser_workflow
|
|
||||||
private lateinit var mediaSession: MediaSessionCompat
|
|
||||||
private lateinit var sessionConnector: MediaSessionConnector
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return the current active player instance. May be null, since the player service can outlive
|
|
||||||
* the player e.g. to respond to Android Auto media browser queries.
|
|
||||||
*/
|
|
||||||
var player: Player? = null
|
|
||||||
private set
|
|
||||||
|
|
||||||
private val mBinder: IBinder = LocalBinder(this)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The parameter taken by this [Consumer] can be null to indicate the player is being
|
|
||||||
* stopped.
|
|
||||||
*/
|
|
||||||
private var onPlayerStartedOrStopped: ((player: Player?) -> Unit)? = null
|
|
||||||
|
|
||||||
//region Service lifecycle
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
|
|
||||||
if (DEBUG) {
|
|
||||||
Log.d(TAG, "onCreate() called")
|
|
||||||
}
|
|
||||||
ThemeHelper.setTheme(this)
|
|
||||||
|
|
||||||
mediaBrowserImpl = MediaBrowserImpl(this, this::notifyChildrenChanged)
|
|
||||||
|
|
||||||
// see https://developer.android.com/training/cars/media#browser_workflow
|
|
||||||
val session = MediaSessionCompat(this, "MediaSessionPlayerServ")
|
|
||||||
mediaSession = session
|
|
||||||
setSessionToken(session.sessionToken)
|
|
||||||
val connector = MediaSessionConnector(session)
|
|
||||||
sessionConnector = connector
|
|
||||||
connector.setMetadataDeduplicationEnabled(true)
|
|
||||||
|
|
||||||
mediaBrowserPlaybackPreparer = MediaBrowserPlaybackPreparer(
|
|
||||||
context = this,
|
|
||||||
setMediaSessionError = connector::setCustomErrorMessage,
|
|
||||||
clearMediaSessionError = { connector.setCustomErrorMessage(null) },
|
|
||||||
onPrepare = { player?.onPrepare() }
|
|
||||||
)
|
|
||||||
connector.setPlaybackPreparer(mediaBrowserPlaybackPreparer)
|
|
||||||
|
|
||||||
// Note: you might be tempted to create the player instance and call startForeground here,
|
|
||||||
// but be aware that the Android system might start the service just to perform media
|
|
||||||
// queries. In those cases creating a player instance is a waste of resources, and calling
|
|
||||||
// startForeground means creating a useless empty notification. In case it's really needed
|
|
||||||
// the player instance can be created here, but startForeground() should definitely not be
|
|
||||||
// called here unless the service is actually starting in the foreground, to avoid the
|
|
||||||
// useless notification.
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
|
|
||||||
if (DEBUG) {
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"onStartCommand() called with: intent = [$intent], extras = [${
|
|
||||||
intent.extras.toDebugString()}], flags = [$flags], startId = [$startId]"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// All internal NewPipe intents used to interact with the player, that are sent to the
|
|
||||||
// PlayerService using startForegroundService(), will have SHOULD_START_FOREGROUND_EXTRA,
|
|
||||||
// to ensure startForeground() is called (otherwise Android will force-crash the app).
|
|
||||||
if (intent.getBooleanExtra(SHOULD_START_FOREGROUND_EXTRA, false)) {
|
|
||||||
val playerWasNull = (player == null)
|
|
||||||
if (playerWasNull) {
|
|
||||||
// make sure the player exists, in case the service was resumed
|
|
||||||
player = Player(this, mediaSession, sessionConnector)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Be sure that the player notification is set and the service is started in foreground,
|
|
||||||
// otherwise, the app may crash on Android 8+ as the service would never be put in the
|
|
||||||
// foreground while we said to the system we would do so. The service is always
|
|
||||||
// requested to be started in foreground, so always creating a notification if there is
|
|
||||||
// no one already and starting the service in foreground should not create any issues.
|
|
||||||
// If the service is already started in foreground, requesting it to be started
|
|
||||||
// shouldn't do anything.
|
|
||||||
player?.UIs()?.get(NotificationPlayerUi::class)?.createNotificationAndStartForeground()
|
|
||||||
|
|
||||||
if (playerWasNull) {
|
|
||||||
// notify that a new player was created (but do it after creating the foreground
|
|
||||||
// notification just to make sure we don't incur, due to slowness, in
|
|
||||||
// "Context.startForegroundService() did not then call Service.startForeground()")
|
|
||||||
onPlayerStartedOrStopped?.invoke(player)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (player == null) {
|
|
||||||
// No need to process media button's actions or other system intents if the player is
|
|
||||||
// not running. However, since the current intent might have been issued by the system
|
|
||||||
// with `startForegroundService()` (for unknown reasons), we need to ensure that we post
|
|
||||||
// a (dummy) foreground notification, otherwise we'd incur in
|
|
||||||
// "Context.startForegroundService() did not then call Service.startForeground()". Then
|
|
||||||
// we stop the service again.
|
|
||||||
Log.d(TAG, "onStartCommand() got a useless intent, closing the service")
|
|
||||||
NotificationUtil.startForegroundWithDummyNotification(this)
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
val oldPlayerType = player?.playerType
|
|
||||||
player?.handleIntent(intent)
|
|
||||||
player?.handleIntentPost(oldPlayerType)
|
|
||||||
player?.UIs()?.get(MediaSessionPlayerUi::class.java)
|
|
||||||
?.handleMediaButtonIntent(intent)
|
|
||||||
|
|
||||||
return START_NOT_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stopForImmediateReusing() {
|
|
||||||
if (DEBUG) {
|
|
||||||
Log.d(TAG, "stopForImmediateReusing() called")
|
|
||||||
}
|
|
||||||
|
|
||||||
val p = player
|
|
||||||
if (p != null && !p.exoPlayerIsNull()) {
|
|
||||||
// Releases wifi & cpu, disables keepScreenOn, etc.
|
|
||||||
// We can't just pause the player here because it will make transition
|
|
||||||
// from one stream to a new stream not smooth
|
|
||||||
p.smoothStopForImmediateReusing()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
|
||||||
super.onTaskRemoved(rootIntent)
|
|
||||||
val p = player
|
|
||||||
if (p != null && !p.videoPlayerSelected()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
onDestroy()
|
|
||||||
// Unload from memory completely
|
|
||||||
Runtime.getRuntime().halt(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDestroy() {
|
|
||||||
if (DEBUG) {
|
|
||||||
Log.d(TAG, "destroy() called")
|
|
||||||
}
|
|
||||||
super.onDestroy()
|
|
||||||
|
|
||||||
cleanup()
|
|
||||||
|
|
||||||
mediaBrowserPlaybackPreparer.dispose()
|
|
||||||
mediaSession.release()
|
|
||||||
mediaBrowserImpl.dispose()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun cleanup() {
|
|
||||||
val p = player
|
|
||||||
if (p != null) {
|
|
||||||
// notify that the player is being destroyed
|
|
||||||
onPlayerStartedOrStopped?.invoke(null)
|
|
||||||
p.saveAndShutdown()
|
|
||||||
player = null
|
|
||||||
}
|
|
||||||
|
|
||||||
// Should already be handled by MediaSessionPlayerUi, but just to be sure.
|
|
||||||
mediaSession.setActive(false)
|
|
||||||
|
|
||||||
// Should already be handled by NotificationUtil.cancelNotificationAndStopForeground() in
|
|
||||||
// NotificationPlayerUi, but let's make sure that the foreground service is stopped.
|
|
||||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroys the player and allows the player instance to be garbage collected. Sets the media
|
|
||||||
* session to inactive. Stops the foreground service and removes the player notification
|
|
||||||
* associated with it. Tries to stop the [PlayerService] completely, but this step will
|
|
||||||
* have no effect in case some service connection still uses the service (e.g. the Android Auto
|
|
||||||
* system accesses the media browser even when no player is running).
|
|
||||||
*/
|
|
||||||
fun destroyPlayerAndStopService() {
|
|
||||||
if (DEBUG) {
|
|
||||||
Log.d(TAG, "destroyPlayerAndStopService() called")
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanup()
|
|
||||||
|
|
||||||
// This only really stops the service if there are no other service connections (see docs):
|
|
||||||
// for example the (Android Auto) media browser binder will block stopService().
|
|
||||||
// This is why we also stopForeground() above, to make sure the notification is removed.
|
|
||||||
// If we were to call stopSelf(), then the service would be surely stopped (regardless of
|
|
||||||
// other service connections), but this would be a waste of resources since the service
|
|
||||||
// would be immediately restarted by those same connections to perform the queries.
|
|
||||||
stopService(Intent(this, PlayerService::class.java))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun attachBaseContext(base: Context?) {
|
|
||||||
super.attachBaseContext(AudioServiceLeakFix.preventLeakOf(base))
|
|
||||||
}
|
|
||||||
|
|
||||||
//endregion
|
|
||||||
//region Bind
|
|
||||||
override fun onBind(intent: Intent): IBinder? {
|
|
||||||
if (DEBUG) {
|
|
||||||
Log.d(
|
|
||||||
TAG,
|
|
||||||
"onBind() called with: intent = [$intent], extras = [${
|
|
||||||
intent.extras.toDebugString()}]"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return if (BIND_PLAYER_HOLDER_ACTION == intent.action) {
|
|
||||||
// Note that this binder might be reused multiple times while the service is alive, even
|
|
||||||
// after unbind() has been called: https://stackoverflow.com/a/8794930 .
|
|
||||||
mBinder
|
|
||||||
} else if (SERVICE_INTERFACE == intent.action) {
|
|
||||||
// MediaBrowserService also uses its own binder, so for actions related to the media
|
|
||||||
// browser service, pass the onBind to the superclass.
|
|
||||||
super.onBind(intent)
|
|
||||||
} else {
|
|
||||||
// This is an unknown request, avoid returning any binder to not leak objects.
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class LocalBinder internal constructor(playerService: PlayerService) : Binder() {
|
|
||||||
private val playerService = WeakReference(playerService)
|
|
||||||
|
|
||||||
val service: PlayerService?
|
|
||||||
get() = playerService.get()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the listener that will be called when the player is started or stopped. If a
|
|
||||||
* `null` listener is passed, then the current listener will be unset. The parameter taken
|
|
||||||
* by the [Consumer] can be null to indicate that the player is stopping.
|
|
||||||
* @param listener the listener to set or unset
|
|
||||||
*/
|
|
||||||
fun setPlayerListener(listener: ((player: Player?) -> Unit)?) {
|
|
||||||
this.onPlayerStartedOrStopped = listener
|
|
||||||
listener?.invoke(player)
|
|
||||||
}
|
|
||||||
|
|
||||||
//endregion
|
|
||||||
//region Media browser
|
|
||||||
override fun onGetRoot(
|
|
||||||
clientPackageName: String,
|
|
||||||
clientUid: Int,
|
|
||||||
rootHints: Bundle?
|
|
||||||
): BrowserRoot? {
|
|
||||||
// TODO check if the accessing package has permission to view data
|
|
||||||
return mediaBrowserImpl.onGetRoot(clientPackageName, clientUid, rootHints)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onLoadChildren(
|
|
||||||
parentId: String,
|
|
||||||
result: Result<List<MediaBrowserCompat.MediaItem>>
|
|
||||||
) {
|
|
||||||
mediaBrowserImpl.onLoadChildren(parentId, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSearch(
|
|
||||||
query: String,
|
|
||||||
extras: Bundle?,
|
|
||||||
result: Result<List<MediaBrowserCompat.MediaItem>>
|
|
||||||
) {
|
|
||||||
mediaBrowserImpl.onSearch(query, result)
|
|
||||||
} //endregion
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private val TAG: String = PlayerService::class.java.getSimpleName()
|
|
||||||
private val DEBUG = Player.DEBUG
|
|
||||||
|
|
||||||
const val SHOULD_START_FOREGROUND_EXTRA: String = "should_start_foreground_extra"
|
|
||||||
const val BIND_PLAYER_HOLDER_ACTION: String = "bind_player_holder_action"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,6 @@ import com.google.android.exoplayer2.PlaybackParameters;
|
|||||||
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||||
|
|
||||||
/** Player-specific events like queue or progress updates. */
|
|
||||||
public interface PlayerEventListener {
|
public interface PlayerEventListener {
|
||||||
void onQueueUpdate(PlayQueue queue);
|
void onQueueUpdate(PlayQueue queue);
|
||||||
void onPlaybackUpdate(int state, int repeatMode, boolean shuffled,
|
void onPlaybackUpdate(int state, int repeatMode, boolean shuffled,
|
||||||
|
|||||||
@@ -2,15 +2,12 @@ package org.schabi.newpipe.player.event;
|
|||||||
|
|
||||||
import com.google.android.exoplayer2.PlaybackException;
|
import com.google.android.exoplayer2.PlaybackException;
|
||||||
|
|
||||||
/** {@link org.schabi.newpipe.player.event.PlayerEventListener} that also gets called for
|
|
||||||
* application-specific events like screen rotation or UI changes.
|
|
||||||
*/
|
|
||||||
public interface PlayerServiceEventListener extends PlayerEventListener {
|
public interface PlayerServiceEventListener extends PlayerEventListener {
|
||||||
void onViewCreated();
|
void onViewCreated();
|
||||||
|
|
||||||
void onFullscreenStateChanged(boolean fullscreen);
|
void onFullscreenStateChanged(boolean fullscreen);
|
||||||
|
|
||||||
void onFullscreenToggleButtonClicked();
|
void onScreenRotationButtonClicked();
|
||||||
|
|
||||||
void onMoreOptionsLongClicked();
|
void onMoreOptionsLongClicked();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
package org.schabi.newpipe.player.helper;
|
||||||
|
|
||||||
|
import android.content.ComponentName;
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.ServiceConnection;
|
||||||
|
import android.os.IBinder;
|
||||||
|
import android.util.Log;
|
||||||
|
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
import androidx.core.content.ContextCompat;
|
||||||
|
|
||||||
|
import com.google.android.exoplayer2.PlaybackException;
|
||||||
|
import com.google.android.exoplayer2.PlaybackParameters;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.App;
|
||||||
|
import org.schabi.newpipe.MainActivity;
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||||
|
import org.schabi.newpipe.player.PlayerService;
|
||||||
|
import org.schabi.newpipe.player.Player;
|
||||||
|
import org.schabi.newpipe.player.PlayerType;
|
||||||
|
import org.schabi.newpipe.player.event.PlayerServiceEventListener;
|
||||||
|
import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener;
|
||||||
|
import org.schabi.newpipe.player.playqueue.PlayQueue;
|
||||||
|
import org.schabi.newpipe.util.NavigationHelper;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
public final class PlayerHolder {
|
||||||
|
|
||||||
|
private PlayerHolder() {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlayerHolder instance;
|
||||||
|
public static synchronized PlayerHolder getInstance() {
|
||||||
|
if (PlayerHolder.instance == null) {
|
||||||
|
PlayerHolder.instance = new PlayerHolder();
|
||||||
|
}
|
||||||
|
return PlayerHolder.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final boolean DEBUG = MainActivity.DEBUG;
|
||||||
|
private static final String TAG = PlayerHolder.class.getSimpleName();
|
||||||
|
|
||||||
|
@Nullable private PlayerServiceExtendedEventListener listener;
|
||||||
|
|
||||||
|
private final PlayerServiceConnection serviceConnection = new PlayerServiceConnection();
|
||||||
|
private boolean bound;
|
||||||
|
@Nullable private PlayerService playerService;
|
||||||
|
|
||||||
|
private Optional<Player> getPlayer() {
|
||||||
|
return Optional.ofNullable(playerService)
|
||||||
|
.flatMap(s -> Optional.ofNullable(s.getPlayer()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Optional<PlayQueue> getPlayQueue() {
|
||||||
|
// player play queue might be null e.g. while player is starting
|
||||||
|
return getPlayer().flatMap(p -> Optional.ofNullable(p.getPlayQueue()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current {@link PlayerType} of the {@link PlayerService} service,
|
||||||
|
* otherwise `null` if no service is running.
|
||||||
|
*
|
||||||
|
* @return Current PlayerType
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public PlayerType getType() {
|
||||||
|
return getPlayer().map(Player::getPlayerType).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPlaying() {
|
||||||
|
return getPlayer().map(Player::isPlaying).orElse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isPlayerOpen() {
|
||||||
|
return getPlayer().isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use this method to only allow the user to manipulate the play queue (e.g. by enqueueing via
|
||||||
|
* the stream long press menu) when there actually is a play queue to manipulate.
|
||||||
|
* @return true only if the player is open and its play queue is ready (i.e. it is not null)
|
||||||
|
*/
|
||||||
|
public boolean isPlayQueueReady() {
|
||||||
|
return getPlayQueue().isPresent();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isBound() {
|
||||||
|
return bound;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getQueueSize() {
|
||||||
|
return getPlayQueue().map(PlayQueue::size).orElse(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getQueuePosition() {
|
||||||
|
return getPlayQueue().map(PlayQueue::getIndex).orElse(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setListener(@Nullable final PlayerServiceExtendedEventListener newListener) {
|
||||||
|
listener = newListener;
|
||||||
|
|
||||||
|
if (listener == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force reload data from service
|
||||||
|
if (playerService != null) {
|
||||||
|
listener.onServiceConnected(playerService);
|
||||||
|
startPlayerListener();
|
||||||
|
// ^ will call listener.onPlayerConnected() down the line if there is an active player
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// helper to handle context in common place as using the same
|
||||||
|
// context to bind/unbind a service is crucial
|
||||||
|
private Context getCommonContext() {
|
||||||
|
return App.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void startService(final boolean playAfterConnect,
|
||||||
|
final PlayerServiceExtendedEventListener newListener) {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "startService() called with playAfterConnect=" + playAfterConnect);
|
||||||
|
}
|
||||||
|
final Context context = getCommonContext();
|
||||||
|
setListener(newListener);
|
||||||
|
if (bound) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// startService() can be called concurrently and it will give a random crashes
|
||||||
|
// and NullPointerExceptions inside the service because the service will be
|
||||||
|
// bound twice. Prevent it with unbinding first
|
||||||
|
unbind(context);
|
||||||
|
final Intent intent = new Intent(context, PlayerService.class);
|
||||||
|
intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true);
|
||||||
|
ContextCompat.startForegroundService(context, intent);
|
||||||
|
serviceConnection.doPlayAfterConnect(playAfterConnect);
|
||||||
|
bind(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void stopService() {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "stopService() called");
|
||||||
|
}
|
||||||
|
if (playerService != null) {
|
||||||
|
playerService.destroyPlayerAndStopService();
|
||||||
|
}
|
||||||
|
final Context context = getCommonContext();
|
||||||
|
unbind(context);
|
||||||
|
// destroyPlayerAndStopService() already runs the next line of code, but run it again just
|
||||||
|
// to make sure to stop the service even if playerService is null by any chance.
|
||||||
|
context.stopService(new Intent(context, PlayerService.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlayerServiceConnection implements ServiceConnection {
|
||||||
|
|
||||||
|
private boolean playAfterConnect = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param playAfterConnection Sets the value of `playAfterConnect` to pass to the {@link
|
||||||
|
* PlayerServiceExtendedEventListener#onPlayerConnected(Player, boolean)} the next time it
|
||||||
|
* is called. The value of `playAfterConnect` will be reset to false after that.
|
||||||
|
*/
|
||||||
|
public void doPlayAfterConnect(final boolean playAfterConnection) {
|
||||||
|
this.playAfterConnect = playAfterConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServiceDisconnected(final ComponentName compName) {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "Player service is disconnected");
|
||||||
|
}
|
||||||
|
|
||||||
|
final Context context = getCommonContext();
|
||||||
|
unbind(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServiceConnected(final ComponentName compName, final IBinder service) {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "Player service is connected");
|
||||||
|
}
|
||||||
|
final PlayerService.LocalBinder localBinder = (PlayerService.LocalBinder) service;
|
||||||
|
|
||||||
|
playerService = localBinder.getService();
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onServiceConnected(playerService);
|
||||||
|
}
|
||||||
|
startPlayerListener();
|
||||||
|
// ^ will call listener.onPlayerConnected() down the line if there is an active player
|
||||||
|
|
||||||
|
if (playerService != null && playerService.getPlayer() != null) {
|
||||||
|
// notify the main activity that binding the service has completed and that there is
|
||||||
|
// a player, so that it can open the bottom mini-player
|
||||||
|
NavigationHelper.sendPlayerStartedEvent(localBinder.getService());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void bind(final Context context) {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "bind() called");
|
||||||
|
}
|
||||||
|
// BIND_AUTO_CREATE starts the service if it's not already running
|
||||||
|
bound = bind(context, Context.BIND_AUTO_CREATE);
|
||||||
|
if (!bound) {
|
||||||
|
context.unbindService(serviceConnection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void tryBindIfNeeded(final Context context) {
|
||||||
|
if (!bound) {
|
||||||
|
// flags=0 means the service will not be started if it does not already exist. In this
|
||||||
|
// case the return value is not useful, as a value of "true" does not really indicate
|
||||||
|
// that the service is going to be bound.
|
||||||
|
bind(context, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean bind(final Context context, final int flags) {
|
||||||
|
final Intent serviceIntent = new Intent(context, PlayerService.class);
|
||||||
|
serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION);
|
||||||
|
return context.bindService(serviceIntent, serviceConnection, flags);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void unbind(final Context context) {
|
||||||
|
if (DEBUG) {
|
||||||
|
Log.d(TAG, "unbind() called");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bound) {
|
||||||
|
context.unbindService(serviceConnection);
|
||||||
|
bound = false;
|
||||||
|
stopPlayerListener();
|
||||||
|
playerService = null;
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onPlayerDisconnected();
|
||||||
|
listener.onServiceDisconnected();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void startPlayerListener() {
|
||||||
|
if (playerService != null) {
|
||||||
|
// setting the player listener will take care of calling relevant callbacks if the
|
||||||
|
// player in the service is (not) already active, also see playerStateListener below
|
||||||
|
playerService.setPlayerListener(playerStateListener);
|
||||||
|
}
|
||||||
|
getPlayer().ifPresent(p -> p.setFragmentListener(internalListener));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void stopPlayerListener() {
|
||||||
|
if (playerService != null) {
|
||||||
|
playerService.setPlayerListener(null);
|
||||||
|
}
|
||||||
|
getPlayer().ifPresent(p -> p.removeFragmentListener(internalListener));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This listener will be held by the players created by {@link PlayerService}.
|
||||||
|
*/
|
||||||
|
private final PlayerServiceEventListener internalListener =
|
||||||
|
new PlayerServiceEventListener() {
|
||||||
|
@Override
|
||||||
|
public void onViewCreated() {
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onViewCreated();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onFullscreenStateChanged(final boolean fullscreen) {
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onFullscreenStateChanged(fullscreen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onScreenRotationButtonClicked() {
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onScreenRotationButtonClicked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMoreOptionsLongClicked() {
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onMoreOptionsLongClicked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPlayerError(final PlaybackException error,
|
||||||
|
final boolean isCatchableException) {
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onPlayerError(error, isCatchableException);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void hideSystemUiIfNeeded() {
|
||||||
|
if (listener != null) {
|
||||||
|
listener.hideSystemUiIfNeeded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onQueueUpdate(final PlayQueue queue) {
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onQueueUpdate(queue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onPlaybackUpdate(final int state,
|
||||||
|
final int repeatMode,
|
||||||
|
final boolean shuffled,
|
||||||
|
final PlaybackParameters parameters) {
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onPlaybackUpdate(state, repeatMode, shuffled, parameters);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onProgressUpdate(final int currentProgress,
|
||||||
|
final int duration,
|
||||||
|
final int bufferPercent) {
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onProgressUpdate(currentProgress, duration, bufferPercent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onMetadataUpdate(final StreamInfo info, final PlayQueue queue) {
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onMetadataUpdate(info, queue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onServiceStopped() {
|
||||||
|
if (listener != null) {
|
||||||
|
listener.onServiceStopped();
|
||||||
|
}
|
||||||
|
unbind(getCommonContext());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This listener will be held by bound {@link PlayerService}s to notify of the player starting
|
||||||
|
* or stopping. This is necessary since the service outlives the player e.g. to answer Android
|
||||||
|
* Auto media browser queries.
|
||||||
|
*/
|
||||||
|
private final Consumer<Player> playerStateListener = (@Nullable final Player player) -> {
|
||||||
|
if (listener != null) {
|
||||||
|
if (player == null) {
|
||||||
|
// player.fragmentListener=null is already done by player.stopActivityBinding(),
|
||||||
|
// which is called by player.destroy(), which is in turn called by PlayerService
|
||||||
|
// before setting its player to null
|
||||||
|
listener.onPlayerDisconnected();
|
||||||
|
} else {
|
||||||
|
listener.onPlayerConnected(player, serviceConnection.playAfterConnect);
|
||||||
|
// reset the value of playAfterConnect: if it was true before, it is now "consumed"
|
||||||
|
serviceConnection.playAfterConnect = false;
|
||||||
|
player.setFragmentListener(internalListener);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
package org.schabi.newpipe.player.helper
|
|
||||||
|
|
||||||
import android.content.ComponentName
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.ServiceConnection
|
|
||||||
import android.os.IBinder
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import com.google.android.exoplayer2.PlaybackException
|
|
||||||
import com.google.android.exoplayer2.PlaybackParameters
|
|
||||||
import org.schabi.newpipe.App
|
|
||||||
import org.schabi.newpipe.MainActivity
|
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfo
|
|
||||||
import org.schabi.newpipe.player.Player
|
|
||||||
import org.schabi.newpipe.player.PlayerService
|
|
||||||
import org.schabi.newpipe.player.PlayerService.LocalBinder
|
|
||||||
import org.schabi.newpipe.player.PlayerType
|
|
||||||
import org.schabi.newpipe.player.event.PlayerServiceEventListener
|
|
||||||
import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener
|
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueue
|
|
||||||
import org.schabi.newpipe.util.NavigationHelper
|
|
||||||
|
|
||||||
private val DEBUG = MainActivity.DEBUG
|
|
||||||
private val TAG: String = PlayerHolder::class.java.getSimpleName()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Singleton that manages a `PlayerService`
|
|
||||||
* and can be used to control the player instance through the service.
|
|
||||||
*/
|
|
||||||
object PlayerHolder {
|
|
||||||
private var listener: PlayerServiceExtendedEventListener? = null
|
|
||||||
|
|
||||||
var isBound: Boolean = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
private var playerService: PlayerService? = null
|
|
||||||
|
|
||||||
private val player: Player?
|
|
||||||
get() = playerService?.player
|
|
||||||
|
|
||||||
// player play queue might be null e.g. while player is starting
|
|
||||||
private val playQueue: PlayQueue?
|
|
||||||
get() = this.player?.playQueue
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the current [PlayerType] of the [PlayerService] service,
|
|
||||||
* otherwise `null` if no service is running.
|
|
||||||
*
|
|
||||||
* @return Current PlayerType
|
|
||||||
*/
|
|
||||||
val type: PlayerType?
|
|
||||||
get() = this.player?.playerType
|
|
||||||
|
|
||||||
val isPlaying: Boolean
|
|
||||||
get() = this.player?.isPlaying == true
|
|
||||||
|
|
||||||
val isPlayerOpen: Boolean
|
|
||||||
get() = this.player != null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Use this method to only allow the user to manipulate the play queue (e.g. by enqueueing via
|
|
||||||
* the stream long press menu) when there actually is a play queue to manipulate.
|
|
||||||
* @return true only if the player is open and its play queue is ready (i.e. it is not null)
|
|
||||||
*/
|
|
||||||
val isPlayQueueReady: Boolean
|
|
||||||
get() = this.playQueue != null
|
|
||||||
|
|
||||||
val queueSize: Int
|
|
||||||
get() = this.playQueue?.size() ?: 0
|
|
||||||
|
|
||||||
val queuePosition: Int
|
|
||||||
get() = this.playQueue?.index ?: 0
|
|
||||||
|
|
||||||
fun setListener(newListener: PlayerServiceExtendedEventListener?) {
|
|
||||||
listener = newListener
|
|
||||||
|
|
||||||
// Force reload data from service
|
|
||||||
newListener?.let { listener ->
|
|
||||||
playerService?.let { service ->
|
|
||||||
listener.onServiceConnected(service)
|
|
||||||
startPlayerListener()
|
|
||||||
// ^ will call listener.onPlayerConnected() down the line if there is an active player
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val commonContext: Context
|
|
||||||
// helper to handle context in common place as using the same
|
|
||||||
get() = App.instance
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Connect to (and if needed start) the [PlayerService]
|
|
||||||
* and bind [PlayerServiceConnection] to it.
|
|
||||||
* If the service is already started, only set the listener.
|
|
||||||
* @param playAfterConnect If this holder’s service was already started,
|
|
||||||
* start playing immediately
|
|
||||||
* @param newListener set this listener
|
|
||||||
*/
|
|
||||||
fun startService(
|
|
||||||
playAfterConnect: Boolean,
|
|
||||||
newListener: PlayerServiceExtendedEventListener?
|
|
||||||
) {
|
|
||||||
if (DEBUG) {
|
|
||||||
Log.d(TAG, "startService() called with playAfterConnect=$playAfterConnect")
|
|
||||||
}
|
|
||||||
val context = this.commonContext
|
|
||||||
setListener(newListener)
|
|
||||||
if (this.isBound) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// startService() can be called concurrently and it will give a random crashes
|
|
||||||
// and NullPointerExceptions inside the service because the service will be
|
|
||||||
// bound twice. Prevent it with unbinding first
|
|
||||||
unbind(context)
|
|
||||||
val intent = Intent(context, PlayerService::class.java)
|
|
||||||
intent.putExtra(PlayerService.SHOULD_START_FOREGROUND_EXTRA, true)
|
|
||||||
ContextCompat.startForegroundService(context, intent)
|
|
||||||
PlayerServiceConnection.doPlayAfterConnect(playAfterConnect)
|
|
||||||
bind(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun stopService() {
|
|
||||||
if (DEBUG) {
|
|
||||||
Log.d(TAG, "stopService() called")
|
|
||||||
}
|
|
||||||
playerService?.destroyPlayerAndStopService()
|
|
||||||
val context = this.commonContext
|
|
||||||
unbind(context)
|
|
||||||
// destroyPlayerAndStopService() already runs the next line of code, but run it again just
|
|
||||||
// to make sure to stop the service even if playerService is null by any chance.
|
|
||||||
context.stopService(Intent(context, PlayerService::class.java))
|
|
||||||
}
|
|
||||||
|
|
||||||
internal object PlayerServiceConnection : ServiceConnection {
|
|
||||||
internal var playAfterConnect = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param playAfterConnection Sets the value of [playAfterConnect] to pass to the
|
|
||||||
* [PlayerServiceExtendedEventListener.onPlayerConnected] the next time it
|
|
||||||
* is called. The value of [playAfterConnect] will be reset to false after that.
|
|
||||||
*/
|
|
||||||
fun doPlayAfterConnect(playAfterConnection: Boolean) {
|
|
||||||
this.playAfterConnect = playAfterConnection
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceDisconnected(compName: ComponentName?) {
|
|
||||||
if (DEBUG) {
|
|
||||||
Log.d(TAG, "Player service is disconnected")
|
|
||||||
}
|
|
||||||
|
|
||||||
val context: Context = this@PlayerHolder.commonContext
|
|
||||||
unbind(context)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceConnected(compName: ComponentName?, service: IBinder?) {
|
|
||||||
if (DEBUG) {
|
|
||||||
Log.d(TAG, "Player service is connected")
|
|
||||||
}
|
|
||||||
val localBinder = service as LocalBinder
|
|
||||||
|
|
||||||
val s = localBinder.service
|
|
||||||
requireNotNull(s) {
|
|
||||||
"PlayerService.LocalBinder.getService() must never be" +
|
|
||||||
"null after the service connects"
|
|
||||||
}
|
|
||||||
playerService = s
|
|
||||||
listener?.let { l ->
|
|
||||||
l.onServiceConnected(s)
|
|
||||||
player?.let { l.onPlayerConnected(it, playAfterConnect) }
|
|
||||||
}
|
|
||||||
startPlayerListener()
|
|
||||||
// ^ will call listener.onPlayerConnected() down the line if there is an active player
|
|
||||||
|
|
||||||
if (playerService != null && playerService?.player != null) {
|
|
||||||
// notify the main activity that binding the service has completed and that there is
|
|
||||||
// a player, so that it can open the bottom mini-player
|
|
||||||
NavigationHelper.sendPlayerStartedEvent(localBinder.service)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bind(context: Context) {
|
|
||||||
if (DEBUG) {
|
|
||||||
Log.d(TAG, "bind() called")
|
|
||||||
}
|
|
||||||
// BIND_AUTO_CREATE starts the service if it's not already running
|
|
||||||
this.isBound = bind(context, Context.BIND_AUTO_CREATE)
|
|
||||||
if (!this.isBound) {
|
|
||||||
context.unbindService(PlayerServiceConnection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun tryBindIfNeeded(context: Context) {
|
|
||||||
if (!this.isBound) {
|
|
||||||
// flags=0 means the service will not be started if it does not already exist. In this
|
|
||||||
// case the return value is not useful, as a value of "true" does not really indicate
|
|
||||||
// that the service is going to be bound.
|
|
||||||
bind(context, 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun bind(context: Context, flags: Int): Boolean {
|
|
||||||
val serviceIntent = Intent(context, PlayerService::class.java)
|
|
||||||
serviceIntent.setAction(PlayerService.BIND_PLAYER_HOLDER_ACTION)
|
|
||||||
return context.bindService(serviceIntent, PlayerServiceConnection, flags)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun unbind(context: Context) {
|
|
||||||
if (DEBUG) {
|
|
||||||
Log.d(TAG, "unbind() called")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isBound) {
|
|
||||||
context.unbindService(PlayerServiceConnection)
|
|
||||||
this.isBound = false
|
|
||||||
stopPlayerListener()
|
|
||||||
playerService = null
|
|
||||||
listener?.onPlayerDisconnected()
|
|
||||||
listener?.onServiceDisconnected()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun startPlayerListener() {
|
|
||||||
// setting the player listener will take care of calling relevant callbacks if the
|
|
||||||
// player in the service is (not) already active, also see playerStateListener below
|
|
||||||
playerService?.setPlayerListener(playerStateListener)
|
|
||||||
this.player?.setFragmentListener(HolderPlayerServiceEventListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun stopPlayerListener() {
|
|
||||||
playerService?.setPlayerListener(null)
|
|
||||||
this.player?.removeFragmentListener(HolderPlayerServiceEventListener)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This listener will be held by the players created by [PlayerService].
|
|
||||||
*/
|
|
||||||
private object HolderPlayerServiceEventListener : PlayerServiceEventListener {
|
|
||||||
override fun onViewCreated() {
|
|
||||||
listener?.onViewCreated()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFullscreenStateChanged(fullscreen: Boolean) {
|
|
||||||
listener?.onFullscreenStateChanged(fullscreen)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFullscreenToggleButtonClicked() {
|
|
||||||
listener?.onFullscreenToggleButtonClicked()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMoreOptionsLongClicked() {
|
|
||||||
listener?.onMoreOptionsLongClicked()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPlayerError(
|
|
||||||
error: PlaybackException?,
|
|
||||||
isCatchableException: Boolean
|
|
||||||
) {
|
|
||||||
listener?.onPlayerError(error, isCatchableException)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun hideSystemUiIfNeeded() {
|
|
||||||
listener?.hideSystemUiIfNeeded()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onQueueUpdate(queue: PlayQueue?) {
|
|
||||||
listener?.onQueueUpdate(queue)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPlaybackUpdate(
|
|
||||||
state: Int,
|
|
||||||
repeatMode: Int,
|
|
||||||
shuffled: Boolean,
|
|
||||||
parameters: PlaybackParameters?
|
|
||||||
) {
|
|
||||||
listener?.onPlaybackUpdate(state, repeatMode, shuffled, parameters)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onProgressUpdate(
|
|
||||||
currentProgress: Int,
|
|
||||||
duration: Int,
|
|
||||||
bufferPercent: Int
|
|
||||||
) {
|
|
||||||
listener?.onProgressUpdate(currentProgress, duration, bufferPercent)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMetadataUpdate(info: StreamInfo?, queue: PlayQueue?) {
|
|
||||||
listener?.onMetadataUpdate(info, queue)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onServiceStopped() {
|
|
||||||
listener?.onServiceStopped()
|
|
||||||
unbind(this@PlayerHolder.commonContext)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This listener will be held by bound [PlayerService]s to notify of the player starting
|
|
||||||
* or stopping. This is necessary since the service outlives the player e.g. to answer Android
|
|
||||||
* Auto media browser queries.
|
|
||||||
*/
|
|
||||||
private val playerStateListener: (Player?) -> Unit = { player: Player? ->
|
|
||||||
listener?.let { l ->
|
|
||||||
if (player == null) {
|
|
||||||
// player.fragmentListener=null is already done by player.stopActivityBinding(),
|
|
||||||
// which is called by player.destroy(), which is in turn called by PlayerService
|
|
||||||
// before setting its player to null
|
|
||||||
l.onPlayerDisconnected()
|
|
||||||
} else {
|
|
||||||
l.onPlayerConnected(player, PlayerServiceConnection.playAfterConnect)
|
|
||||||
// reset the value of playAfterConnect: if it was true before, it is now "consumed"
|
|
||||||
PlayerServiceConnection.playAfterConnect = false
|
|
||||||
player.setFragmentListener(HolderPlayerServiceEventListener)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ import android.support.v4.media.MediaDescriptionCompat
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.DrawableRes
|
import androidx.annotation.DrawableRes
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.core.os.bundleOf
|
|
||||||
import androidx.media.MediaBrowserServiceCompat
|
import androidx.media.MediaBrowserServiceCompat
|
||||||
import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT
|
import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT
|
||||||
import androidx.media.MediaBrowserServiceCompat.Result
|
import androidx.media.MediaBrowserServiceCompat.Result
|
||||||
@@ -18,6 +17,7 @@ import io.reactivex.rxjava3.core.Flowable
|
|||||||
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.schedulers.Schedulers
|
import io.reactivex.rxjava3.schedulers.Schedulers
|
||||||
|
import java.util.function.Consumer
|
||||||
import org.schabi.newpipe.MainActivity.DEBUG
|
import org.schabi.newpipe.MainActivity.DEBUG
|
||||||
import org.schabi.newpipe.NewPipeDatabase
|
import org.schabi.newpipe.NewPipeDatabase
|
||||||
import org.schabi.newpipe.R
|
import org.schabi.newpipe.R
|
||||||
@@ -47,7 +47,8 @@ import org.schabi.newpipe.util.image.ImageStrategy
|
|||||||
*/
|
*/
|
||||||
class MediaBrowserImpl(
|
class MediaBrowserImpl(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
notifyChildrenChanged: (parentId: String) -> Unit
|
// parentId
|
||||||
|
notifyChildrenChanged: Consumer<String>
|
||||||
) {
|
) {
|
||||||
private val packageValidator = PackageValidator(context)
|
private val packageValidator = PackageValidator(context)
|
||||||
private val database = NewPipeDatabase.getInstance(context)
|
private val database = NewPipeDatabase.getInstance(context)
|
||||||
@@ -55,7 +56,9 @@ class MediaBrowserImpl(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
// this will listen to changes in the bookmarks until this MediaBrowserImpl is dispose()d
|
// this will listen to changes in the bookmarks until this MediaBrowserImpl is dispose()d
|
||||||
disposables.add(getMergedPlaylists().subscribe { notifyChildrenChanged(ID_BOOKMARKS) })
|
disposables.add(
|
||||||
|
getMergedPlaylists().subscribe { notifyChildrenChanged.accept(ID_BOOKMARKS) }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
//region Cleanup
|
//region Cleanup
|
||||||
@@ -195,16 +198,17 @@ class MediaBrowserImpl(
|
|||||||
|
|
||||||
private fun createPlaylistMediaItem(playlist: PlaylistLocalItem): MediaBrowserCompat.MediaItem {
|
private fun createPlaylistMediaItem(playlist: PlaylistLocalItem): MediaBrowserCompat.MediaItem {
|
||||||
val builder = MediaDescriptionCompat.Builder()
|
val builder = MediaDescriptionCompat.Builder()
|
||||||
|
builder
|
||||||
.setMediaId(createMediaIdForInfoItem(playlist is PlaylistRemoteEntity, playlist.uid))
|
.setMediaId(createMediaIdForInfoItem(playlist is PlaylistRemoteEntity, playlist.uid))
|
||||||
.setTitle(playlist.orderingName)
|
.setTitle(playlist.orderingName)
|
||||||
.setIconUri(imageUriOrNullIfDisabled(playlist.thumbnailUrl))
|
.setIconUri(imageUriOrNullIfDisabled(playlist.thumbnailUrl))
|
||||||
.setExtras(
|
|
||||||
bundleOf(
|
|
||||||
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE
|
|
||||||
to context.resources.getString(R.string.tab_bookmarks)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
val extras = Bundle()
|
||||||
|
extras.putString(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE,
|
||||||
|
context.resources.getString(R.string.tab_bookmarks)
|
||||||
|
)
|
||||||
|
builder.setExtras(extras)
|
||||||
return MediaBrowserCompat.MediaItem(
|
return MediaBrowserCompat.MediaItem(
|
||||||
builder.build(),
|
builder.build(),
|
||||||
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
MediaBrowserCompat.MediaItem.FLAG_BROWSABLE
|
||||||
@@ -213,9 +217,8 @@ class MediaBrowserImpl(
|
|||||||
|
|
||||||
private fun createInfoItemMediaItem(item: InfoItem): MediaBrowserCompat.MediaItem? {
|
private fun createInfoItemMediaItem(item: InfoItem): MediaBrowserCompat.MediaItem? {
|
||||||
val builder = MediaDescriptionCompat.Builder()
|
val builder = MediaDescriptionCompat.Builder()
|
||||||
.setMediaId(createMediaIdForInfoItem(item))
|
builder.setMediaId(createMediaIdForInfoItem(item))
|
||||||
.setTitle(item.name)
|
.setTitle(item.name)
|
||||||
.setIconUri(ImageStrategy.choosePreferredImage(item.thumbnails)?.toUri())
|
|
||||||
|
|
||||||
when (item.infoType) {
|
when (item.infoType) {
|
||||||
InfoType.STREAM -> builder.setSubtitle((item as StreamInfoItem).uploaderName)
|
InfoType.STREAM -> builder.setSubtitle((item as StreamInfoItem).uploaderName)
|
||||||
@@ -224,6 +227,10 @@ class MediaBrowserImpl(
|
|||||||
else -> return null
|
else -> return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
|
||||||
|
builder.setIconUri(imageUriOrNullIfDisabled(it))
|
||||||
|
}
|
||||||
|
|
||||||
return MediaBrowserCompat.MediaItem(
|
return MediaBrowserCompat.MediaItem(
|
||||||
builder.build(),
|
builder.build(),
|
||||||
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
MediaBrowserCompat.MediaItem.FLAG_PLAYABLE
|
||||||
@@ -264,7 +271,7 @@ class MediaBrowserImpl(
|
|||||||
index: Int
|
index: Int
|
||||||
): MediaBrowserCompat.MediaItem {
|
): MediaBrowserCompat.MediaItem {
|
||||||
val builder = MediaDescriptionCompat.Builder()
|
val builder = MediaDescriptionCompat.Builder()
|
||||||
.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index))
|
builder.setMediaId(createMediaIdForPlaylistIndex(false, playlistId, index))
|
||||||
.setTitle(item.streamEntity.title)
|
.setTitle(item.streamEntity.title)
|
||||||
.setSubtitle(item.streamEntity.uploader)
|
.setSubtitle(item.streamEntity.uploader)
|
||||||
.setIconUri(imageUriOrNullIfDisabled(item.streamEntity.thumbnailUrl))
|
.setIconUri(imageUriOrNullIfDisabled(item.streamEntity.thumbnailUrl))
|
||||||
@@ -284,7 +291,10 @@ class MediaBrowserImpl(
|
|||||||
builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index))
|
builder.setMediaId(createMediaIdForPlaylistIndex(true, playlistId, index))
|
||||||
.setTitle(item.name)
|
.setTitle(item.name)
|
||||||
.setSubtitle(item.uploaderName)
|
.setSubtitle(item.uploaderName)
|
||||||
.setIconUri(ImageStrategy.choosePreferredImage(item.thumbnails)?.toUri())
|
|
||||||
|
ImageStrategy.choosePreferredImage(item.thumbnails)?.let {
|
||||||
|
builder.setIconUri(imageUriOrNullIfDisabled(it))
|
||||||
|
}
|
||||||
|
|
||||||
return MediaBrowserCompat.MediaItem(
|
return MediaBrowserCompat.MediaItem(
|
||||||
builder.build(),
|
builder.build(),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.os.Bundle
|
|||||||
import android.os.ResultReceiver
|
import android.os.ResultReceiver
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import com.google.android.exoplayer2.Player
|
import com.google.android.exoplayer2.Player
|
||||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.PlaybackPreparer
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.PlaybackPreparer
|
||||||
@@ -30,7 +31,6 @@ import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue
|
|||||||
import org.schabi.newpipe.player.playqueue.SinglePlayQueue
|
import org.schabi.newpipe.player.playqueue.SinglePlayQueue
|
||||||
import org.schabi.newpipe.util.ChannelTabHelper
|
import org.schabi.newpipe.util.ChannelTabHelper
|
||||||
import org.schabi.newpipe.util.ExtractorHelper
|
import org.schabi.newpipe.util.ExtractorHelper
|
||||||
import org.schabi.newpipe.util.Localization
|
|
||||||
import org.schabi.newpipe.util.NavigationHelper
|
import org.schabi.newpipe.util.NavigationHelper
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,7 +49,7 @@ import org.schabi.newpipe.util.NavigationHelper
|
|||||||
*/
|
*/
|
||||||
class MediaBrowserPlaybackPreparer(
|
class MediaBrowserPlaybackPreparer(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val setMediaSessionError: BiConsumer<String, Int>, // error string, error code
|
private val setMediaSessionError: BiConsumer<CharSequence, Int>, // error string, error code
|
||||||
private val clearMediaSessionError: Runnable,
|
private val clearMediaSessionError: Runnable,
|
||||||
private val onPrepare: Consumer<Boolean>
|
private val onPrepare: Consumer<Boolean>
|
||||||
) : PlaybackPreparer {
|
) : PlaybackPreparer {
|
||||||
@@ -111,14 +111,14 @@ class MediaBrowserPlaybackPreparer(
|
|||||||
//region Errors
|
//region Errors
|
||||||
private fun onUnsupportedError() {
|
private fun onUnsupportedError() {
|
||||||
setMediaSessionError.accept(
|
setMediaSessionError.accept(
|
||||||
Localization.compatGetString(context, R.string.content_not_supported),
|
ContextCompat.getString(context, R.string.content_not_supported),
|
||||||
PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED
|
PlaybackStateCompat.ERROR_CODE_NOT_SUPPORTED
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun onPrepareError(throwable: Throwable) {
|
private fun onPrepareError(throwable: Throwable) {
|
||||||
setMediaSessionError.accept(
|
setMediaSessionError.accept(
|
||||||
ErrorInfo.getMessage(throwable, null, null).getString(context),
|
ErrorInfo.getMessage(throwable, null, null).getText(context),
|
||||||
PlaybackStateCompat.ERROR_CODE_APP_ERROR
|
PlaybackStateCompat.ERROR_CODE_APP_ERROR
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,10 +124,8 @@ public class MediaSessionPlayerUi extends PlayerUi
|
|||||||
MediaButtonReceiver.handleIntent(mediaSession, intent);
|
MediaButtonReceiver.handleIntent(mediaSession, intent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Optional<MediaSessionCompat.Token> getSessionToken() {
|
||||||
@NonNull
|
return Optional.ofNullable(mediaSession).map(MediaSessionCompat::getSessionToken);
|
||||||
public MediaSessionCompat.Token getSessionToken() {
|
|
||||||
return mediaSession.getSessionToken();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -140,10 +138,7 @@ public class MediaSessionPlayerUi extends PlayerUi
|
|||||||
public void play() {
|
public void play() {
|
||||||
player.play();
|
player.play();
|
||||||
// hide the player controls even if the play command came from the media session
|
// hide the player controls even if the play command came from the media session
|
||||||
final VideoPlayerUi ui = player.UIs().get(VideoPlayerUi.class);
|
player.UIs().get(VideoPlayerUi.class).ifPresent(ui -> ui.hideControls(0, 0));
|
||||||
if (ui != null) {
|
|
||||||
ui.hideControls(0, 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -106,10 +106,10 @@ public final class NotificationUtil {
|
|||||||
final int[] compactSlots = initializeNotificationSlots();
|
final int[] compactSlots = initializeNotificationSlots();
|
||||||
mediaStyle.setShowActionsInCompactView(compactSlots);
|
mediaStyle.setShowActionsInCompactView(compactSlots);
|
||||||
}
|
}
|
||||||
@Nullable final MediaSessionPlayerUi ui = player.UIs().get(MediaSessionPlayerUi.class);
|
player.UIs()
|
||||||
if (ui != null) {
|
.get(MediaSessionPlayerUi.class)
|
||||||
mediaStyle.setMediaSession(ui.getSessionToken());
|
.flatMap(MediaSessionPlayerUi::getSessionToken)
|
||||||
}
|
.ifPresent(mediaStyle::setMediaSession);
|
||||||
|
|
||||||
// setup notification builder
|
// setup notification builder
|
||||||
final var builder = setupNotificationBuilder(player.getContext(), mediaStyle)
|
final var builder = setupNotificationBuilder(player.getContext(), mediaStyle)
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ import io.reactivex.rxjava3.internal.subscriptions.EmptySubscription;
|
|||||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||||
import io.reactivex.rxjava3.subjects.PublishSubject;
|
import io.reactivex.rxjava3.subjects.PublishSubject;
|
||||||
|
|
||||||
import static org.schabi.newpipe.BuildConfig.DEBUG;
|
|
||||||
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.util.ServiceHelper.getCacheExpirationMillis;
|
import static org.schabi.newpipe.util.ServiceHelper.getCacheExpirationMillis;
|
||||||
|
|
||||||
public class MediaSourceManager {
|
public class MediaSourceManager {
|
||||||
|
|||||||
@@ -0,0 +1,575 @@
|
|||||||
|
package org.schabi.newpipe.player.playqueue;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.MainActivity;
|
||||||
|
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.AppendEvent;
|
||||||
|
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ErrorEvent;
|
||||||
|
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.InitEvent;
|
||||||
|
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.MoveEvent;
|
||||||
|
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RecoveryEvent;
|
||||||
|
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RemoveEvent;
|
||||||
|
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ReorderEvent;
|
||||||
|
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.SelectEvent;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||||
|
import io.reactivex.rxjava3.core.BackpressureStrategy;
|
||||||
|
import io.reactivex.rxjava3.core.Flowable;
|
||||||
|
import io.reactivex.rxjava3.subjects.PublishSubject;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlayQueue is responsible for keeping track of a list of streams and the index of
|
||||||
|
* the stream that should be currently playing.
|
||||||
|
* <p>
|
||||||
|
* This class contains basic manipulation of a playlist while also functions as a
|
||||||
|
* message bus, providing all listeners with new updates to the play queue.
|
||||||
|
* </p>
|
||||||
|
* <p>
|
||||||
|
* This class can be serialized for passing intents, but in order to start the
|
||||||
|
* message bus, it must be initialized.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public abstract class PlayQueue implements Serializable {
|
||||||
|
public static final boolean DEBUG = MainActivity.DEBUG;
|
||||||
|
@NonNull
|
||||||
|
private final AtomicInteger queueIndex;
|
||||||
|
private final List<PlayQueueItem> history = new ArrayList<>();
|
||||||
|
|
||||||
|
private List<PlayQueueItem> backup;
|
||||||
|
private List<PlayQueueItem> streams;
|
||||||
|
|
||||||
|
private transient PublishSubject<PlayQueueEvent> eventBroadcast;
|
||||||
|
private transient Flowable<PlayQueueEvent> broadcastReceiver;
|
||||||
|
private transient boolean disposed = false;
|
||||||
|
|
||||||
|
PlayQueue(final int index, final List<PlayQueueItem> startWith) {
|
||||||
|
streams = new ArrayList<>(startWith);
|
||||||
|
|
||||||
|
if (streams.size() > index) {
|
||||||
|
history.add(streams.get(index));
|
||||||
|
}
|
||||||
|
|
||||||
|
queueIndex = new AtomicInteger(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Playlist actions
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the play queue message buses.
|
||||||
|
* <p>
|
||||||
|
* Also starts a self reporter for logging if debug mode is enabled.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public void init() {
|
||||||
|
eventBroadcast = PublishSubject.create();
|
||||||
|
|
||||||
|
broadcastReceiver = eventBroadcast.toFlowable(BackpressureStrategy.BUFFER)
|
||||||
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
|
.startWithItem(new InitEvent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispose the play queue by stopping all message buses.
|
||||||
|
*/
|
||||||
|
public void dispose() {
|
||||||
|
if (eventBroadcast != null) {
|
||||||
|
eventBroadcast.onComplete();
|
||||||
|
}
|
||||||
|
|
||||||
|
eventBroadcast = null;
|
||||||
|
broadcastReceiver = null;
|
||||||
|
disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the queue is complete.
|
||||||
|
* <p>
|
||||||
|
* A queue is complete if it has loaded all items in an external playlist
|
||||||
|
* single stream or local queues are always complete.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @return whether the queue is complete
|
||||||
|
*/
|
||||||
|
public abstract boolean isComplete();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load partial queue in the background, does nothing if the queue is complete.
|
||||||
|
*/
|
||||||
|
public abstract void fetch();
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Readonly ops
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the current index that should be played
|
||||||
|
*/
|
||||||
|
public int getIndex() {
|
||||||
|
return queueIndex.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the current playing index to a new index.
|
||||||
|
* <p>
|
||||||
|
* This method is guarded using in a circular manner for index exceeding the play queue size.
|
||||||
|
* </p>
|
||||||
|
* <p>
|
||||||
|
* Will emit a {@link SelectEvent} if the index is not the current playing index.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param index the index to be set
|
||||||
|
*/
|
||||||
|
public synchronized void setIndex(final int index) {
|
||||||
|
final int oldIndex = getIndex();
|
||||||
|
|
||||||
|
final int newIndex;
|
||||||
|
|
||||||
|
if (index < 0) {
|
||||||
|
newIndex = 0;
|
||||||
|
} else if (index < streams.size()) {
|
||||||
|
// Regular assignment for index in bounds
|
||||||
|
newIndex = index;
|
||||||
|
} else if (streams.isEmpty()) {
|
||||||
|
// Out of bounds from here on
|
||||||
|
// Need to check if stream is empty to prevent arithmetic error and negative index
|
||||||
|
newIndex = 0;
|
||||||
|
} else if (isComplete()) {
|
||||||
|
// Circular indexing
|
||||||
|
newIndex = index % streams.size();
|
||||||
|
} else {
|
||||||
|
// Index of last element
|
||||||
|
newIndex = streams.size() - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
queueIndex.set(newIndex);
|
||||||
|
|
||||||
|
if (oldIndex != newIndex) {
|
||||||
|
history.add(streams.get(newIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
TODO: Documentation states that a SelectEvent will only be emitted if the new index is...
|
||||||
|
different from the old one but this is emitted regardless? Not sure what this what it does
|
||||||
|
exactly so I won't touch it
|
||||||
|
*/
|
||||||
|
broadcast(new SelectEvent(oldIndex, newIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the current item that should be played, or null if the queue is empty
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public PlayQueueItem getItem() {
|
||||||
|
return getItem(getIndex());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param index the index of the item to return
|
||||||
|
* @return the item at the given index, or null if the index is out of bounds
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public PlayQueueItem getItem(final int index) {
|
||||||
|
if (index < 0 || index >= streams.size()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return streams.get(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the index of the given item using referential equality.
|
||||||
|
* May be null despite play queue contains identical item.
|
||||||
|
*
|
||||||
|
* @param item the item to find the index of
|
||||||
|
* @return the index of the given item
|
||||||
|
*/
|
||||||
|
public int indexOf(@NonNull final PlayQueueItem item) {
|
||||||
|
return streams.indexOf(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the current size of play queue.
|
||||||
|
*/
|
||||||
|
public int size() {
|
||||||
|
return streams.size();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the play queue is empty.
|
||||||
|
*
|
||||||
|
* @return whether the play queue is empty
|
||||||
|
*/
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return streams.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if the current play queue is shuffled.
|
||||||
|
*
|
||||||
|
* @return whether the play queue is shuffled
|
||||||
|
*/
|
||||||
|
public boolean isShuffled() {
|
||||||
|
return backup != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return an immutable view of the play queue
|
||||||
|
*/
|
||||||
|
@NonNull
|
||||||
|
public List<PlayQueueItem> getStreams() {
|
||||||
|
return Collections.unmodifiableList(streams);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Write ops
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the play queue's update broadcast.
|
||||||
|
* May be null if the play queue message bus is not initialized.
|
||||||
|
*
|
||||||
|
* @return the play queue's update broadcast
|
||||||
|
*/
|
||||||
|
@Nullable
|
||||||
|
public Flowable<PlayQueueEvent> getBroadcastReceiver() {
|
||||||
|
return broadcastReceiver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the current playing index by an offset amount.
|
||||||
|
* <p>
|
||||||
|
* Will emit a {@link SelectEvent} if offset is non-zero.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param offset the offset relative to the current index
|
||||||
|
*/
|
||||||
|
public synchronized void offsetIndex(final int offset) {
|
||||||
|
setIndex(getIndex() + offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies that a change has occurred.
|
||||||
|
*/
|
||||||
|
public synchronized void notifyChange() {
|
||||||
|
broadcast(new AppendEvent(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends the given {@link PlayQueueItem}s to the current play queue.
|
||||||
|
* <p>
|
||||||
|
* If the play queue is shuffled, then append the items to the backup queue as is and
|
||||||
|
* append the shuffle items to the play queue.
|
||||||
|
* </p>
|
||||||
|
* <p>
|
||||||
|
* Will emit a {@link AppendEvent} on any given context.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param items {@link PlayQueueItem}s to append
|
||||||
|
*/
|
||||||
|
public synchronized void append(@NonNull final List<PlayQueueItem> items) {
|
||||||
|
final List<PlayQueueItem> itemList = new ArrayList<>(items);
|
||||||
|
|
||||||
|
if (isShuffled()) {
|
||||||
|
backup.addAll(itemList);
|
||||||
|
Collections.shuffle(itemList);
|
||||||
|
}
|
||||||
|
if (!streams.isEmpty() && streams.get(streams.size() - 1).isAutoQueued()
|
||||||
|
&& !itemList.get(0).isAutoQueued()) {
|
||||||
|
streams.remove(streams.size() - 1);
|
||||||
|
}
|
||||||
|
streams.addAll(itemList);
|
||||||
|
|
||||||
|
broadcast(new AppendEvent(itemList.size()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add the given item after the current stream.
|
||||||
|
*
|
||||||
|
* @param item item to add.
|
||||||
|
* @param skipIfSame if set, skip adding if the next stream is the same stream.
|
||||||
|
*/
|
||||||
|
public void enqueueNext(@NonNull final PlayQueueItem item, final boolean skipIfSame) {
|
||||||
|
final int currentIndex = getIndex();
|
||||||
|
// if the next item is the same item as the one we want to enqueue, skip if flag is true
|
||||||
|
if (skipIfSame && item.isSameItem(getItem(currentIndex + 1))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
append(List.of(item));
|
||||||
|
move(size() - 1, currentIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes the item at the given index from the play queue.
|
||||||
|
* <p>
|
||||||
|
* The current playing index will decrement if it is greater than the index being removed.
|
||||||
|
* On cases where the current playing index exceeds the playlist range, it is set to 0.
|
||||||
|
* </p>
|
||||||
|
* <p>
|
||||||
|
* Will emit a {@link RemoveEvent} if the index is within the play queue index range.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param index the index of the item to remove
|
||||||
|
*/
|
||||||
|
public synchronized void remove(final int index) {
|
||||||
|
if (index >= streams.size() || index < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
removeInternal(index);
|
||||||
|
broadcast(new RemoveEvent(index, getIndex()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report an exception for the item at the current index in order and skip to the next one
|
||||||
|
* <p>
|
||||||
|
* This is done as a separate event as the underlying manager may have
|
||||||
|
* different implementation regarding exceptions.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public synchronized void error() {
|
||||||
|
final int oldIndex = getIndex();
|
||||||
|
queueIndex.incrementAndGet();
|
||||||
|
if (streams.size() > queueIndex.get()) {
|
||||||
|
history.add(streams.get(queueIndex.get()));
|
||||||
|
}
|
||||||
|
broadcast(new ErrorEvent(oldIndex, getIndex()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void removeInternal(final int removeIndex) {
|
||||||
|
final int currentIndex = queueIndex.get();
|
||||||
|
final int size = size();
|
||||||
|
|
||||||
|
if (currentIndex > removeIndex) {
|
||||||
|
queueIndex.decrementAndGet();
|
||||||
|
|
||||||
|
} else if (currentIndex >= size) {
|
||||||
|
queueIndex.set(currentIndex % (size - 1));
|
||||||
|
|
||||||
|
} else if (currentIndex == removeIndex && currentIndex == size - 1) {
|
||||||
|
queueIndex.set(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backup != null) {
|
||||||
|
backup.remove(getItem(removeIndex));
|
||||||
|
}
|
||||||
|
|
||||||
|
history.remove(streams.remove(removeIndex));
|
||||||
|
if (streams.size() > queueIndex.get()) {
|
||||||
|
history.add(streams.get(queueIndex.get()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves a queue item at the source index to the target index.
|
||||||
|
* <p>
|
||||||
|
* If the item being moved is the currently playing, then the current playing index is set
|
||||||
|
* to that of the target.
|
||||||
|
* If the moved item is not the currently playing and moves to an index <b>AFTER</b> the
|
||||||
|
* current playing index, then the current playing index is decremented.
|
||||||
|
* Vice versa if the an item after the currently playing is moved <b>BEFORE</b>.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param source the original index of the item
|
||||||
|
* @param target the new index of the item
|
||||||
|
*/
|
||||||
|
public synchronized void move(final int source, final int target) {
|
||||||
|
if (source < 0 || target < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (source >= streams.size() || target >= streams.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final int current = getIndex();
|
||||||
|
if (source == current) {
|
||||||
|
queueIndex.set(target);
|
||||||
|
} else if (source < current && target >= current) {
|
||||||
|
queueIndex.decrementAndGet();
|
||||||
|
} else if (source > current && target <= current) {
|
||||||
|
queueIndex.incrementAndGet();
|
||||||
|
}
|
||||||
|
|
||||||
|
final PlayQueueItem playQueueItem = streams.remove(source);
|
||||||
|
playQueueItem.setAutoQueued(false);
|
||||||
|
streams.add(target, playQueueItem);
|
||||||
|
broadcast(new MoveEvent(source, target));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the recovery record of the item at the index.
|
||||||
|
* <p>
|
||||||
|
* Broadcasts a recovery event.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param index index of the item
|
||||||
|
* @param position the recovery position
|
||||||
|
*/
|
||||||
|
public synchronized void setRecovery(final int index, final long position) {
|
||||||
|
if (index < 0 || index >= streams.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
streams.get(index).setRecoveryPosition(position);
|
||||||
|
broadcast(new RecoveryEvent(index, position));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke the recovery record of the item at the index.
|
||||||
|
* <p>
|
||||||
|
* Broadcasts a recovery event.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @param index index of the item
|
||||||
|
*/
|
||||||
|
public synchronized void unsetRecovery(final int index) {
|
||||||
|
setRecovery(index, PlayQueueItem.RECOVERY_UNSET);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shuffles the current play queue
|
||||||
|
* <p>
|
||||||
|
* This method first backs up the existing play queue and item being played. Then a newly
|
||||||
|
* shuffled play queue will be generated along with currently playing item placed at the
|
||||||
|
* beginning of the queue. This item will also be added to the history.
|
||||||
|
* </p>
|
||||||
|
* <p>
|
||||||
|
* Will emit a {@link ReorderEvent} if shuffled.
|
||||||
|
* </p>
|
||||||
|
*
|
||||||
|
* @implNote Does nothing if the queue has a size <= 2 (the currently playing video must stay on
|
||||||
|
* top, so shuffling a size-2 list does nothing)
|
||||||
|
*/
|
||||||
|
public synchronized void shuffle() {
|
||||||
|
// Create a backup if it doesn't already exist
|
||||||
|
// Note: The backup-list has to be created at all cost (even when size <= 2).
|
||||||
|
// Otherwise it's not possible to enter shuffle-mode!
|
||||||
|
if (backup == null) {
|
||||||
|
backup = new ArrayList<>(streams);
|
||||||
|
}
|
||||||
|
// Can't shuffle a list that's empty or only has one element
|
||||||
|
if (size() <= 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final int originalIndex = getIndex();
|
||||||
|
final PlayQueueItem currentItem = getItem();
|
||||||
|
|
||||||
|
Collections.shuffle(streams);
|
||||||
|
|
||||||
|
// Move currentItem to the head of the queue
|
||||||
|
streams.remove(currentItem);
|
||||||
|
streams.add(0, currentItem);
|
||||||
|
queueIndex.set(0);
|
||||||
|
|
||||||
|
history.add(currentItem);
|
||||||
|
|
||||||
|
broadcast(new ReorderEvent(originalIndex, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unshuffles the current play queue if a backup play queue exists.
|
||||||
|
* <p>
|
||||||
|
* This method undoes shuffling and index will be set to the previously playing item if found,
|
||||||
|
* otherwise, the index will reset to 0.
|
||||||
|
* </p>
|
||||||
|
* <p>
|
||||||
|
* Will emit a {@link ReorderEvent} if a backup exists.
|
||||||
|
* </p>
|
||||||
|
*/
|
||||||
|
public synchronized void unshuffle() {
|
||||||
|
if (backup == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final int originIndex = getIndex();
|
||||||
|
final PlayQueueItem current = getItem();
|
||||||
|
|
||||||
|
streams = backup;
|
||||||
|
backup = null;
|
||||||
|
|
||||||
|
final int newIndex = streams.indexOf(current);
|
||||||
|
if (newIndex != -1) {
|
||||||
|
queueIndex.set(newIndex);
|
||||||
|
} else {
|
||||||
|
queueIndex.set(0);
|
||||||
|
}
|
||||||
|
if (streams.size() > queueIndex.get()) {
|
||||||
|
history.add(streams.get(queueIndex.get()));
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast(new ReorderEvent(originIndex, queueIndex.get()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects previous played item.
|
||||||
|
*
|
||||||
|
* This method removes currently playing item from history and
|
||||||
|
* starts playing the last item from history if it exists
|
||||||
|
*
|
||||||
|
* @return true if history is not empty and the item can be played
|
||||||
|
* */
|
||||||
|
public synchronized boolean previous() {
|
||||||
|
if (history.size() <= 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
history.remove(history.size() - 1);
|
||||||
|
|
||||||
|
final PlayQueueItem last = history.remove(history.size() - 1);
|
||||||
|
setIndex(indexOf(last));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Compares two PlayQueues. Useful when a user switches players but queue is the same so
|
||||||
|
* we don't have to do anything with new queue.
|
||||||
|
* This method also gives a chance to track history of items in a queue in
|
||||||
|
* VideoDetailFragment without duplicating items from two identical queues
|
||||||
|
*/
|
||||||
|
public boolean equalStreams(@Nullable final PlayQueue other) {
|
||||||
|
if (other == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (size() != other.size()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < size(); i++) {
|
||||||
|
final PlayQueueItem stream = streams.get(i);
|
||||||
|
final PlayQueueItem otherStream = other.streams.get(i);
|
||||||
|
// Check is based on serviceId and URL
|
||||||
|
if (!stream.isSameItem(otherStream)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean equalStreamsAndIndex(@Nullable final PlayQueue other) {
|
||||||
|
if (equalStreams(other)) {
|
||||||
|
//noinspection ConstantConditions
|
||||||
|
return other.getIndex() == getIndex(); //NOSONAR: other is not null
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isDisposed() {
|
||||||
|
return disposed;
|
||||||
|
}
|
||||||
|
/*//////////////////////////////////////////////////////////////////////////
|
||||||
|
// Rx Broadcast
|
||||||
|
//////////////////////////////////////////////////////////////////////////*/
|
||||||
|
|
||||||
|
private void broadcast(@NonNull final PlayQueueEvent event) {
|
||||||
|
if (eventBroadcast != null) {
|
||||||
|
eventBroadcast.onNext(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,511 +0,0 @@
|
|||||||
package org.schabi.newpipe.player.playqueue
|
|
||||||
|
|
||||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
|
|
||||||
import io.reactivex.rxjava3.core.BackpressureStrategy
|
|
||||||
import io.reactivex.rxjava3.core.Flowable
|
|
||||||
import io.reactivex.rxjava3.subjects.PublishSubject
|
|
||||||
import java.io.Serializable
|
|
||||||
import java.util.Collections
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger
|
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.AppendEvent
|
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ErrorEvent
|
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.InitEvent
|
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.MoveEvent
|
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RecoveryEvent
|
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.RemoveEvent
|
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.ReorderEvent
|
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueueEvent.SelectEvent
|
|
||||||
import org.schabi.newpipe.player.playqueue.PlayQueueItem
|
|
||||||
|
|
||||||
/**
|
|
||||||
* PlayQueue is responsible for keeping track of a list of streams and the index of
|
|
||||||
* the stream that should be currently playing.
|
|
||||||
*
|
|
||||||
* This class contains basic manipulation of a playlist while also functions as a
|
|
||||||
* message bus, providing all listeners with new updates to the play queue.
|
|
||||||
*
|
|
||||||
* This class can be serialized for passing intents, but in order to start the
|
|
||||||
* message bus, it must be initialized.
|
|
||||||
*/
|
|
||||||
abstract class PlayQueue internal constructor(
|
|
||||||
index: Int,
|
|
||||||
startWith: List<PlayQueueItem>
|
|
||||||
) : Serializable {
|
|
||||||
private val queueIndex = AtomicInteger(index)
|
|
||||||
private val history = mutableListOf<PlayQueueItem>()
|
|
||||||
private var backup = mutableListOf<PlayQueueItem>()
|
|
||||||
private var streams = startWith.toMutableList()
|
|
||||||
|
|
||||||
@Transient
|
|
||||||
private var eventBroadcast: PublishSubject<PlayQueueEvent>? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the play queue's update broadcast.
|
|
||||||
* May be null if the play queue message bus is not initialized.
|
|
||||||
*
|
|
||||||
* @return the play queue's update broadcast
|
|
||||||
*/
|
|
||||||
@Transient
|
|
||||||
var broadcastReceiver: Flowable<PlayQueueEvent>? = null
|
|
||||||
private set
|
|
||||||
|
|
||||||
@Transient
|
|
||||||
var isDisposed: Boolean = false
|
|
||||||
private set
|
|
||||||
|
|
||||||
init {
|
|
||||||
if (streams.size > index) {
|
|
||||||
history.add(streams[index])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Playlist actions
|
|
||||||
////////////////////////////////////////////////////////////////////////// */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the play queue message buses.
|
|
||||||
*
|
|
||||||
* Also starts a self reporter for logging if debug mode is enabled.
|
|
||||||
*/
|
|
||||||
fun init() {
|
|
||||||
eventBroadcast = PublishSubject.create()
|
|
||||||
|
|
||||||
broadcastReceiver =
|
|
||||||
eventBroadcast!!
|
|
||||||
.toFlowable(BackpressureStrategy.BUFFER)
|
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
|
||||||
.startWithItem(InitEvent())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispose the play queue by stopping all message buses.
|
|
||||||
*/
|
|
||||||
open fun dispose() {
|
|
||||||
eventBroadcast?.onComplete()
|
|
||||||
eventBroadcast = null
|
|
||||||
broadcastReceiver = null
|
|
||||||
this.isDisposed = true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the queue is complete.
|
|
||||||
*
|
|
||||||
* A queue is complete if it has loaded all items in an external playlist
|
|
||||||
* single stream or local queues are always complete.
|
|
||||||
*
|
|
||||||
* @return whether the queue is complete
|
|
||||||
*/
|
|
||||||
abstract val isComplete: Boolean
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load partial queue in the background, does nothing if the queue is complete.
|
|
||||||
*/
|
|
||||||
abstract fun fetch()
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Readonly ops
|
|
||||||
////////////////////////////////////////////////////////////////////////// */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Changes the current playing index to a new index.
|
|
||||||
*
|
|
||||||
* This method is guarded using in a circular manner for index exceeding the play queue size.
|
|
||||||
*
|
|
||||||
* Will emit a [SelectEvent] if the index is not the current playing index.
|
|
||||||
*
|
|
||||||
* @param index the index to be set
|
|
||||||
* @return the current index that should be played
|
|
||||||
*/
|
|
||||||
@set:Synchronized
|
|
||||||
var index: Int = 0
|
|
||||||
get() = queueIndex.get()
|
|
||||||
set(index) {
|
|
||||||
val oldIndex = field
|
|
||||||
|
|
||||||
val newIndex: Int
|
|
||||||
|
|
||||||
if (index < 0) {
|
|
||||||
newIndex = 0
|
|
||||||
} else if (index < streams.size) {
|
|
||||||
// Regular assignment for index in bounds
|
|
||||||
newIndex = index
|
|
||||||
} else if (streams.isEmpty()) {
|
|
||||||
// Out of bounds from here on
|
|
||||||
// Need to check if stream is empty to prevent arithmetic error and negative index
|
|
||||||
newIndex = 0
|
|
||||||
} else if (this.isComplete) {
|
|
||||||
// Circular indexing
|
|
||||||
newIndex = index % streams.size
|
|
||||||
} else {
|
|
||||||
// Index of last element
|
|
||||||
newIndex = streams.size - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
queueIndex.set(newIndex)
|
|
||||||
|
|
||||||
if (oldIndex != newIndex) {
|
|
||||||
history.add(streams[newIndex])
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
TODO: Documentation states that a SelectEvent will only be emitted if the new index is...
|
|
||||||
different from the old one but this is emitted regardless? Not sure what this what it does
|
|
||||||
exactly so I won't touch it
|
|
||||||
*/
|
|
||||||
broadcast(SelectEvent(oldIndex, newIndex))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return the current item that should be played, or null if the queue is empty
|
|
||||||
*/
|
|
||||||
val item get() = getItem(this.index)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param index the index of the item to return
|
|
||||||
* @return the item at the given index, or null if the index is out of bounds
|
|
||||||
*/
|
|
||||||
fun getItem(index: Int) = streams.getOrNull(index)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the index of the given item using referential equality.
|
|
||||||
* May be null despite play queue contains identical item.
|
|
||||||
*
|
|
||||||
* @param item the item to find the index of
|
|
||||||
* @return the index of the given item
|
|
||||||
*/
|
|
||||||
fun indexOf(item: PlayQueueItem): Int = streams.indexOf(item)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return the current size of play queue.
|
|
||||||
*/
|
|
||||||
fun size(): Int = streams.size
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if the play queue is empty.
|
|
||||||
*
|
|
||||||
* @return whether the play queue is empty
|
|
||||||
*/
|
|
||||||
val isEmpty: Boolean
|
|
||||||
get() = streams.isEmpty()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determines if the current play queue is shuffled.
|
|
||||||
*
|
|
||||||
* @return whether the play queue is shuffled
|
|
||||||
*/
|
|
||||||
val isShuffled: Boolean
|
|
||||||
get() = backup.isNotEmpty()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return an immutable view of the play queue
|
|
||||||
*/
|
|
||||||
fun getStreams(): List<PlayQueueItem> = Collections.unmodifiableList(streams)
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Write ops
|
|
||||||
////////////////////////////////////////////////////////////////////////// */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Changes the current playing index by an offset amount.
|
|
||||||
*
|
|
||||||
* Will emit a [SelectEvent] if offset is non-zero.
|
|
||||||
*
|
|
||||||
* @param offset the offset relative to the current index
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
fun offsetIndex(offset: Int) {
|
|
||||||
this.index += offset
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Notifies that a change has occurred.
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
fun notifyChange() {
|
|
||||||
broadcast(AppendEvent(0))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Appends the given [PlayQueueItem]s to the current play queue.
|
|
||||||
*
|
|
||||||
* If the play queue is shuffled, then append the items to the backup queue as is and
|
|
||||||
* append the shuffle items to the play queue.
|
|
||||||
*
|
|
||||||
* Will emit a [AppendEvent] on any given context.
|
|
||||||
*
|
|
||||||
* @param items [PlayQueueItem]s to append
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
fun append(items: List<PlayQueueItem>) {
|
|
||||||
val itemList = items.toMutableList()
|
|
||||||
|
|
||||||
if (this.isShuffled) {
|
|
||||||
backup.addAll(itemList)
|
|
||||||
itemList.shuffle()
|
|
||||||
}
|
|
||||||
if (!streams.isEmpty() && streams.last().isAutoQueued && !itemList[0].isAutoQueued) {
|
|
||||||
streams.removeAt(streams.lastIndex)
|
|
||||||
}
|
|
||||||
streams.addAll(itemList)
|
|
||||||
|
|
||||||
broadcast(AppendEvent(itemList.size))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add the given item after the current stream.
|
|
||||||
*
|
|
||||||
* @param item item to add.
|
|
||||||
* @param skipIfSame if set, skip adding if the next stream is the same stream.
|
|
||||||
*/
|
|
||||||
fun enqueueNext(item: PlayQueueItem, skipIfSame: Boolean) {
|
|
||||||
val currentIndex = index
|
|
||||||
// if the next item is the same item as the one we want to enqueue, skip if flag is true
|
|
||||||
if (skipIfSame && item == getItem(currentIndex + 1)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
append(listOf(item))
|
|
||||||
move(size() - 1, currentIndex + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes the item at the given index from the play queue.
|
|
||||||
*
|
|
||||||
* The current playing index will decrement if it is greater than the index being removed.
|
|
||||||
* On cases where the current playing index exceeds the playlist range, it is set to 0.
|
|
||||||
*
|
|
||||||
* Will emit a [RemoveEvent] if the index is within the play queue index range.
|
|
||||||
*
|
|
||||||
* @param index the index of the item to remove
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
fun remove(index: Int) {
|
|
||||||
if (index >= streams.size || index < 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
removeInternal(index)
|
|
||||||
broadcast(RemoveEvent(index, this.index))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Report an exception for the item at the current index in order and skip to the next one
|
|
||||||
*
|
|
||||||
* This is done as a separate event as the underlying manager may have
|
|
||||||
* different implementation regarding exceptions.
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
fun error() {
|
|
||||||
val oldIndex = this.index
|
|
||||||
queueIndex.incrementAndGet()
|
|
||||||
if (streams.size > queueIndex.get()) {
|
|
||||||
history.add(streams[queueIndex.get()])
|
|
||||||
}
|
|
||||||
broadcast(ErrorEvent(oldIndex, this.index))
|
|
||||||
}
|
|
||||||
|
|
||||||
@Synchronized
|
|
||||||
private fun removeInternal(removeIndex: Int) {
|
|
||||||
val currentIndex = queueIndex.get()
|
|
||||||
val size = size()
|
|
||||||
|
|
||||||
if (currentIndex > removeIndex) {
|
|
||||||
queueIndex.decrementAndGet()
|
|
||||||
} else if (currentIndex >= size) {
|
|
||||||
queueIndex.set(currentIndex % (size - 1))
|
|
||||||
} else if (currentIndex == removeIndex && currentIndex == size - 1) {
|
|
||||||
queueIndex.set(0)
|
|
||||||
}
|
|
||||||
|
|
||||||
backup.remove(getItem(removeIndex)!!)
|
|
||||||
|
|
||||||
history.remove(streams.removeAt(removeIndex))
|
|
||||||
if (streams.size > queueIndex.get()) {
|
|
||||||
history.add(streams[queueIndex.get()])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves a queue item at the source index to the target index.
|
|
||||||
*
|
|
||||||
* If the item being moved is the currently playing, then the current playing index is set
|
|
||||||
* to that of the target.
|
|
||||||
* If the moved item is not the currently playing and moves to an index **AFTER** the
|
|
||||||
* current playing index, then the current playing index is decremented.
|
|
||||||
* Vice versa if the an item after the currently playing is moved **BEFORE**.
|
|
||||||
*
|
|
||||||
* @param source the original index of the item
|
|
||||||
* @param target the new index of the item
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
fun move(
|
|
||||||
source: Int,
|
|
||||||
target: Int
|
|
||||||
) {
|
|
||||||
if (source < 0 || target < 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (source >= streams.size || target >= streams.size) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val current = this.index
|
|
||||||
if (source == current) {
|
|
||||||
queueIndex.set(target)
|
|
||||||
} else if (source < current && target >= current) {
|
|
||||||
queueIndex.decrementAndGet()
|
|
||||||
} else if (source > current && target <= current) {
|
|
||||||
queueIndex.incrementAndGet()
|
|
||||||
}
|
|
||||||
|
|
||||||
val playQueueItem = streams.removeAt(source)
|
|
||||||
playQueueItem.isAutoQueued = false
|
|
||||||
streams.add(target, playQueueItem)
|
|
||||||
broadcast(MoveEvent(source, target))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the recovery record of the item at the index.
|
|
||||||
*
|
|
||||||
* Broadcasts a recovery event.
|
|
||||||
*
|
|
||||||
* @param index index of the item
|
|
||||||
* @param position the recovery position
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
fun setRecovery(
|
|
||||||
index: Int,
|
|
||||||
position: Long
|
|
||||||
) {
|
|
||||||
streams.getOrNull(index)?.let {
|
|
||||||
it.recoveryPosition = position
|
|
||||||
broadcast(RecoveryEvent(index, position))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Revoke the recovery record of the item at the index.
|
|
||||||
*
|
|
||||||
* Broadcasts a recovery event.
|
|
||||||
*
|
|
||||||
* @param index index of the item
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
fun unsetRecovery(index: Int) {
|
|
||||||
setRecovery(index, PlayQueueItem.RECOVERY_UNSET)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shuffles the current play queue
|
|
||||||
*
|
|
||||||
* This method first backs up the existing play queue and item being played. Then a newly
|
|
||||||
* shuffled play queue will be generated along with currently playing item placed at the
|
|
||||||
* beginning of the queue. This item will also be added to the history.
|
|
||||||
*
|
|
||||||
* Will emit a [ReorderEvent] if shuffled.
|
|
||||||
*
|
|
||||||
* @implNote Does nothing if the queue has a size <= 2 (the currently playing video must stay on
|
|
||||||
* top, so shuffling a size-2 list does nothing)
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
fun shuffle() {
|
|
||||||
// Create a backup if it doesn't already exist
|
|
||||||
// Note: The backup-list has to be created at all cost (even when size <= 2).
|
|
||||||
// Otherwise it's not possible to enter shuffle-mode!
|
|
||||||
if (backup.isEmpty()) {
|
|
||||||
backup = streams.toMutableList()
|
|
||||||
}
|
|
||||||
// Can't shuffle a list that's empty or only has one element
|
|
||||||
if (size() <= 2) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val originalIndex = this.index
|
|
||||||
val currentItem = this.item
|
|
||||||
|
|
||||||
streams.shuffle()
|
|
||||||
|
|
||||||
// Move currentItem to the head of the queue
|
|
||||||
streams.remove(currentItem!!)
|
|
||||||
streams.add(0, currentItem)
|
|
||||||
queueIndex.set(0)
|
|
||||||
|
|
||||||
history.add(currentItem)
|
|
||||||
|
|
||||||
broadcast(ReorderEvent(originalIndex, 0))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unshuffles the current play queue if a backup play queue exists.
|
|
||||||
*
|
|
||||||
* This method undoes shuffling and index will be set to the previously playing item if found,
|
|
||||||
* otherwise, the index will reset to 0.
|
|
||||||
*
|
|
||||||
* Will emit a [ReorderEvent] if a backup exists.
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
fun unshuffle() {
|
|
||||||
if (backup.isEmpty()) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val originIndex = this.index
|
|
||||||
val current = this.item
|
|
||||||
|
|
||||||
streams = backup
|
|
||||||
backup = mutableListOf()
|
|
||||||
|
|
||||||
val newIndex = streams.indexOf(current!!)
|
|
||||||
if (newIndex != -1) {
|
|
||||||
queueIndex.set(newIndex)
|
|
||||||
} else {
|
|
||||||
queueIndex.set(0)
|
|
||||||
}
|
|
||||||
if (streams.size > queueIndex.get()) {
|
|
||||||
history.add(streams[queueIndex.get()])
|
|
||||||
}
|
|
||||||
|
|
||||||
broadcast(ReorderEvent(originIndex, queueIndex.get()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Selects previous played item.
|
|
||||||
*
|
|
||||||
* This method removes currently playing item from history and
|
|
||||||
* starts playing the last item from history if it exists
|
|
||||||
*
|
|
||||||
* @return true if history is not empty and the item can be played
|
|
||||||
*/
|
|
||||||
@Synchronized
|
|
||||||
fun previous(): Boolean {
|
|
||||||
if (history.size <= 1) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
history.removeAt(history.size - 1)
|
|
||||||
|
|
||||||
val last = history.removeAt(history.size - 1)
|
|
||||||
this.index = indexOf(last)
|
|
||||||
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Compares two PlayQueues. Useful when a user switches players but queue is the same so
|
|
||||||
* we don't have to do anything with new queue.
|
|
||||||
* This method also gives a chance to track history of items in a queue in
|
|
||||||
* VideoDetailFragment without duplicating items from two identical queues
|
|
||||||
*/
|
|
||||||
override fun equals(o: Any?): Boolean = o is PlayQueue && streams == o.streams
|
|
||||||
|
|
||||||
override fun hashCode(): Int = streams.hashCode()
|
|
||||||
|
|
||||||
fun equalStreamsAndIndex(other: PlayQueue?): Boolean {
|
|
||||||
return equals(other) && other!!.index == this.index // NOSONAR: other is not null
|
|
||||||
}
|
|
||||||
|
|
||||||
/*//////////////////////////////////////////////////////////////////////////
|
|
||||||
// Rx Broadcast
|
|
||||||
////////////////////////////////////////////////////////////////////////// */
|
|
||||||
private fun broadcast(event: PlayQueueEvent) {
|
|
||||||
eventBroadcast?.onNext(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
package org.schabi.newpipe.player.playqueue;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.annotation.Nullable;
|
||||||
|
|
||||||
|
import org.schabi.newpipe.extractor.Image;
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfo;
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
|
||||||
|
import org.schabi.newpipe.extractor.stream.StreamType;
|
||||||
|
import org.schabi.newpipe.util.ExtractorHelper;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import io.reactivex.rxjava3.core.Single;
|
||||||
|
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||||
|
|
||||||
|
public class PlayQueueItem implements Serializable {
|
||||||
|
public static final long RECOVERY_UNSET = Long.MIN_VALUE;
|
||||||
|
private static final String EMPTY_STRING = "";
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private final String title;
|
||||||
|
@NonNull
|
||||||
|
private final String url;
|
||||||
|
private final int serviceId;
|
||||||
|
private final long duration;
|
||||||
|
@NonNull
|
||||||
|
private final List<Image> thumbnails;
|
||||||
|
@NonNull
|
||||||
|
private final String uploader;
|
||||||
|
private final String uploaderUrl;
|
||||||
|
@NonNull
|
||||||
|
private final StreamType streamType;
|
||||||
|
|
||||||
|
private boolean isAutoQueued;
|
||||||
|
|
||||||
|
private long recoveryPosition;
|
||||||
|
private Throwable error;
|
||||||
|
|
||||||
|
public PlayQueueItem(@NonNull final StreamInfo info) {
|
||||||
|
this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(),
|
||||||
|
info.getThumbnails(), info.getUploaderName(),
|
||||||
|
info.getUploaderUrl(), info.getStreamType());
|
||||||
|
|
||||||
|
if (info.getStartPosition() > 0) {
|
||||||
|
setRecoveryPosition(info.getStartPosition() * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayQueueItem(@NonNull final StreamInfoItem item) {
|
||||||
|
this(item.getName(), item.getUrl(), item.getServiceId(), item.getDuration(),
|
||||||
|
item.getThumbnails(), item.getUploaderName(),
|
||||||
|
item.getUploaderUrl(), item.getStreamType());
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings("ParameterNumber")
|
||||||
|
private PlayQueueItem(@Nullable final String name, @Nullable final String url,
|
||||||
|
final int serviceId, final long duration,
|
||||||
|
final List<Image> thumbnails, @Nullable final String uploader,
|
||||||
|
final String uploaderUrl, @NonNull final StreamType streamType) {
|
||||||
|
this.title = name != null ? name : EMPTY_STRING;
|
||||||
|
this.url = url != null ? url : EMPTY_STRING;
|
||||||
|
this.serviceId = serviceId;
|
||||||
|
this.duration = duration;
|
||||||
|
this.thumbnails = thumbnails;
|
||||||
|
this.uploader = uploader != null ? uploader : EMPTY_STRING;
|
||||||
|
this.uploaderUrl = uploaderUrl;
|
||||||
|
this.streamType = streamType;
|
||||||
|
|
||||||
|
this.recoveryPosition = RECOVERY_UNSET;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether these two items should be treated as the same stream
|
||||||
|
* for the sake of keeping the same player running when e.g. jumping between timestamps.
|
||||||
|
*
|
||||||
|
* @param other the {@link PlayQueueItem} to compare against.
|
||||||
|
* @return whether the two items are the same so the stream can be re-used.
|
||||||
|
*/
|
||||||
|
public boolean isSameItem(@Nullable final PlayQueueItem other) {
|
||||||
|
if (other == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// We assume that the same service & URL uniquely determines
|
||||||
|
// that we can keep the same stream running.
|
||||||
|
return serviceId == other.serviceId
|
||||||
|
&& url.equals(other.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
public String getTitle() {
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
public String getUrl() {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getServiceId() {
|
||||||
|
return serviceId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getDuration() {
|
||||||
|
return duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
public List<Image> getThumbnails() {
|
||||||
|
return thumbnails;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
public String getUploader() {
|
||||||
|
return uploader;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUploaderUrl() {
|
||||||
|
return uploaderUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
public StreamType getStreamType() {
|
||||||
|
return streamType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long getRecoveryPosition() {
|
||||||
|
return recoveryPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*package-private*/ void setRecoveryPosition(final long recoveryPosition) {
|
||||||
|
this.recoveryPosition = recoveryPosition;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
public Throwable getError() {
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
public Single<StreamInfo> getStream() {
|
||||||
|
return ExtractorHelper.getStreamInfo(this.serviceId, this.url, false)
|
||||||
|
.subscribeOn(Schedulers.io())
|
||||||
|
.doOnError(throwable -> error = throwable);
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAutoQueued() {
|
||||||
|
return isAutoQueued;
|
||||||
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Item States, keep external access out
|
||||||
|
////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
|
public void setAutoQueued(final boolean autoQueued) {
|
||||||
|
isAutoQueued = autoQueued;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package org.schabi.newpipe.player.playqueue
|
|
||||||
|
|
||||||
import io.reactivex.rxjava3.core.Single
|
|
||||||
import io.reactivex.rxjava3.schedulers.Schedulers
|
|
||||||
import java.io.Serializable
|
|
||||||
import java.util.Objects
|
|
||||||
import org.schabi.newpipe.extractor.Image
|
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfo
|
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
|
||||||
import org.schabi.newpipe.extractor.stream.StreamType
|
|
||||||
import org.schabi.newpipe.util.ExtractorHelper
|
|
||||||
|
|
||||||
class PlayQueueItem private constructor(
|
|
||||||
val title: String,
|
|
||||||
val url: String,
|
|
||||||
val serviceId: Int,
|
|
||||||
val duration: Long,
|
|
||||||
val thumbnails: List<Image>,
|
|
||||||
val uploader: String,
|
|
||||||
val uploaderUrl: String?,
|
|
||||||
val streamType: StreamType
|
|
||||||
) : Serializable {
|
|
||||||
//
|
|
||||||
// ////////////////////////////////////////////////////////////////////// */
|
|
||||||
// Item States, keep external access out
|
|
||||||
//
|
|
||||||
// ////////////////////////////////////////////////////////////////////// */
|
|
||||||
var isAutoQueued: Boolean = false
|
|
||||||
|
|
||||||
// package-private
|
|
||||||
var recoveryPosition = RECOVERY_UNSET
|
|
||||||
var error: Throwable? = null
|
|
||||||
private set
|
|
||||||
|
|
||||||
constructor(info: StreamInfo) : this(
|
|
||||||
info.name.orEmpty(),
|
|
||||||
info.url.orEmpty(),
|
|
||||||
info.serviceId,
|
|
||||||
info.duration,
|
|
||||||
info.thumbnails,
|
|
||||||
info.uploaderName.orEmpty(),
|
|
||||||
info.uploaderUrl,
|
|
||||||
info.streamType
|
|
||||||
) {
|
|
||||||
if (info.startPosition > 0) {
|
|
||||||
this.recoveryPosition = info.startPosition * 1000
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor(item: StreamInfoItem) : this(
|
|
||||||
item.name.orEmpty(),
|
|
||||||
item.url.orEmpty(),
|
|
||||||
item.serviceId,
|
|
||||||
item.duration,
|
|
||||||
item.thumbnails,
|
|
||||||
item.uploaderName.orEmpty(),
|
|
||||||
item.uploaderUrl,
|
|
||||||
item.streamType
|
|
||||||
)
|
|
||||||
|
|
||||||
val stream: Single<StreamInfo>
|
|
||||||
get() =
|
|
||||||
ExtractorHelper
|
|
||||||
.getStreamInfo(serviceId, url, false)
|
|
||||||
.subscribeOn(Schedulers.io())
|
|
||||||
.doOnError { throwable -> error = throwable }
|
|
||||||
|
|
||||||
override fun equals(o: Any?) = o is PlayQueueItem && serviceId == o.serviceId && url == o.url
|
|
||||||
|
|
||||||
override fun hashCode() = Objects.hash(url, serviceId)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val RECOVERY_UNSET = Long.MIN_VALUE
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -123,7 +123,7 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
|||||||
&& DeviceUtils.isTablet(player.getService())
|
&& DeviceUtils.isTablet(player.getService())
|
||||||
&& PlayerHelper.globalScreenOrientationLocked(player.getService())) {
|
&& PlayerHelper.globalScreenOrientationLocked(player.getService())) {
|
||||||
player.getFragmentListener().ifPresent(
|
player.getFragmentListener().ifPresent(
|
||||||
PlayerServiceEventListener::onFullscreenToggleButtonClicked);
|
PlayerServiceEventListener::onScreenRotationButtonClicked);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,12 +155,12 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
|||||||
protected void initListeners() {
|
protected void initListeners() {
|
||||||
super.initListeners();
|
super.initListeners();
|
||||||
|
|
||||||
binding.fullscreenToggleButton.setOnClickListener(makeOnClickListener(() -> {
|
binding.screenRotationButton.setOnClickListener(makeOnClickListener(() -> {
|
||||||
// Only if it's not a vertical video or vertical video but in landscape with locked
|
// Only if it's not a vertical video or vertical video but in landscape with locked
|
||||||
// orientation a screen orientation can be changed automatically
|
// orientation a screen orientation can be changed automatically
|
||||||
if (!isVerticalVideo || (isLandscape() && globalScreenOrientationLocked(context))) {
|
if (!isVerticalVideo || (isLandscape() && globalScreenOrientationLocked(context))) {
|
||||||
player.getFragmentListener()
|
player.getFragmentListener()
|
||||||
.ifPresent(PlayerServiceEventListener::onFullscreenToggleButtonClicked);
|
.ifPresent(PlayerServiceEventListener::onScreenRotationButtonClicked);
|
||||||
} else {
|
} else {
|
||||||
toggleFullscreen();
|
toggleFullscreen();
|
||||||
}
|
}
|
||||||
@@ -238,7 +238,7 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
|||||||
|
|
||||||
// Exit from fullscreen when user closes the player via notification
|
// Exit from fullscreen when user closes the player via notification
|
||||||
if (isFullscreen) {
|
if (isFullscreen) {
|
||||||
exitFullscreen();
|
toggleFullscreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
removeViewFromParent();
|
removeViewFromParent();
|
||||||
@@ -275,7 +275,7 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
|||||||
|
|
||||||
closeItemsList();
|
closeItemsList();
|
||||||
showHideKodiButton();
|
showHideKodiButton();
|
||||||
binding.fullscreenToggleButtonSecondaryMenu.setVisibility(View.GONE);
|
binding.fullScreenButton.setVisibility(View.GONE);
|
||||||
setupScreenRotationButton();
|
setupScreenRotationButton();
|
||||||
binding.resizeTextView.setVisibility(View.VISIBLE);
|
binding.resizeTextView.setVisibility(View.VISIBLE);
|
||||||
binding.getRoot().findViewById(R.id.metadataView).setVisibility(View.VISIBLE);
|
binding.getRoot().findViewById(R.id.metadataView).setVisibility(View.VISIBLE);
|
||||||
@@ -421,7 +421,7 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
|||||||
public void onCompleted() {
|
public void onCompleted() {
|
||||||
super.onCompleted();
|
super.onCompleted();
|
||||||
if (isFullscreen) {
|
if (isFullscreen) {
|
||||||
exitFullscreen();
|
toggleFullscreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
@@ -883,10 +883,10 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
|||||||
//region Video size, orientation, fullscreen
|
//region Video size, orientation, fullscreen
|
||||||
|
|
||||||
private void setupScreenRotationButton() {
|
private void setupScreenRotationButton() {
|
||||||
binding.fullscreenToggleButton.setVisibility(globalScreenOrientationLocked(context)
|
binding.screenRotationButton.setVisibility(globalScreenOrientationLocked(context)
|
||||||
|| isVerticalVideo || DeviceUtils.isTablet(context)
|
|| isVerticalVideo || DeviceUtils.isTablet(context)
|
||||||
? View.VISIBLE : View.GONE);
|
? View.VISIBLE : View.GONE);
|
||||||
binding.fullscreenToggleButton.setImageDrawable(AppCompatResources.getDrawable(context,
|
binding.screenRotationButton.setImageDrawable(AppCompatResources.getDrawable(context,
|
||||||
isFullscreen ? R.drawable.ic_fullscreen_exit
|
isFullscreen ? R.drawable.ic_fullscreen_exit
|
||||||
: R.drawable.ic_fullscreen));
|
: R.drawable.ic_fullscreen));
|
||||||
}
|
}
|
||||||
@@ -903,7 +903,7 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
|||||||
&& !DeviceUtils.isTablet(context)) {
|
&& !DeviceUtils.isTablet(context)) {
|
||||||
// set correct orientation
|
// set correct orientation
|
||||||
player.getFragmentListener().ifPresent(
|
player.getFragmentListener().ifPresent(
|
||||||
PlayerServiceEventListener::onFullscreenToggleButtonClicked);
|
PlayerServiceEventListener::onScreenRotationButtonClicked);
|
||||||
}
|
}
|
||||||
|
|
||||||
setupScreenRotationButton();
|
setupScreenRotationButton();
|
||||||
@@ -913,53 +913,27 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
|||||||
if (DEBUG) {
|
if (DEBUG) {
|
||||||
Log.d(TAG, "toggleFullscreen() called");
|
Log.d(TAG, "toggleFullscreen() called");
|
||||||
}
|
}
|
||||||
|
final PlayerServiceEventListener fragmentListener = player.getFragmentListener()
|
||||||
|
.orElse(null);
|
||||||
|
if (fragmentListener == null || player.exoPlayerIsNull()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isFullscreen = !isFullscreen;
|
||||||
if (isFullscreen) {
|
if (isFullscreen) {
|
||||||
exitFullscreen();
|
// Android needs tens milliseconds to send new insets but a user is able to see
|
||||||
|
// how controls changes it's position from `0` to `nav bar height` padding.
|
||||||
|
// So just hide the controls to hide this visual inconsistency
|
||||||
|
hideControls(0, 0);
|
||||||
} else {
|
} else {
|
||||||
enterFullscreen();
|
// Apply window insets because Android will not do it when orientation changes
|
||||||
|
// from landscape to portrait (open vertical video to reproduce)
|
||||||
|
binding.playbackControlRoot.setPadding(0, 0, 0, 0);
|
||||||
}
|
}
|
||||||
|
fragmentListener.onFullscreenStateChanged(isFullscreen);
|
||||||
|
|
||||||
}
|
binding.metadataView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE);
|
||||||
|
binding.playerCloseButton.setVisibility(isFullscreen ? View.GONE : View.VISIBLE);
|
||||||
public void enterFullscreen() {
|
|
||||||
if (DEBUG) {
|
|
||||||
Log.d(TAG, "enterFullscreen() called");
|
|
||||||
}
|
|
||||||
final PlayerServiceEventListener fragmentListener = player.getFragmentListener()
|
|
||||||
.orElse(null);
|
|
||||||
if (fragmentListener == null || player.exoPlayerIsNull()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isFullscreen = true;
|
|
||||||
// Android needs tens milliseconds to send new insets but a user is able to see
|
|
||||||
// how controls changes it's position from `0` to `nav bar height` padding.
|
|
||||||
// So just hide the controls to hide this visual inconsistency
|
|
||||||
hideControls(0, 0);
|
|
||||||
fragmentListener.onFullscreenStateChanged(true);
|
|
||||||
setupFullscreenButtons(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void exitFullscreen() {
|
|
||||||
if (DEBUG) {
|
|
||||||
Log.d(TAG, "exitFullscreen() called");
|
|
||||||
}
|
|
||||||
final PlayerServiceEventListener fragmentListener = player.getFragmentListener()
|
|
||||||
.orElse(null);
|
|
||||||
if (fragmentListener == null || player.exoPlayerIsNull()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
isFullscreen = false;
|
|
||||||
// Apply window insets because Android will not do it when orientation changes
|
|
||||||
// from landscape to portrait (open vertical video to reproduce)
|
|
||||||
binding.playbackControlRoot.setPadding(0, 0, 0, 0);
|
|
||||||
fragmentListener.onFullscreenStateChanged(false);
|
|
||||||
setupFullscreenButtons(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void setupFullscreenButtons(final boolean fullscreen) {
|
|
||||||
binding.metadataView.setVisibility(fullscreen ? View.VISIBLE : View.GONE);
|
|
||||||
binding.playerCloseButton.setVisibility(fullscreen ? View.GONE : View.VISIBLE);
|
|
||||||
setupScreenRotationButton();
|
setupScreenRotationButton();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -974,7 +948,7 @@ public final class MainPlayerUi extends VideoPlayerUi implements View.OnLayoutCh
|
|||||||
if (videoInLandscapeButNotInFullscreen
|
if (videoInLandscapeButNotInFullscreen
|
||||||
&& notPaused
|
&& notPaused
|
||||||
&& !DeviceUtils.isTablet(context)) {
|
&& !DeviceUtils.isTablet(context)) {
|
||||||
enterFullscreen();
|
toggleFullscreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//endregion
|
//endregion
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package org.schabi.newpipe.player.ui;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
public final class PlayerUiList {
|
||||||
|
final List<PlayerUi> playerUis = new ArrayList<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a {@link PlayerUiList} starting with the provided player uis. The provided player uis
|
||||||
|
* will not be prepared like those passed to {@link #addAndPrepare(PlayerUi)}, because when
|
||||||
|
* the {@link PlayerUiList} constructor is called, the player is still not running and it
|
||||||
|
* wouldn't make sense to initialize uis then. Instead the player will initialize them by doing
|
||||||
|
* proper calls to {@link #call(Consumer)}.
|
||||||
|
*
|
||||||
|
* @param initialPlayerUis the player uis this list should start with; the order will be kept
|
||||||
|
*/
|
||||||
|
public PlayerUiList(final PlayerUi... initialPlayerUis) {
|
||||||
|
playerUis.addAll(List.of(initialPlayerUis));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the provided player ui to the list and calls on it the initialization functions that
|
||||||
|
* apply based on the current player state. The preparation step needs to be done since when UIs
|
||||||
|
* are removed and re-added, the player will not call e.g. initPlayer again since the exoplayer
|
||||||
|
* is already initialized, but we need to notify the newly built UI that the player is ready
|
||||||
|
* nonetheless.
|
||||||
|
* @param playerUi the player ui to prepare and add to the list; its {@link
|
||||||
|
* PlayerUi#getPlayer()} will be used to query information about the player
|
||||||
|
* state
|
||||||
|
*/
|
||||||
|
public void addAndPrepare(final PlayerUi playerUi) {
|
||||||
|
if (playerUi.getPlayer().getFragmentListener().isPresent()) {
|
||||||
|
// make sure UIs know whether a service is connected or not
|
||||||
|
playerUi.onFragmentListenerSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playerUi.getPlayer().exoPlayerIsNull()) {
|
||||||
|
playerUi.initPlayer();
|
||||||
|
if (playerUi.getPlayer().getPlayQueue() != null) {
|
||||||
|
playerUi.initPlayback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
playerUis.add(playerUi);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Destroys all matching player UIs and removes them from the list.
|
||||||
|
* @param playerUiType the class of the player UI to destroy; the {@link
|
||||||
|
* Class#isInstance(Object)} method will be used, so even subclasses will be
|
||||||
|
* destroyed and removed
|
||||||
|
* @param <T> the class type parameter
|
||||||
|
*/
|
||||||
|
public <T> void destroyAll(final Class<T> playerUiType) {
|
||||||
|
playerUis.stream()
|
||||||
|
.filter(playerUiType::isInstance)
|
||||||
|
.forEach(playerUi -> {
|
||||||
|
playerUi.destroyPlayer();
|
||||||
|
playerUi.destroy();
|
||||||
|
});
|
||||||
|
playerUis.removeIf(playerUiType::isInstance);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param playerUiType the class of the player UI to return; the {@link
|
||||||
|
* Class#isInstance(Object)} method will be used, so even subclasses could
|
||||||
|
* be returned
|
||||||
|
* @param <T> the class type parameter
|
||||||
|
* @return the first player UI of the required type found in the list, or an empty {@link
|
||||||
|
* Optional} otherwise
|
||||||
|
*/
|
||||||
|
public <T> Optional<T> get(final Class<T> playerUiType) {
|
||||||
|
return playerUis.stream()
|
||||||
|
.filter(playerUiType::isInstance)
|
||||||
|
.map(playerUiType::cast)
|
||||||
|
.findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calls the provided consumer on all player UIs in the list, in order of addition.
|
||||||
|
* @param consumer the consumer to call with player UIs
|
||||||
|
*/
|
||||||
|
public void call(final Consumer<PlayerUi> consumer) {
|
||||||
|
//noinspection SimplifyStreamApiCallChains
|
||||||
|
playerUis.stream().forEachOrdered(consumer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
package org.schabi.newpipe.player.ui
|
|
||||||
|
|
||||||
import kotlin.reflect.KClass
|
|
||||||
import kotlin.reflect.safeCast
|
|
||||||
import org.schabi.newpipe.util.GuardedByMutex
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a [PlayerUiList] starting with the provided player uis. The provided player uis
|
|
||||||
* will not be prepared like those passed to [.addAndPrepare], because when
|
|
||||||
* the [PlayerUiList] constructor is called, the player is still not running and it
|
|
||||||
* wouldn't make sense to initialize uis then. Instead the player will initialize them by doing
|
|
||||||
* proper calls to [.call].
|
|
||||||
*
|
|
||||||
* @param initialPlayerUis the player uis this list should start with; the order will be kept
|
|
||||||
*/
|
|
||||||
class PlayerUiList(vararg initialPlayerUis: PlayerUi) {
|
|
||||||
private val playerUis = GuardedByMutex(mutableListOf(*initialPlayerUis))
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds the provided player ui to the list and calls on it the initialization functions that
|
|
||||||
* apply based on the current player state. The preparation step needs to be done since when UIs
|
|
||||||
* are removed and re-added, the player will not call e.g. initPlayer again since the exoplayer
|
|
||||||
* is already initialized, but we need to notify the newly built UI that the player is ready
|
|
||||||
* nonetheless.
|
|
||||||
* @param playerUi the player ui to prepare and add to the list; its [PlayerUi.getPlayer]
|
|
||||||
* will be used to query information about the player state
|
|
||||||
*/
|
|
||||||
fun addAndPrepare(playerUi: PlayerUi) {
|
|
||||||
if (playerUi.getPlayer().fragmentListener.isPresent) {
|
|
||||||
// make sure UIs know whether a service is connected or not
|
|
||||||
playerUi.onFragmentListenerSet()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!playerUi.getPlayer().exoPlayerIsNull()) {
|
|
||||||
playerUi.initPlayer()
|
|
||||||
if (playerUi.getPlayer().playQueue != null) {
|
|
||||||
playerUi.initPlayback()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
playerUis.runWithLockSync {
|
|
||||||
lockData.add(playerUi)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Destroys all matching player UIs and removes them from the list.
|
|
||||||
* @param playerUiType the class of the player UI to destroy, everything if `null`.
|
|
||||||
* The [Class.isInstance] method will be used, so even subclasses will be
|
|
||||||
* destroyed and removed
|
|
||||||
* @param T the class type parameter </T>
|
|
||||||
* */
|
|
||||||
fun <T : PlayerUi> destroyAllOfType(playerUiType: Class<T>? = null) {
|
|
||||||
val toDestroy = mutableListOf<PlayerUi>()
|
|
||||||
|
|
||||||
// short blocking removal from class to prevent interfering from other threads
|
|
||||||
playerUis.runWithLockSync {
|
|
||||||
val new = mutableListOf<PlayerUi>()
|
|
||||||
for (ui in lockData) {
|
|
||||||
if (playerUiType == null || playerUiType.isInstance(ui)) {
|
|
||||||
toDestroy.add(ui)
|
|
||||||
} else {
|
|
||||||
new.add(ui)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
lockData = new
|
|
||||||
}
|
|
||||||
// then actually destroy the UIs
|
|
||||||
for (ui in toDestroy) {
|
|
||||||
ui.destroyPlayer()
|
|
||||||
ui.destroy()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param playerUiType the class of the player UI to return;
|
|
||||||
* the [Class.isInstance] method will be used, so even subclasses could be returned
|
|
||||||
* @param T the class type parameter
|
|
||||||
* @return the first player UI of the required type found in the list, or null
|
|
||||||
</T> */
|
|
||||||
fun <T : PlayerUi> get(playerUiType: KClass<T>): T? = playerUis.runWithLockSync {
|
|
||||||
for (ui in lockData) {
|
|
||||||
if (playerUiType.isInstance(ui)) {
|
|
||||||
// try all UIs before returning null
|
|
||||||
playerUiType.safeCast(ui)?.let { return@runWithLockSync it }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return@runWithLockSync null
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* See [get] above
|
|
||||||
*/
|
|
||||||
fun <T : PlayerUi> get(playerUiType: Class<T>): T? = get(playerUiType.kotlin)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calls the provided consumer on all player UIs in the list, in order of addition.
|
|
||||||
* @param consumer the consumer to call with player UIs
|
|
||||||
*/
|
|
||||||
fun call(consumer: java.util.function.Consumer<PlayerUi>) {
|
|
||||||
// copy the list out of the mutex before calling the consumer which might block
|
|
||||||
val new = playerUis.runWithLockSync {
|
|
||||||
lockData.toMutableList()
|
|
||||||
}
|
|
||||||
for (ui in new) {
|
|
||||||
consumer.accept(ui)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -162,8 +162,8 @@ public final class PopupPlayerUi extends VideoPlayerUi {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void setupElementsVisibility() {
|
protected void setupElementsVisibility() {
|
||||||
binding.fullscreenToggleButtonSecondaryMenu.setVisibility(View.VISIBLE);
|
binding.fullScreenButton.setVisibility(View.VISIBLE);
|
||||||
binding.fullscreenToggleButton.setVisibility(View.GONE);
|
binding.screenRotationButton.setVisibility(View.GONE);
|
||||||
binding.resizeTextView.setVisibility(View.GONE);
|
binding.resizeTextView.setVisibility(View.GONE);
|
||||||
binding.getRoot().findViewById(R.id.metadataView).setVisibility(View.GONE);
|
binding.getRoot().findViewById(R.id.metadataView).setVisibility(View.GONE);
|
||||||
binding.queueButton.setVisibility(View.GONE);
|
binding.queueButton.setVisibility(View.GONE);
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import static org.schabi.newpipe.player.helper.PlayerHelper.getTimeString;
|
|||||||
import static org.schabi.newpipe.player.helper.PlayerHelper.nextResizeModeAndSaveToPrefs;
|
import static org.schabi.newpipe.player.helper.PlayerHelper.nextResizeModeAndSaveToPrefs;
|
||||||
import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences;
|
import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences;
|
||||||
|
|
||||||
import android.annotation.SuppressLint;
|
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.res.Resources;
|
import android.content.res.Resources;
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
@@ -24,6 +23,7 @@ import android.graphics.Color;
|
|||||||
import android.graphics.PorterDuff;
|
import android.graphics.PorterDuff;
|
||||||
import android.graphics.PorterDuffColorFilter;
|
import android.graphics.PorterDuffColorFilter;
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
|
import android.os.Build;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
import android.os.Looper;
|
import android.os.Looper;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
@@ -233,7 +233,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
|
|||||||
ShareUtils.copyToClipboard(context, player.getVideoUrlAtCurrentTime());
|
ShareUtils.copyToClipboard(context, player.getVideoUrlAtCurrentTime());
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
binding.fullscreenToggleButtonSecondaryMenu.setOnClickListener(makeOnClickListener(() -> {
|
binding.fullScreenButton.setOnClickListener(makeOnClickListener(() -> {
|
||||||
player.setRecovery();
|
player.setRecovery();
|
||||||
NavigationHelper.playOnMainPlayer(context,
|
NavigationHelper.playOnMainPlayer(context,
|
||||||
Objects.requireNonNull(player.getPlayQueue()), true);
|
Objects.requireNonNull(player.getPlayQueue()), true);
|
||||||
@@ -300,8 +300,8 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
|
|||||||
binding.moreOptionsButton.setOnLongClickListener(null);
|
binding.moreOptionsButton.setOnLongClickListener(null);
|
||||||
binding.share.setOnClickListener(null);
|
binding.share.setOnClickListener(null);
|
||||||
binding.share.setOnLongClickListener(null);
|
binding.share.setOnLongClickListener(null);
|
||||||
binding.fullscreenToggleButtonSecondaryMenu.setOnClickListener(null);
|
binding.fullScreenButton.setOnClickListener(null);
|
||||||
binding.fullscreenToggleButton.setOnClickListener(null);
|
binding.screenRotationButton.setOnClickListener(null);
|
||||||
binding.playWithKodi.setOnClickListener(null);
|
binding.playWithKodi.setOnClickListener(null);
|
||||||
binding.openInBrowser.setOnClickListener(null);
|
binding.openInBrowser.setOnClickListener(null);
|
||||||
binding.playerCloseButton.setOnClickListener(null);
|
binding.playerCloseButton.setOnClickListener(null);
|
||||||
@@ -761,7 +761,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the play/pause button (`R.id.playPauseButton`) to reflect the action
|
* Update the play/pause button ({@link R.id.playPauseButton}) to reflect the action
|
||||||
* that will be performed when the button is clicked..
|
* that will be performed when the button is clicked..
|
||||||
* @param action the action that is performed when the play/pause button is clicked
|
* @param action the action that is performed when the play/pause button is clicked
|
||||||
*/
|
*/
|
||||||
@@ -947,8 +947,6 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
|
|||||||
player.toggleShuffleModeEnabled();
|
player.toggleShuffleModeEnabled();
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: don’t reference internal exoplayer2 resources
|
|
||||||
@SuppressLint("PrivateResource")
|
|
||||||
@Override
|
@Override
|
||||||
public void onRepeatModeChanged(@RepeatMode final int repeatMode) {
|
public void onRepeatModeChanged(@RepeatMode final int repeatMode) {
|
||||||
super.onRepeatModeChanged(repeatMode);
|
super.onRepeatModeChanged(repeatMode);
|
||||||
@@ -1454,7 +1452,7 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
|
|||||||
if (player.getCurrentState() == STATE_PLAYING && !isSomePopupMenuVisible) {
|
if (player.getCurrentState() == STATE_PLAYING && !isSomePopupMenuVisible) {
|
||||||
if (v == binding.playPauseButton
|
if (v == binding.playPauseButton
|
||||||
// Hide controls in fullscreen immediately
|
// Hide controls in fullscreen immediately
|
||||||
|| (v == binding.fullscreenToggleButton && isFullscreen())) {
|
|| (v == binding.screenRotationButton && 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);
|
||||||
@@ -1586,15 +1584,19 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
|
|||||||
// make sure there is nothing left over from previous calls
|
// make sure there is nothing left over from previous calls
|
||||||
clearVideoSurface();
|
clearVideoSurface();
|
||||||
|
|
||||||
surfaceHolderCallback = new SurfaceHolderCallback(context, player.getExoPlayer());
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { // >=API23
|
||||||
binding.surfaceView.getHolder().addCallback(surfaceHolderCallback);
|
surfaceHolderCallback = new SurfaceHolderCallback(context, player.getExoPlayer());
|
||||||
|
binding.surfaceView.getHolder().addCallback(surfaceHolderCallback);
|
||||||
|
|
||||||
// ensure player is using an unreleased surface, which the surfaceView might not be
|
// ensure player is using an unreleased surface, which the surfaceView might not be
|
||||||
// when starting playback on background or during player switching
|
// when starting playback on background or during player switching
|
||||||
if (binding.surfaceView.getHolder().getSurface().isValid()) {
|
if (binding.surfaceView.getHolder().getSurface().isValid()) {
|
||||||
// initially set the surface manually otherwise
|
// initially set the surface manually otherwise
|
||||||
// onRenderedFirstFrame() will not be called
|
// onRenderedFirstFrame() will not be called
|
||||||
player.getExoPlayer().setVideoSurfaceHolder(binding.surfaceView.getHolder());
|
player.getExoPlayer().setVideoSurfaceHolder(binding.surfaceView.getHolder());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
player.getExoPlayer().setVideoSurfaceView(binding.surfaceView);
|
||||||
}
|
}
|
||||||
|
|
||||||
surfaceIsSetup = true;
|
surfaceIsSetup = true;
|
||||||
@@ -1602,7 +1604,8 @@ public abstract class VideoPlayerUi extends PlayerUi implements SeekBar.OnSeekBa
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void clearVideoSurface() {
|
private void clearVideoSurface() {
|
||||||
if (surfaceHolderCallback != null) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M // >=API23
|
||||||
|
&& surfaceHolderCallback != null) {
|
||||||
binding.surfaceView.getHolder().removeCallback(surfaceHolderCallback);
|
binding.surfaceView.getHolder().removeCallback(surfaceHolderCallback);
|
||||||
surfaceHolderCallback.release();
|
surfaceHolderCallback.release();
|
||||||
surfaceHolderCallback = null;
|
surfaceHolderCallback = null;
|
||||||
|
|||||||
@@ -73,14 +73,13 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
|
|||||||
requirePreference(R.string.image_quality_key).setOnPreferenceChangeListener(
|
requirePreference(R.string.image_quality_key).setOnPreferenceChangeListener(
|
||||||
(preference, newValue) -> {
|
(preference, newValue) -> {
|
||||||
ImageStrategy.setPreferredImageQuality(PreferredImageQuality
|
ImageStrategy.setPreferredImageQuality(PreferredImageQuality
|
||||||
.fromPreferenceKey(requireContext(), (String) newValue));
|
.fromPreferenceKey(requireContext(), (String) newValue));
|
||||||
final var loader = SingletonImageLoader.get(preference.getContext());
|
final var loader = SingletonImageLoader.get(preference.getContext());
|
||||||
loader.getMemoryCache().clear();
|
loader.getMemoryCache().clear();
|
||||||
loader.getDiskCache().clear();
|
loader.getDiskCache().clear();
|
||||||
Toast.makeText(preference.getContext(),
|
Toast.makeText(preference.getContext(),
|
||||||
R.string.thumbnail_cache_wipe_complete_notice, Toast.LENGTH_SHORT)
|
R.string.thumbnail_cache_wipe_complete_notice, Toast.LENGTH_SHORT)
|
||||||
.show();
|
.show();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +0,0 @@
|
|||||||
package org.schabi.newpipe.settings
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import org.schabi.newpipe.R
|
|
||||||
import org.schabi.newpipe.settings.viewmodel.SettingsViewModel
|
|
||||||
import org.schabi.newpipe.ui.SwitchPreference
|
|
||||||
import org.schabi.newpipe.ui.theme.SizeTokens
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DebugScreen(viewModel: SettingsViewModel, modifier: Modifier = Modifier) {
|
|
||||||
val settingsLayoutRedesign by viewModel.settingsLayoutRedesign.collectAsState()
|
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
|
||||||
SwitchPreference(
|
|
||||||
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
|
|
||||||
R.string.settings_layout_redesign,
|
|
||||||
settingsLayoutRedesign,
|
|
||||||
viewModel::toggleSettingsLayoutRedesign
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import android.widget.TextView;
|
|||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.compose.ui.platform.ComposeView;
|
|
||||||
import androidx.fragment.app.DialogFragment;
|
import androidx.fragment.app.DialogFragment;
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
@@ -20,8 +19,6 @@ import org.schabi.newpipe.R;
|
|||||||
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
|
||||||
import org.schabi.newpipe.error.ErrorUtil;
|
import org.schabi.newpipe.error.ErrorUtil;
|
||||||
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
import org.schabi.newpipe.local.subscription.SubscriptionManager;
|
||||||
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
|
|
||||||
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
|
|
||||||
import org.schabi.newpipe.util.ThemeHelper;
|
import org.schabi.newpipe.util.ThemeHelper;
|
||||||
import org.schabi.newpipe.util.image.CoilHelper;
|
import org.schabi.newpipe.util.image.CoilHelper;
|
||||||
|
|
||||||
@@ -60,7 +57,7 @@ public class SelectChannelFragment extends DialogFragment {
|
|||||||
private OnCancelListener onCancelListener = null;
|
private OnCancelListener onCancelListener = null;
|
||||||
|
|
||||||
private ProgressBar progressBar;
|
private ProgressBar progressBar;
|
||||||
private ComposeView emptyView;
|
private TextView emptyView;
|
||||||
private RecyclerView recyclerView;
|
private RecyclerView recyclerView;
|
||||||
|
|
||||||
private List<SubscriptionEntity> subscriptions = new Vector<>();
|
private List<SubscriptionEntity> subscriptions = new Vector<>();
|
||||||
@@ -94,8 +91,6 @@ public class SelectChannelFragment extends DialogFragment {
|
|||||||
|
|
||||||
progressBar = v.findViewById(R.id.progressBar);
|
progressBar = v.findViewById(R.id.progressBar);
|
||||||
emptyView = v.findViewById(R.id.empty_state_view);
|
emptyView = v.findViewById(R.id.empty_state_view);
|
||||||
|
|
||||||
EmptyStateUtil.setEmptyStateComposable(emptyView, EmptyStateSpec.NoSubscriptions);
|
|
||||||
progressBar.setVisibility(View.VISIBLE);
|
progressBar.setVisibility(View.VISIBLE);
|
||||||
recyclerView.setVisibility(View.GONE);
|
recyclerView.setVisibility(View.GONE);
|
||||||
emptyView.setVisibility(View.GONE);
|
emptyView.setVisibility(View.GONE);
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import android.widget.ProgressBar;
|
|||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.compose.ui.platform.ComposeView;
|
|
||||||
import androidx.fragment.app.DialogFragment;
|
import androidx.fragment.app.DialogFragment;
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
@@ -28,8 +27,6 @@ import org.schabi.newpipe.error.ErrorUtil;
|
|||||||
import org.schabi.newpipe.error.UserAction;
|
import org.schabi.newpipe.error.UserAction;
|
||||||
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
|
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
|
||||||
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
|
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
|
||||||
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
|
|
||||||
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
|
|
||||||
import org.schabi.newpipe.util.image.CoilHelper;
|
import org.schabi.newpipe.util.image.CoilHelper;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -43,7 +40,7 @@ public class SelectPlaylistFragment extends DialogFragment {
|
|||||||
private OnSelectedListener onSelectedListener = null;
|
private OnSelectedListener onSelectedListener = null;
|
||||||
|
|
||||||
private ProgressBar progressBar;
|
private ProgressBar progressBar;
|
||||||
private ComposeView emptyView;
|
private TextView emptyView;
|
||||||
private RecyclerView recyclerView;
|
private RecyclerView recyclerView;
|
||||||
private Disposable disposable = null;
|
private Disposable disposable = null;
|
||||||
|
|
||||||
@@ -65,7 +62,6 @@ public class SelectPlaylistFragment extends DialogFragment {
|
|||||||
recyclerView = v.findViewById(R.id.items_list);
|
recyclerView = v.findViewById(R.id.items_list);
|
||||||
emptyView = v.findViewById(R.id.empty_state_view);
|
emptyView = v.findViewById(R.id.empty_state_view);
|
||||||
|
|
||||||
EmptyStateUtil.setEmptyStateComposable(emptyView, EmptyStateSpec.NoBookmarkedPlaylist);
|
|
||||||
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
|
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
|
||||||
final SelectPlaylistAdapter playlistAdapter = new SelectPlaylistAdapter();
|
final SelectPlaylistAdapter playlistAdapter = new SelectPlaylistAdapter();
|
||||||
recyclerView.setAdapter(playlistAdapter);
|
recyclerView.setAdapter(playlistAdapter);
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
package org.schabi.newpipe.settings
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import org.schabi.newpipe.R
|
|
||||||
import org.schabi.newpipe.ui.TextPreference
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SettingsScreen(
|
|
||||||
onSelectSettingOption: (SettingsScreenKey) -> Unit,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Column(modifier = modifier) {
|
|
||||||
TextPreference(
|
|
||||||
title = R.string.settings_category_debug_title,
|
|
||||||
onClick = { onSelectSettingOption(SettingsScreenKey.DEBUG) }
|
|
||||||
)
|
|
||||||
HorizontalDivider(color = Color.Black)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
package org.schabi.newpipe.settings
|
|
||||||
|
|
||||||
import android.os.Bundle
|
|
||||||
import androidx.activity.ComponentActivity
|
|
||||||
import androidx.activity.compose.setContent
|
|
||||||
import androidx.activity.viewModels
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.navigation.compose.NavHost
|
|
||||||
import androidx.navigation.compose.composable
|
|
||||||
import androidx.navigation.compose.rememberNavController
|
|
||||||
import androidx.navigation.navArgument
|
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import org.schabi.newpipe.R
|
|
||||||
import org.schabi.newpipe.settings.viewmodel.SettingsViewModel
|
|
||||||
import org.schabi.newpipe.ui.Toolbar
|
|
||||||
import org.schabi.newpipe.ui.theme.AppTheme
|
|
||||||
|
|
||||||
const val SCREEN_TITLE_KEY = "SCREEN_TITLE_KEY"
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class SettingsV2Activity : ComponentActivity() {
|
|
||||||
|
|
||||||
private val settingsViewModel: SettingsViewModel by viewModels()
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
|
|
||||||
setContent {
|
|
||||||
val navController = rememberNavController()
|
|
||||||
var screenTitle by remember { mutableIntStateOf(SettingsScreenKey.ROOT.screenTitle) }
|
|
||||||
navController.addOnDestinationChangedListener { _, _, arguments ->
|
|
||||||
screenTitle =
|
|
||||||
arguments?.getInt(SCREEN_TITLE_KEY) ?: SettingsScreenKey.ROOT.screenTitle
|
|
||||||
}
|
|
||||||
|
|
||||||
AppTheme {
|
|
||||||
Scaffold(topBar = {
|
|
||||||
Toolbar(
|
|
||||||
title = stringResource(id = screenTitle),
|
|
||||||
hasSearch = true,
|
|
||||||
onSearchQueryChange = null // TODO: Add suggestions logic
|
|
||||||
)
|
|
||||||
}) { padding ->
|
|
||||||
NavHost(
|
|
||||||
navController = navController,
|
|
||||||
startDestination = SettingsScreenKey.ROOT.name,
|
|
||||||
modifier = Modifier.padding(padding)
|
|
||||||
) {
|
|
||||||
composable(
|
|
||||||
SettingsScreenKey.ROOT.name,
|
|
||||||
listOf(createScreenTitleArg(SettingsScreenKey.ROOT.screenTitle))
|
|
||||||
) {
|
|
||||||
SettingsScreen(onSelectSettingOption = { screen ->
|
|
||||||
navController.navigate(screen.name)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
composable(
|
|
||||||
SettingsScreenKey.DEBUG.name,
|
|
||||||
listOf(createScreenTitleArg(SettingsScreenKey.DEBUG.screenTitle))
|
|
||||||
) {
|
|
||||||
DebugScreen(settingsViewModel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun createScreenTitleArg(@StringRes screenTitle: Int) = navArgument(SCREEN_TITLE_KEY) {
|
|
||||||
defaultValue = screenTitle
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class SettingsScreenKey(@StringRes val screenTitle: Int) {
|
|
||||||
ROOT(R.string.settings),
|
|
||||||
DEBUG(R.string.settings_category_debug_title)
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ package org.schabi.newpipe.settings;
|
|||||||
|
|
||||||
import android.content.SharedPreferences;
|
import android.content.SharedPreferences;
|
||||||
import android.content.res.Resources;
|
import android.content.res.Resources;
|
||||||
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.provider.Settings;
|
import android.provider.Settings;
|
||||||
import android.text.format.DateUtils;
|
import android.text.format.DateUtils;
|
||||||
@@ -32,7 +33,8 @@ public class VideoAudioSettingsFragment extends BasePreferenceFragment {
|
|||||||
// 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 (getString(R.string.minimize_on_exit_key).equals(key)) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M
|
||||||
|
&& getString(R.string.minimize_on_exit_key).equals(key)) {
|
||||||
final String newSetting = sharedPreferences.getString(key, 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))
|
||||||
|
|||||||
@@ -77,13 +77,11 @@ class NotificationModeConfigFragment : Fragment() {
|
|||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("Deprecated in Java")
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
|
||||||
super.onCreateOptionsMenu(menu, inflater)
|
super.onCreateOptionsMenu(menu, inflater)
|
||||||
inflater.inflate(R.menu.menu_notifications_channels, menu)
|
inflater.inflate(R.menu.menu_notifications_channels, menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Deprecated("Deprecated in Java")
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||||
return when (item.itemId) {
|
return when (item.itemId) {
|
||||||
R.id.action_toggle_all -> {
|
R.id.action_toggle_all -> {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
package org.schabi.newpipe.settings.preferencesearch;
|
package org.schabi.newpipe.settings.preferencesearch;
|
||||||
|
|
||||||
import static org.schabi.newpipe.ui.emptystate.EmptyStateUtil.setEmptyStateComposable;
|
|
||||||
|
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
@@ -13,7 +11,6 @@ import androidx.fragment.app.Fragment;
|
|||||||
import androidx.recyclerview.widget.LinearLayoutManager;
|
import androidx.recyclerview.widget.LinearLayoutManager;
|
||||||
|
|
||||||
import org.schabi.newpipe.databinding.SettingsPreferencesearchFragmentBinding;
|
import org.schabi.newpipe.databinding.SettingsPreferencesearchFragmentBinding;
|
||||||
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -42,7 +39,6 @@ public class PreferenceSearchFragment extends Fragment {
|
|||||||
binding = SettingsPreferencesearchFragmentBinding.inflate(inflater, container, false);
|
binding = SettingsPreferencesearchFragmentBinding.inflate(inflater, container, false);
|
||||||
|
|
||||||
binding.searchResults.setLayoutManager(new LinearLayoutManager(getContext()));
|
binding.searchResults.setLayoutManager(new LinearLayoutManager(getContext()));
|
||||||
setEmptyStateComposable(binding.emptyStateView, EmptyStateSpec.NoSearchResult);
|
|
||||||
|
|
||||||
adapter = new PreferenceSearchAdapter();
|
adapter = new PreferenceSearchAdapter();
|
||||||
adapter.setOnItemClickListener(this::onItemClicked);
|
adapter.setOnItemClickListener(this::onItemClicked);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import androidx.annotation.XmlRes
|
|||||||
*
|
*
|
||||||
* @param key Key of the setting/preference. E.g. used inside [android.content.SharedPreferences].
|
* @param key Key of the setting/preference. E.g. used inside [android.content.SharedPreferences].
|
||||||
* @param title Title of the setting, e.g. 'Default resolution' or 'Show higher resolutions'.
|
* @param title Title of the setting, e.g. 'Default resolution' or 'Show higher resolutions'.
|
||||||
* @param summary Summary of the setting, e.g. '480p' or 'Only some devices can play 1440p/4k'.
|
* @param summary Summary of the setting, e.g. '480p' or 'Only some devices can play 2k/4k'.
|
||||||
* @param entries Possible entries of the setting, e.g. 480p,720p,...
|
* @param entries Possible entries of the setting, e.g. 480p,720p,...
|
||||||
* @param breadcrumbs Breadcrumbs - a hint where the setting is located e.g. 'Video and Audio > Player'
|
* @param breadcrumbs Breadcrumbs - a hint where the setting is located e.g. 'Video and Audio > Player'
|
||||||
* @param searchIndexItemResId The xml-resource where this item was found/built from.
|
* @param searchIndexItemResId The xml-resource where this item was found/built from.
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
package org.schabi.newpipe.settings.viewmodel
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.SharedPreferences
|
|
||||||
import androidx.lifecycle.AndroidViewModel
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
|
||||||
import org.schabi.newpipe.R
|
|
||||||
import org.schabi.newpipe.util.Localization
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class SettingsViewModel @Inject constructor(
|
|
||||||
@ApplicationContext context: Context,
|
|
||||||
private val preferenceManager: SharedPreferences
|
|
||||||
) : AndroidViewModel(context.applicationContext as Application) {
|
|
||||||
|
|
||||||
private var settingsLayoutRedesignPref: Boolean
|
|
||||||
get() = preferenceManager.getBoolean(
|
|
||||||
Localization.compatGetString(getApplication(), R.string.settings_layout_redesign_key),
|
|
||||||
false
|
|
||||||
)
|
|
||||||
set(value) {
|
|
||||||
preferenceManager.edit().putBoolean(
|
|
||||||
Localization.compatGetString(getApplication(), R.string.settings_layout_redesign_key),
|
|
||||||
value
|
|
||||||
).apply()
|
|
||||||
}
|
|
||||||
private val _settingsLayoutRedesign: MutableStateFlow<Boolean> =
|
|
||||||
MutableStateFlow(settingsLayoutRedesignPref)
|
|
||||||
val settingsLayoutRedesign = _settingsLayoutRedesign.asStateFlow()
|
|
||||||
|
|
||||||
fun toggleSettingsLayoutRedesign(newState: Boolean) {
|
|
||||||
_settingsLayoutRedesign.value = newState
|
|
||||||
settingsLayoutRedesignPref = newState
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
package org.schabi.newpipe.ui
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import org.schabi.newpipe.ui.theme.SizeTokens
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SwitchPreference(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
@StringRes title: Int,
|
|
||||||
isChecked: Boolean,
|
|
||||||
onCheckedChange: (Boolean) -> Unit,
|
|
||||||
@StringRes summary: Int? = null
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = title),
|
|
||||||
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
textAlign = TextAlign.Start
|
|
||||||
)
|
|
||||||
summary?.let {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = summary),
|
|
||||||
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
textAlign = TextAlign.Start
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(modifier = Modifier.width(SizeTokens.SpacingSmall))
|
|
||||||
Switch(checked = isChecked, onCheckedChange = onCheckedChange)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package org.schabi.newpipe.ui
|
|
||||||
|
|
||||||
import androidx.annotation.DrawableRes
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.defaultMinSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import org.schabi.newpipe.ui.theme.SizeTokens
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TextPreference(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
@StringRes title: Int,
|
|
||||||
@DrawableRes icon: Int? = null,
|
|
||||||
@StringRes summary: Int? = null,
|
|
||||||
onClick: () -> Unit
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(SizeTokens.SpacingSmall)
|
|
||||||
.defaultMinSize(minHeight = SizeTokens.SpaceMinSize)
|
|
||||||
.clickable { onClick() }
|
|
||||||
) {
|
|
||||||
icon?.let {
|
|
||||||
Icon(
|
|
||||||
painter = painterResource(id = icon),
|
|
||||||
contentDescription = "icon for $title preference"
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(SizeTokens.SpacingSmall))
|
|
||||||
}
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = title),
|
|
||||||
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
textAlign = TextAlign.Start
|
|
||||||
)
|
|
||||||
summary?.let {
|
|
||||||
Text(
|
|
||||||
text = stringResource(id = summary),
|
|
||||||
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
textAlign = TextAlign.Start
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package org.schabi.newpipe.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.RowScope
|
|
||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.SearchBar
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TopAppBar
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.painterResource
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import org.schabi.newpipe.R
|
|
||||||
import org.schabi.newpipe.ui.theme.AppTheme
|
|
||||||
import org.schabi.newpipe.ui.theme.SizeTokens
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TextAction(text: String, modifier: Modifier = Modifier) {
|
|
||||||
Text(text = text, color = MaterialTheme.colorScheme.onSurface, modifier = modifier)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun NavigationIcon() {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = "Back",
|
|
||||||
modifier = Modifier.padding(horizontal = SizeTokens.SpacingExtraSmall)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SearchSuggestionItem(text: String) {
|
|
||||||
// TODO: Add more components here to display all the required details of a search suggestion item.
|
|
||||||
Text(text = text)
|
|
||||||
}
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
|
||||||
fun Toolbar(
|
|
||||||
title: String,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
hasNavigationIcon: Boolean = true,
|
|
||||||
hasSearch: Boolean = false,
|
|
||||||
onSearchQueryChange: ((String) -> List<String>)? = null,
|
|
||||||
actions: @Composable RowScope.() -> Unit = {}
|
|
||||||
) {
|
|
||||||
var isSearchActive by remember { mutableStateOf(false) }
|
|
||||||
var query by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
Column {
|
|
||||||
TopAppBar(
|
|
||||||
title = { Text(text = title) },
|
|
||||||
modifier = modifier,
|
|
||||||
navigationIcon = { if (hasNavigationIcon) NavigationIcon() },
|
|
||||||
actions = {
|
|
||||||
actions()
|
|
||||||
if (hasSearch) {
|
|
||||||
IconButton(onClick = { isSearchActive = true }) {
|
|
||||||
Icon(
|
|
||||||
painterResource(id = R.drawable.ic_search),
|
|
||||||
contentDescription = stringResource(id = R.string.search),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
if (isSearchActive) {
|
|
||||||
SearchBar(
|
|
||||||
query = query,
|
|
||||||
onQueryChange = { query = it },
|
|
||||||
onSearch = {},
|
|
||||||
placeholder = {
|
|
||||||
Text(text = stringResource(id = R.string.search))
|
|
||||||
},
|
|
||||||
active = true,
|
|
||||||
onActiveChange = {
|
|
||||||
isSearchActive = it
|
|
||||||
}
|
|
||||||
) {
|
|
||||||
onSearchQueryChange?.invoke(query)?.takeIf { it.isNotEmpty() }
|
|
||||||
?.map { suggestionText -> SearchSuggestionItem(text = suggestionText) }
|
|
||||||
?: run {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxHeight()
|
|
||||||
.fillMaxWidth(),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Text(text = "╰(°●°╰)")
|
|
||||||
Text(text = stringResource(id = R.string.search_no_results))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview
|
|
||||||
@Composable
|
|
||||||
fun ToolbarPreview() {
|
|
||||||
AppTheme {
|
|
||||||
Toolbar(
|
|
||||||
title = "Title",
|
|
||||||
hasSearch = true,
|
|
||||||
onSearchQueryChange = { emptyList() },
|
|
||||||
actions = {
|
|
||||||
TextAction(text = "Action1")
|
|
||||||
TextAction(text = "Action2")
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
package org.schabi.newpipe.ui.components.about
|
|
||||||
|
|
||||||
import androidx.annotation.StringRes
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.wrapContentSize
|
|
||||||
import androidx.compose.foundation.layout.wrapContentWidth
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.NonRestartableComposable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
|
||||||
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import my.nanihadesuka.compose.ColumnScrollbar
|
|
||||||
import org.schabi.newpipe.BuildConfig
|
|
||||||
import org.schabi.newpipe.R
|
|
||||||
import org.schabi.newpipe.ui.components.common.defaultThemedScrollbarSettings
|
|
||||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
|
||||||
import org.schabi.newpipe.util.image.NewPipeSquircleIcon
|
|
||||||
|
|
||||||
private val ABOUT_ITEMS = listOf(
|
|
||||||
AboutData(R.string.faq_title, R.string.faq_description, R.string.faq, R.string.faq_url),
|
|
||||||
AboutData(
|
|
||||||
R.string.contribution_title,
|
|
||||||
R.string.contribution_encouragement,
|
|
||||||
R.string.view_on_github,
|
|
||||||
R.string.github_url
|
|
||||||
),
|
|
||||||
AboutData(
|
|
||||||
R.string.donation_title,
|
|
||||||
R.string.donation_encouragement,
|
|
||||||
R.string.give_back,
|
|
||||||
R.string.donation_url
|
|
||||||
),
|
|
||||||
AboutData(
|
|
||||||
R.string.website_title,
|
|
||||||
R.string.website_encouragement,
|
|
||||||
R.string.open_in_browser,
|
|
||||||
R.string.website_url
|
|
||||||
),
|
|
||||||
AboutData(
|
|
||||||
R.string.privacy_policy_title,
|
|
||||||
R.string.privacy_policy_encouragement,
|
|
||||||
R.string.read_privacy_policy,
|
|
||||||
R.string.privacy_policy_url
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
private class AboutData(
|
|
||||||
@StringRes val title: Int,
|
|
||||||
@StringRes val description: Int,
|
|
||||||
@StringRes val buttonText: Int,
|
|
||||||
@StringRes val url: Int
|
|
||||||
)
|
|
||||||
|
|
||||||
private class AboutDataProvider : CollectionPreviewParameterProvider<AboutData>(ABOUT_ITEMS)
|
|
||||||
|
|
||||||
@Preview(backgroundColor = 0xFFFFFFFF, showBackground = true)
|
|
||||||
@Composable
|
|
||||||
@NonRestartableComposable
|
|
||||||
fun AboutTab() {
|
|
||||||
val scrollState = rememberScrollState()
|
|
||||||
|
|
||||||
ColumnScrollbar(state = scrollState, settings = defaultThemedScrollbarSettings()) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.verticalScroll(scrollState),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.padding(16.dp)
|
|
||||||
.fillMaxWidth()
|
|
||||||
.wrapContentSize(Alignment.Center),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
|
||||||
Image(
|
|
||||||
imageVector = NewPipeSquircleIcon,
|
|
||||||
contentDescription = stringResource(R.string.app_name),
|
|
||||||
modifier = Modifier.size(64.dp)
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.app_name),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = BuildConfig.VERSION_NAME,
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
text = stringResource(R.string.app_description),
|
|
||||||
textAlign = TextAlign.Center
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
for (item in ABOUT_ITEMS) {
|
|
||||||
AboutItem(item, Modifier.padding(horizontal = 16.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(backgroundColor = 0xFFFFFFFF, showBackground = true)
|
|
||||||
@Composable
|
|
||||||
@NonRestartableComposable
|
|
||||||
private fun AboutItem(
|
|
||||||
@PreviewParameter(AboutDataProvider::class) aboutData: AboutData,
|
|
||||||
modifier: Modifier = Modifier
|
|
||||||
) {
|
|
||||||
Column(modifier = modifier) {
|
|
||||||
Text(
|
|
||||||
text = stringResource(aboutData.title),
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = stringResource(aboutData.description),
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
TextButton(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.wrapContentWidth(Alignment.End),
|
|
||||||
onClick = { ShareUtils.openUrlInApp(context, context.getString(aboutData.url)) }
|
|
||||||
) {
|
|
||||||
Text(text = stringResource(aboutData.buttonText))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
@file:OptIn(ExperimentalLayoutApi::class)
|
|
||||||
|
|
||||||
package org.schabi.newpipe.ui.components.about
|
|
||||||
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
|
||||||
import androidx.compose.foundation.layout.FlowRow
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.Badge
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
|
||||||
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
|
|
||||||
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import com.mikepenz.aboutlibraries.entity.Developer
|
|
||||||
import com.mikepenz.aboutlibraries.entity.Library
|
|
||||||
import com.mikepenz.aboutlibraries.entity.License
|
|
||||||
import com.mikepenz.aboutlibraries.entity.Organization
|
|
||||||
import com.mikepenz.aboutlibraries.entity.Scm
|
|
||||||
import com.mikepenz.aboutlibraries.ui.compose.m3.util.author
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
|
||||||
import kotlinx.collections.immutable.toImmutableSet
|
|
||||||
import org.schabi.newpipe.ui.theme.AppTheme
|
|
||||||
import org.schabi.newpipe.util.external_communication.ShareUtils
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Library(
|
|
||||||
@PreviewParameter(LibraryProvider::class) library: Library,
|
|
||||||
showLicenseDialog: (licenseFilename: String) -> Unit,
|
|
||||||
descriptionMaxLines: Int
|
|
||||||
) {
|
|
||||||
val spdxLicense = library.licenses.firstOrNull()?.spdxId?.takeIf { it.isNotBlank() }
|
|
||||||
val licenseAssetPath = spdxLicense?.let { SPDX_ID_TO_ASSET_PATH[it] }
|
|
||||||
val context = LocalContext.current
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = (
|
|
||||||
if (licenseAssetPath != null) {
|
|
||||||
Modifier.clickable {
|
|
||||||
showLicenseDialog(licenseAssetPath)
|
|
||||||
}
|
|
||||||
} else if (spdxLicense != null) {
|
|
||||||
Modifier.clickable {
|
|
||||||
ShareUtils.openUrlInBrowser(context, "https://spdx.org/licenses/$spdxLicense.html")
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.padding(vertical = 8.dp, horizontal = 16.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = library.name,
|
|
||||||
modifier = Modifier.weight(0.75f),
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
val version = library.artifactVersion
|
|
||||||
if (!version.isNullOrBlank()) {
|
|
||||||
Text(
|
|
||||||
version,
|
|
||||||
modifier = if (version.length > 12) {
|
|
||||||
// limit the version size if it's too many characters (can happen e.g. if
|
|
||||||
// the version is a commit hash)
|
|
||||||
Modifier.weight(0.25f)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}.padding(start = 8.dp),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val author = library.author
|
|
||||||
if (author.isNotBlank()) {
|
|
||||||
Text(
|
|
||||||
text = author,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val description = library.description
|
|
||||||
if (!description.isNullOrBlank() && description != library.name) {
|
|
||||||
Spacer(Modifier.height(3.dp))
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
maxLines = descriptionMaxLines,
|
|
||||||
overflow = TextOverflow.Ellipsis
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (library.licenses.isNotEmpty()) {
|
|
||||||
FlowRow(
|
|
||||||
modifier = Modifier.padding(top = 6.dp, bottom = 4.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
|
||||||
) {
|
|
||||||
library.licenses.forEach {
|
|
||||||
Badge {
|
|
||||||
Text(text = it.spdxId?.takeIf { it.isNotBlank() } ?: it.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
|
|
||||||
@Composable
|
|
||||||
private fun LibraryPreview(@PreviewParameter(LibraryProvider::class) library: Library) {
|
|
||||||
AppTheme {
|
|
||||||
Library(library, {}, 2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class LibraryProvider : CollectionPreviewParameterProvider<Library>(
|
|
||||||
listOf(
|
|
||||||
Library(
|
|
||||||
uniqueId = "org.schabi.newpipe.extractor",
|
|
||||||
artifactVersion = "v0.24.3",
|
|
||||||
name = "NewPipeExtractor",
|
|
||||||
description = "NewPipe Extractor is a library for extracting things from streaming sites. It is a core component of NewPipe, but could be used independently.",
|
|
||||||
website = "https://newpipe.net",
|
|
||||||
developers = listOf(Developer("TeamNewPipe", "https://newpipe.net")).toImmutableList(),
|
|
||||||
organization = Organization("TeamNewPipe", "https://newpipe.net"),
|
|
||||||
scm = Scm(null, null, "https://github.com/TeamNewPipe/NewPipeExtractor"),
|
|
||||||
licenses = setOf(
|
|
||||||
License(
|
|
||||||
name = "GNU General Public License v3.0",
|
|
||||||
url = "https://api.github.com/licenses/gpl-3.0",
|
|
||||||
year = null,
|
|
||||||
spdxId = "GPL-3.0-only",
|
|
||||||
licenseContent = LoremIpsum().values.first(),
|
|
||||||
hash = "1234"
|
|
||||||
),
|
|
||||||
License(
|
|
||||||
name = "GNU General Public License v3.0",
|
|
||||||
url = "https://api.github.com/licenses/gpl-3.0",
|
|
||||||
year = null,
|
|
||||||
spdxId = "GPL-3.0-only",
|
|
||||||
licenseContent = LoremIpsum().values.first(),
|
|
||||||
hash = "4321"
|
|
||||||
)
|
|
||||||
).toImmutableSet()
|
|
||||||
),
|
|
||||||
Library(
|
|
||||||
uniqueId = "org.schabi.newpipe.extractor",
|
|
||||||
artifactVersion = "v0.24.3",
|
|
||||||
name = "NewPipeExtractor",
|
|
||||||
description = "NewPipe Extractor is a library for extracting things from streaming sites. It is a core component of NewPipe, but could be used independently.",
|
|
||||||
website = null,
|
|
||||||
developers = listOf<Developer>().toImmutableList(),
|
|
||||||
organization = null,
|
|
||||||
scm = null,
|
|
||||||
licenses = setOf(
|
|
||||||
License(
|
|
||||||
name = "GNU General Public License v3.0",
|
|
||||||
url = "https://api.github.com/licenses/gpl-3.0",
|
|
||||||
year = null,
|
|
||||||
spdxId = "GPL-3.0-only",
|
|
||||||
licenseContent = LoremIpsum().values.first(),
|
|
||||||
hash = "1234"
|
|
||||||
)
|
|
||||||
).toImmutableSet()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
/*
|
|
||||||
* The library definitions for most libraries are autogenerated by the AboutLibraries plugin.
|
|
||||||
* This file is only for TeamNewPipe-related libraries.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.schabi.newpipe.ui.components.about
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.mikepenz.aboutlibraries.entity.Developer
|
|
||||||
import com.mikepenz.aboutlibraries.entity.Library
|
|
||||||
import com.mikepenz.aboutlibraries.entity.License
|
|
||||||
import com.mikepenz.aboutlibraries.entity.Scm
|
|
||||||
import kotlinx.collections.immutable.ImmutableSet
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
|
||||||
import kotlinx.collections.immutable.toImmutableSet
|
|
||||||
import org.schabi.newpipe.BuildConfig
|
|
||||||
import org.schabi.newpipe.R
|
|
||||||
|
|
||||||
val SPDX_ID_TO_ASSET_PATH = mapOf(
|
|
||||||
"Apache-2.0" to "apache2.html",
|
|
||||||
"EPL-1.0" to "epl1.html",
|
|
||||||
"GPL-3.0-only" to "gpl_3.html",
|
|
||||||
"GPL-3.0-or-later" to "gpl_3.html",
|
|
||||||
"MIT" to "mit.html",
|
|
||||||
"MPL-2.0" to "mpl2.html"
|
|
||||||
)
|
|
||||||
|
|
||||||
fun getFirstPartyLibraries(
|
|
||||||
context: Context,
|
|
||||||
teamNewPipeLibraries: List<Library>
|
|
||||||
): List<Library> {
|
|
||||||
val gpl3 = setOf(
|
|
||||||
License(
|
|
||||||
name = "GNU General Public License v3.0",
|
|
||||||
url = "https://www.gnu.org/licenses/gpl-3.0.txt",
|
|
||||||
year = null,
|
|
||||||
spdxId = "GPL-3.0-or-later",
|
|
||||||
licenseContent = null,
|
|
||||||
hash = "GPL-3.0-or-later"
|
|
||||||
)
|
|
||||||
).toImmutableSet()
|
|
||||||
|
|
||||||
val npeId = "com.github.TeamNewPipe:NewPipeExtractor"
|
|
||||||
val npe = teamNewPipeLibraries.firstOrNull { it.uniqueId == npeId }
|
|
||||||
|
|
||||||
return listOf(
|
|
||||||
Library(
|
|
||||||
uniqueId = BuildConfig.APPLICATION_ID,
|
|
||||||
artifactVersion = BuildConfig.VERSION_NAME,
|
|
||||||
name = context.getString(R.string.app_name),
|
|
||||||
description = context.getString(R.string.app_description),
|
|
||||||
website = context.getString(R.string.website_url),
|
|
||||||
developers = listOf(
|
|
||||||
Developer(
|
|
||||||
name = context.getString(R.string.team_newpipe),
|
|
||||||
organisationUrl = context.getString(R.string.website_url)
|
|
||||||
)
|
|
||||||
).toImmutableList(),
|
|
||||||
organization = null,
|
|
||||||
scm = Scm(null, null, context.getString(R.string.github_url)),
|
|
||||||
licenses = gpl3
|
|
||||||
),
|
|
||||||
Library(
|
|
||||||
uniqueId = npeId,
|
|
||||||
artifactVersion = npe?.artifactVersion,
|
|
||||||
name = context.getString(R.string.newpipe_extractor),
|
|
||||||
description = context.getString(R.string.newpipe_extractor_description),
|
|
||||||
website = context.getString(R.string.newpipe_extractor_github_url),
|
|
||||||
developers = listOf(
|
|
||||||
Developer(
|
|
||||||
name = context.getString(R.string.team_newpipe),
|
|
||||||
organisationUrl = context.getString(R.string.website_url)
|
|
||||||
)
|
|
||||||
).toImmutableList(),
|
|
||||||
organization = null,
|
|
||||||
scm = Scm(null, null, context.getString(R.string.newpipe_extractor_github_url)),
|
|
||||||
licenses = gpl3
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getAdditionalThirdPartyLibraries(
|
|
||||||
context: Context,
|
|
||||||
teamNewPipeLibraries: List<Library>,
|
|
||||||
licenses: ImmutableSet<License>
|
|
||||||
): List<Library> {
|
|
||||||
val apache2 = licenses.firstOrNull { it.spdxId == "Apache-2.0" }
|
|
||||||
val mit = licenses.firstOrNull { it.spdxId == "MIT" }
|
|
||||||
val mpl2 = licenses.firstOrNull { it.spdxId == "MPL-2.0" }
|
|
||||||
|
|
||||||
val nanojsonId = "com.github.TeamNewPipe:nanojson"
|
|
||||||
val nanojson = teamNewPipeLibraries.firstOrNull { it.uniqueId == nanojsonId }
|
|
||||||
val nnfpId = "com.github.TeamNewPipe:NoNonsense-FilePicker"
|
|
||||||
val nnfp = teamNewPipeLibraries.firstOrNull { it.uniqueId == nnfpId }
|
|
||||||
|
|
||||||
return listOf(
|
|
||||||
Library(
|
|
||||||
uniqueId = nnfpId,
|
|
||||||
artifactVersion = nnfp?.artifactVersion,
|
|
||||||
name = "NoNonsense-FilePicker",
|
|
||||||
description = "A file/directory-picker for Android.",
|
|
||||||
website = "https://github.com/TeamNewPipe/NoNonsense-FilePicker",
|
|
||||||
developers = listOf(
|
|
||||||
Developer(
|
|
||||||
name = "Jonas Kalderstam",
|
|
||||||
organisationUrl = "https://github.com/spacecowboy/NoNonsense-FilePicker"
|
|
||||||
),
|
|
||||||
Developer(
|
|
||||||
name = context.getString(R.string.team_newpipe),
|
|
||||||
organisationUrl = context.getString(R.string.website_url)
|
|
||||||
)
|
|
||||||
).toImmutableList(),
|
|
||||||
organization = null,
|
|
||||||
scm = Scm(null, null, "https://github.com/TeamNewPipe/NoNonsense-FilePicker"),
|
|
||||||
licenses = listOfNotNull(mpl2).toImmutableSet()
|
|
||||||
),
|
|
||||||
Library(
|
|
||||||
uniqueId = nanojsonId,
|
|
||||||
artifactVersion = nanojson?.artifactVersion,
|
|
||||||
name = "nanojson",
|
|
||||||
description = "nanojson is a tiny, fast, and compliant JSON parser and writer for Java.",
|
|
||||||
website = "https://github.com/TeamNewPipe/nanojson",
|
|
||||||
developers = listOf(
|
|
||||||
Developer(
|
|
||||||
name = "mmastrac",
|
|
||||||
organisationUrl = "https://github.com/mmastrac/nanojson"
|
|
||||||
),
|
|
||||||
Developer(
|
|
||||||
name = context.getString(R.string.team_newpipe),
|
|
||||||
organisationUrl = context.getString(R.string.website_url)
|
|
||||||
)
|
|
||||||
).toImmutableList(),
|
|
||||||
organization = null,
|
|
||||||
scm = Scm(null, null, "https://github.com/TeamNewPipe/nanojson"),
|
|
||||||
licenses = listOfNotNull(mit, apache2).toImmutableSet()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
|
|
||||||
package org.schabi.newpipe.ui.components.about
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.LocalContentColor
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.contentColorFor
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar
|
|
||||||
import org.schabi.newpipe.ui.components.common.LoadingIndicator
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun LicenseDialog(licenseHtml: AnnotatedString, onDismissRequest: () -> Unit) {
|
|
||||||
val lazyListState = rememberLazyListState()
|
|
||||||
|
|
||||||
ModalBottomSheet(onDismissRequest) {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
// contentColorFor(MaterialTheme.colorScheme.containerColor), i.e. ModalBottomSheet's
|
|
||||||
// default background color, does not resolve correctly, so need to manually set the
|
|
||||||
// content color for MaterialTheme.colorScheme.background instead
|
|
||||||
LocalContentColor provides contentColorFor(MaterialTheme.colorScheme.background)
|
|
||||||
) {
|
|
||||||
LazyColumnThemedScrollbar(state = lazyListState) {
|
|
||||||
LazyColumn(
|
|
||||||
state = lazyListState
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
if (licenseHtml.isEmpty()) {
|
|
||||||
LoadingIndicator(modifier = Modifier.padding(32.dp))
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
text = licenseHtml,
|
|
||||||
modifier = Modifier.padding(horizontal = 12.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
package org.schabi.newpipe.ui.components.about
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.NonRestartableComposable
|
|
||||||
import androidx.compose.runtime.collectAsState
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.res.stringResource
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
|
||||||
import org.schabi.newpipe.R
|
|
||||||
import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar
|
|
||||||
import org.schabi.newpipe.ui.components.common.LoadingIndicator
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
@NonRestartableComposable
|
|
||||||
fun LicenseTab(viewModel: LicenseTabViewModel = viewModel()) {
|
|
||||||
val lazyListState = rememberLazyListState()
|
|
||||||
val stateFlow = viewModel.state.collectAsState()
|
|
||||||
val state = stateFlow.value
|
|
||||||
|
|
||||||
if (state.licenseDialogHtml != null) {
|
|
||||||
LicenseDialog(
|
|
||||||
licenseHtml = state.licenseDialogHtml,
|
|
||||||
onDismissRequest = { viewModel.closeLicenseDialog() }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
LazyColumnThemedScrollbar(state = lazyListState) {
|
|
||||||
LazyColumn(
|
|
||||||
state = lazyListState
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.app_license_title),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
modifier = Modifier.padding(
|
|
||||||
start = 16.dp,
|
|
||||||
top = 16.dp,
|
|
||||||
end = 16.dp,
|
|
||||||
bottom = 8.dp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.app_license),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.padding(
|
|
||||||
start = 16.dp,
|
|
||||||
end = 16.dp,
|
|
||||||
bottom = 8.dp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (state.firstPartyLibraries == null) {
|
|
||||||
item {
|
|
||||||
LoadingIndicator(modifier = Modifier.padding(32.dp))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (library in state.firstPartyLibraries) {
|
|
||||||
item {
|
|
||||||
Library(
|
|
||||||
library = library,
|
|
||||||
showLicenseDialog = viewModel::showLicenseDialog,
|
|
||||||
descriptionMaxLines = Int.MAX_VALUE
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.title_licenses),
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
modifier = Modifier.padding(
|
|
||||||
start = 16.dp,
|
|
||||||
top = 16.dp,
|
|
||||||
end = 16.dp,
|
|
||||||
bottom = 8.dp
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (state.thirdPartyLibraries == null) {
|
|
||||||
item {
|
|
||||||
LoadingIndicator(modifier = Modifier.padding(32.dp))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (library in state.thirdPartyLibraries) {
|
|
||||||
item {
|
|
||||||
Library(
|
|
||||||
library = library,
|
|
||||||
showLicenseDialog = viewModel::showLicenseDialog,
|
|
||||||
descriptionMaxLines = 2
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
package org.schabi.newpipe.ui.components.about
|
|
||||||
|
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.fromHtml
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.lifecycle.viewModelScope
|
|
||||||
import com.mikepenz.aboutlibraries.Libs
|
|
||||||
import com.mikepenz.aboutlibraries.entity.Library
|
|
||||||
import com.mikepenz.aboutlibraries.util.withContext
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
|
||||||
import kotlinx.coroutines.flow.update
|
|
||||||
import kotlinx.coroutines.isActive
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.schabi.newpipe.App
|
|
||||||
|
|
||||||
class LicenseTabViewModel : ViewModel() {
|
|
||||||
private val _state = MutableStateFlow(LicenseTabState(null, null, null))
|
|
||||||
val state: StateFlow<LicenseTabState> = _state
|
|
||||||
private var licenseLoadJob: Job? = null
|
|
||||||
|
|
||||||
init {
|
|
||||||
viewModelScope.launch {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
loadLibraries()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun loadLibraries() {
|
|
||||||
val context = App.instance
|
|
||||||
val libs = Libs.Builder().withContext(context).build()
|
|
||||||
val (teamNewPipeLibraries, thirdParty) = libs.libraries
|
|
||||||
.toMutableList()
|
|
||||||
.partition { it.uniqueId.startsWith("com.github.TeamNewPipe") }
|
|
||||||
|
|
||||||
val firstParty = getFirstPartyLibraries(context, teamNewPipeLibraries)
|
|
||||||
val allThirdParty =
|
|
||||||
getAdditionalThirdPartyLibraries(context, teamNewPipeLibraries, libs.licenses) +
|
|
||||||
thirdParty
|
|
||||||
|
|
||||||
_state.update {
|
|
||||||
it.copy(
|
|
||||||
firstPartyLibraries = firstParty,
|
|
||||||
thirdPartyLibraries = allThirdParty
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun showLicenseDialog(filename: String) {
|
|
||||||
licenseLoadJob?.cancel()
|
|
||||||
_state.update { it.copy(licenseDialogHtml = AnnotatedString("")) }
|
|
||||||
licenseLoadJob = viewModelScope.launch {
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
val text = App.instance.assets.open(filename).bufferedReader().use { it.readText() }
|
|
||||||
val parsedHtml = AnnotatedString.fromHtml(text)
|
|
||||||
_state.update {
|
|
||||||
if (it.licenseDialogHtml != null && isActive) {
|
|
||||||
it.copy(licenseDialogHtml = parsedHtml)
|
|
||||||
} else {
|
|
||||||
it
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun closeLicenseDialog() {
|
|
||||||
licenseLoadJob?.cancel()
|
|
||||||
_state.update { it.copy(licenseDialogHtml = null) }
|
|
||||||
}
|
|
||||||
|
|
||||||
data class LicenseTabState(
|
|
||||||
val firstPartyLibraries: List<Library>?,
|
|
||||||
val thirdPartyLibraries: List<Library>?,
|
|
||||||
// null if dialog closed, empty if loading, otherwise license HTML content
|
|
||||||
val licenseDialogHtml: AnnotatedString?
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user