Signed-off-by: baalajimaestro <me@baalajimaestro.me>
This commit is contained in:
baalajimaestro 2023-12-26 17:42:56 +05:30
commit 155e66d07f
Signed by: baalajimaestro
GPG key ID: F93C394FE9BBAFD5
358 changed files with 11595 additions and 3955 deletions

17
.github/changed-lines-count-labeler.yml vendored Normal file
View file

@ -0,0 +1,17 @@
# Add 'size/small' label to any changes with less than 50 lines
size/small:
max: 49
# Add 'size/medium' label to any changes between 50 and 249 lines
size/medium:
min: 50
max: 249
# Add 'size/large' label to any changes between 250 and 749 lines
size/large:
min: 250
max: 749
# Add 'size/giant' label to any changes for more than 749 lines
size/giant:
min: 750

View file

@ -86,7 +86,7 @@ module.exports = async ({github, context}) => {
}); });
} }
// Asnyc replace function from https://stackoverflow.com/a/48032528 // Async replace function from https://stackoverflow.com/a/48032528
async function replaceAsync(str, regex, asyncFn) { async function replaceAsync(str, regex, asyncFn) {
const promises = []; const promises = [];
str.replace(regex, (match, ...args) => { str.replace(regex, (match, ...args) => {
@ -138,7 +138,7 @@ module.exports = async ({github, context}) => {
if (shouldModify) { if (shouldModify) {
wasMatchModified = true; wasMatchModified = true;
console.log(`Modifying match '${match}'`); console.log(`Modifying match '${match}'`);
return `<img alt="${g1}" src="${g2}" width=${Math.min(600, (IMG_MAX_HEIGHT_PX * probeAspectRatio).toFixed(0))} />`; return `<img alt="${g1}" src="${g2}" width=${Math.min(600, Math.floor(IMG_MAX_HEIGHT_PX * probeAspectRatio))} />`;
} }
console.log(`Match '${match}' is ok/will not be modified`); console.log(`Match '${match}' is ok/will not be modified`);

18
.github/workflows/pr-labeler.yml vendored Normal file
View file

@ -0,0 +1,18 @@
name: "PR size labeler"
on: [pull_request]
permissions:
contents: read
pull-requests: write
jobs:
changed-lines-count-labeler:
runs-on: ubuntu-latest
name: Automatically labelling pull requests based on the changed lines count
permissions:
pull-requests: write
steps:
- name: Set a label
uses: TeamNewPipe/changed-lines-count-labeler@main
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
configuration-path: .github/changed-lines-count-labeler.yml

View file

@ -12,7 +12,7 @@ plugins {
} }
android { android {
compileSdk 33 compileSdk 34
namespace 'org.schabi.newpipe' namespace 'org.schabi.newpipe'
defaultConfig { defaultConfig {
@ -20,8 +20,8 @@ android {
resValue "string", "app_name", "NewPipe SponsorBlock" resValue "string", "app_name", "NewPipe SponsorBlock"
minSdk 21 minSdk 21
targetSdk 33 targetSdk 33
versionCode 994 versionCode 995
versionName "0.25.2" versionName "0.26.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -50,9 +50,6 @@ android {
} }
} }
// Keep the release build type at the end of the list to override 'archivesBaseName' of
// debug build. This seems to be a Gradle bug, therefore
// TODO: update Gradle version
release { release {
if (System.properties.containsKey('packageSuffix')) { if (System.properties.containsKey('packageSuffix')) {
applicationIdSuffix System.getProperty('packageSuffix') applicationIdSuffix System.getProperty('packageSuffix')
@ -101,7 +98,9 @@ android {
resources { resources {
// remove two files which belong to jsoup // remove two files which belong to jsoup
// no idea how they ended up in the META-INF dir... // no idea how they ended up in the META-INF dir...
excludes += ['META-INF/README.md', 'META-INF/CHANGES'] excludes += ['META-INF/README.md', 'META-INF/CHANGES',
// 'COPYRIGHT' belongs to RxJava...
'META-INF/COPYRIGHT']
} }
} }
} }
@ -109,19 +108,18 @@ android {
ext { ext {
checkstyleVersion = '10.12.1' checkstyleVersion = '10.12.1'
androidxLifecycleVersion = '2.5.1' androidxLifecycleVersion = '2.6.2'
androidxRoomVersion = '2.5.2' androidxRoomVersion = '2.6.1'
androidxWorkVersion = '2.7.1' androidxWorkVersion = '2.8.1'
icepickVersion = '3.2.0' icepickVersion = '3.2.0'
exoPlayerVersion = '2.18.7' exoPlayerVersion = '2.18.7'
googleAutoServiceVersion = '1.0.1' googleAutoServiceVersion = '1.1.1'
groupieVersion = '2.10.1' groupieVersion = '2.10.1'
markwonVersion = '4.6.2' markwonVersion = '4.6.2'
leakCanaryVersion = '2.12' leakCanaryVersion = '2.12'
stethoVersion = '1.6.0' stethoVersion = '1.6.0'
mockitoVersion = '4.0.0'
} }
configurations { configurations {
@ -136,7 +134,7 @@ checkstyle {
toolVersion = checkstyleVersion toolVersion = checkstyleVersion
} }
task runCheckstyle(type: Checkstyle) { tasks.register('runCheckstyle', Checkstyle) {
source 'src' source 'src'
include '**/*.java' include '**/*.java'
exclude '**/gen/**' exclude '**/gen/**'
@ -157,7 +155,7 @@ task runCheckstyle(type: Checkstyle) {
def outputDir = "${project.buildDir}/reports/ktlint/" def outputDir = "${project.buildDir}/reports/ktlint/"
def inputFiles = project.fileTree(dir: "src", include: "**/*.kt") def inputFiles = project.fileTree(dir: "src", include: "**/*.kt")
task runKtlint(type: JavaExec) { tasks.register('runKtlint', JavaExec) {
inputs.files(inputFiles) inputs.files(inputFiles)
outputs.dir(outputDir) outputs.dir(outputDir)
getMainClass().set("com.pinterest.ktlint.Main") getMainClass().set("com.pinterest.ktlint.Main")
@ -166,7 +164,7 @@ task runKtlint(type: JavaExec) {
jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED")
} }
task formatKtlint(type: JavaExec) { tasks.register('formatKtlint', JavaExec) {
inputs.files(inputFiles) inputs.files(inputFiles)
outputs.dir(outputDir) outputs.dir(outputDir)
getMainClass().set("com.pinterest.ktlint.Main") getMainClass().set("com.pinterest.ktlint.Main")
@ -192,7 +190,7 @@ sonar {
dependencies { dependencies {
/** Desugaring **/ /** Desugaring **/
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.3' coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.0.4'
/** NewPipe libraries **/ /** NewPipe libraries **/
// You can use a local version by uncommenting a few lines in settings.gradle // You can use a local version by uncommenting a few lines in settings.gradle
@ -200,7 +198,7 @@ dependencies {
// name and the commit hash with the commit hash of the (pushed) commit you want to test // name and the commit hash with the commit hash of the (pushed) commit you want to test
// This works thanks to JitPack: https://jitpack.io/ // This works thanks to JitPack: https://jitpack.io/
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751' implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.22.7' implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.23.1'
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0' implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
/** Checkstyle **/ /** Checkstyle **/
@ -208,31 +206,31 @@ dependencies {
ktlint 'com.pinterest:ktlint:0.45.2' ktlint 'com.pinterest:ktlint:0.45.2'
/** Kotlin **/ /** Kotlin **/
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${kotlin_version}" implementation "org.jetbrains.kotlin:kotlin-stdlib:${kotlin_version}"
/** AndroidX **/ /** AndroidX **/
implementation 'androidx.appcompat:appcompat:1.5.1' implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.cardview:cardview:1.0.0' implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.core:core-ktx:1.10.0' implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.documentfile:documentfile:1.0.1' implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'androidx.fragment:fragment-ktx:1.4.1' implementation 'androidx.fragment:fragment-ktx:1.6.2'
implementation "androidx.lifecycle:lifecycle-livedata-ktx:${androidxLifecycleVersion}" implementation "androidx.lifecycle:lifecycle-livedata-ktx:${androidxLifecycleVersion}"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${androidxLifecycleVersion}" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${androidxLifecycleVersion}"
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
implementation 'androidx.media:media:1.6.0' implementation 'androidx.media:media:1.7.0'
implementation 'androidx.preference:preference:1.2.0' implementation 'androidx.preference:preference:1.2.1'
implementation 'androidx.recyclerview:recyclerview:1.2.1' implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation "androidx.room:room-runtime:${androidxRoomVersion}" implementation "androidx.room:room-runtime:${androidxRoomVersion}"
implementation "androidx.room:room-rxjava3:${androidxRoomVersion}" implementation "androidx.room:room-rxjava3:${androidxRoomVersion}"
kapt "androidx.room:room-compiler:${androidxRoomVersion}" kapt "androidx.room:room-compiler:${androidxRoomVersion}"
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
// Newer version specified to prevent accessibility regressions with RecyclerView, see: // Newer version specified to prevent accessibility regressions with RecyclerView, see:
// https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01 // https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01' implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}" implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}"
implementation "androidx.work:work-rxjava3:${androidxWorkVersion}" implementation "androidx.work:work-rxjava3:${androidxWorkVersion}"
implementation 'com.google.android.material:material:1.6.1' implementation 'com.google.android.material:material:1.11.0'
/** Third-party libraries **/ /** Third-party libraries **/
// Instance state boilerplate elimination // Instance state boilerplate elimination
@ -240,13 +238,10 @@ dependencies {
kapt "frankiesardo:icepick-processor:${icepickVersion}" kapt "frankiesardo:icepick-processor:${icepickVersion}"
// HTML parser // HTML parser
implementation "org.jsoup:jsoup:1.16.1" implementation "org.jsoup:jsoup:1.16.2"
// HTTP client // HTTP client
implementation "com.squareup.okhttp3:okhttp:4.11.0" implementation "com.squareup.okhttp3:okhttp:4.12.0"
// okhttp3:4.11.0 introduces a vulnerability from com.squareup.okio:okio@3.3.0,
// remove com.squareup.okio:okio when updating okhttp
implementation "com.squareup.okio:okio:3.4.0"
// Media player // Media player
implementation "com.google.android.exoplayer:exoplayer-core:${exoPlayerVersion}" implementation "com.google.android.exoplayer:exoplayer-core:${exoPlayerVersion}"
@ -275,19 +270,19 @@ dependencies {
implementation "io.noties.markwon:linkify:${markwonVersion}" implementation "io.noties.markwon:linkify:${markwonVersion}"
// Crash reporting // Crash reporting
implementation "ch.acra:acra-core:5.10.1" implementation "ch.acra:acra-core:5.11.3"
// Properly restarting // Properly restarting
implementation 'com.jakewharton:process-phoenix:2.1.2' implementation 'com.jakewharton:process-phoenix:2.1.2'
// Reactive extensions for Java VM // Reactive extensions for Java VM
implementation "io.reactivex.rxjava3:rxjava:3.1.6" implementation "io.reactivex.rxjava3:rxjava:3.1.8"
implementation "io.reactivex.rxjava3:rxandroid:3.0.2" implementation "io.reactivex.rxjava3:rxandroid:3.0.2"
// RxJava binding APIs for Android UI widgets // RxJava binding APIs for Android UI widgets
implementation "com.jakewharton.rxbinding4:rxbinding:4.0.0" implementation "com.jakewharton.rxbinding4:rxbinding:4.0.0"
// Date and time formatting // Date and time formatting
implementation "org.ocpsoft.prettytime:prettytime:5.0.6.Final" implementation "org.ocpsoft.prettytime:prettytime:5.0.7.Final"
/** Debugging **/ /** Debugging **/
// Memory leak detection // Memory leak detection
@ -300,13 +295,12 @@ dependencies {
/** Testing **/ /** Testing **/
testImplementation 'junit:junit:4.13.2' testImplementation 'junit:junit:4.13.2'
testImplementation "org.mockito:mockito-core:${mockitoVersion}" testImplementation 'org.mockito:mockito-core:5.6.0'
testImplementation "org.mockito:mockito-inline:${mockitoVersion}"
androidTestImplementation "androidx.test.ext:junit:1.1.5" androidTestImplementation "androidx.test.ext:junit:1.1.5"
androidTestImplementation "androidx.test:runner:1.5.2" androidTestImplementation "androidx.test:runner:1.5.2"
androidTestImplementation "androidx.room:room-testing:${androidxRoomVersion}" androidTestImplementation "androidx.room:room-testing:${androidxRoomVersion}"
androidTestImplementation "org.assertj:assertj-core:3.23.1" androidTestImplementation "org.assertj:assertj-core:3.24.2"
} }
static String getGitWorkingBranch() { static String getGitWorkingBranch() {

View file

@ -0,0 +1,124 @@
package org.schabi.newpipe.database
import android.content.Context
import androidx.room.Room
import androidx.test.core.app.ApplicationProvider
import io.reactivex.rxjava3.core.Single
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import org.schabi.newpipe.database.feed.dao.FeedDAO
import org.schabi.newpipe.database.feed.model.FeedEntity
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.stream.StreamWithState
import org.schabi.newpipe.database.stream.dao.StreamDAO
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.subscription.SubscriptionDAO
import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.extractor.ServiceList
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.stream.StreamType
import java.io.IOException
import java.time.OffsetDateTime
import kotlin.streams.toList
class FeedDAOTest {
private lateinit var db: AppDatabase
private lateinit var feedDAO: FeedDAO
private lateinit var streamDAO: StreamDAO
private lateinit var subscriptionDAO: SubscriptionDAO
private val serviceId = ServiceList.YouTube.serviceId
private val stream1 = StreamEntity(1, serviceId, "https://youtube.com/watch?v=1", "stream 1", StreamType.VIDEO_STREAM, 1000, "channel-1", "https://youtube.com/channel/1", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-01-01", OffsetDateTime.parse("2023-01-01T00:00:00Z"))
private val stream2 = StreamEntity(2, serviceId, "https://youtube.com/watch?v=2", "stream 2", StreamType.VIDEO_STREAM, 1000, "channel-1", "https://youtube.com/channel/1", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-01-02", OffsetDateTime.parse("2023-01-02T00:00:00Z"))
private val stream3 = StreamEntity(3, serviceId, "https://youtube.com/watch?v=3", "stream 3", StreamType.LIVE_STREAM, 1000, "channel-1", "https://youtube.com/channel/1", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-01-03", OffsetDateTime.parse("2023-01-03T00:00:00Z"))
private val stream4 = StreamEntity(4, serviceId, "https://youtube.com/watch?v=4", "stream 4", StreamType.VIDEO_STREAM, 1000, "channel-2", "https://youtube.com/channel/2", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-08-10", OffsetDateTime.parse("2023-08-10T00:00:00Z"))
private val stream5 = StreamEntity(5, serviceId, "https://youtube.com/watch?v=5", "stream 5", StreamType.VIDEO_STREAM, 1000, "channel-2", "https://youtube.com/channel/2", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-08-20", OffsetDateTime.parse("2023-08-20T00:00:00Z"))
private val stream6 = StreamEntity(6, serviceId, "https://youtube.com/watch?v=6", "stream 6", StreamType.VIDEO_STREAM, 1000, "channel-3", "https://youtube.com/channel/3", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-09-01", OffsetDateTime.parse("2023-09-01T00:00:00Z"))
private val stream7 = StreamEntity(7, serviceId, "https://youtube.com/watch?v=7", "stream 7", StreamType.VIDEO_STREAM, 1000, "channel-4", "https://youtube.com/channel/4", "https://i.ytimg.com/vi/1/hqdefault.jpg", 100, "2023-08-10", OffsetDateTime.parse("2023-08-10T00:00:00Z"))
private val allStreams = listOf(
stream1, stream2, stream3, stream4, stream5, stream6, stream7
)
@Before
fun createDb() {
val context = ApplicationProvider.getApplicationContext<Context>()
db = Room.inMemoryDatabaseBuilder(
context, AppDatabase::class.java
).build()
feedDAO = db.feedDAO()
streamDAO = db.streamDAO()
subscriptionDAO = db.subscriptionDAO()
}
@After
@Throws(IOException::class)
fun closeDb() {
db.close()
}
@Test
fun testUnlinkStreamsOlderThan_KeepOne() {
setupUnlinkDelete("2023-08-15T00:00:00Z")
val streams = feedDAO.getStreams(
FeedGroupEntity.GROUP_ALL_ID, includePlayed = true, includePartiallyPlayed = true, null
)
.blockingGet()
val allowedStreams = listOf(stream3, stream5, stream6, stream7)
assertEqual(streams, allowedStreams)
}
@Test
fun testUnlinkStreamsOlderThan_KeepMultiple() {
setupUnlinkDelete("2023-08-01T00:00:00Z")
val streams = feedDAO.getStreams(
FeedGroupEntity.GROUP_ALL_ID, includePlayed = true, includePartiallyPlayed = true, null
)
.blockingGet()
val allowedStreams = listOf(stream3, stream4, stream5, stream6, stream7)
assertEqual(streams, allowedStreams)
}
private fun assertEqual(streams: List<StreamWithState>?, allowedStreams: List<StreamEntity>) {
assertNotNull(streams)
assertEquals(allowedStreams, streams!!.stream().map { it.stream }.toList().sortedBy { it.uid })
}
private fun setupUnlinkDelete(time: String) {
clearAndFillTables()
Single.fromCallable {
feedDAO.unlinkStreamsOlderThan(OffsetDateTime.parse(time))
}.blockingSubscribe()
Single.fromCallable {
streamDAO.deleteOrphans()
}.blockingSubscribe()
}
private fun clearAndFillTables() {
db.clearAllTables()
streamDAO.insertAll(allStreams)
subscriptionDAO.insertAll(
listOf(
SubscriptionEntity.from(ChannelInfo(serviceId, "1", "https://youtube.com/channel/1", "https://youtube.com/channel/1", "channel-1")),
SubscriptionEntity.from(ChannelInfo(serviceId, "2", "https://youtube.com/channel/2", "https://youtube.com/channel/2", "channel-2")),
SubscriptionEntity.from(ChannelInfo(serviceId, "3", "https://youtube.com/channel/3", "https://youtube.com/channel/3", "channel-3")),
SubscriptionEntity.from(ChannelInfo(serviceId, "4", "https://youtube.com/channel/4", "https://youtube.com/channel/4", "channel-4")),
)
)
feedDAO.insertAll(
listOf(
FeedEntity(1, 1),
FeedEntity(2, 1),
FeedEntity(3, 1),
FeedEntity(4, 2),
FeedEntity(5, 2),
FeedEntity(6, 3),
FeedEntity(7, 4),
)
)
}
}

View file

@ -10,19 +10,13 @@ import org.junit.Rule;
import org.junit.Test; import org.junit.Test;
import org.schabi.newpipe.database.AppDatabase; import org.schabi.newpipe.database.AppDatabase;
import org.schabi.newpipe.database.feed.model.FeedGroupEntity; import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.localization.DateWrapper;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.testUtil.TestDatabase; import org.schabi.newpipe.testUtil.TestDatabase;
import org.schabi.newpipe.testUtil.TrampolineSchedulerRule; import org.schabi.newpipe.testUtil.TrampolineSchedulerRule;
import java.io.IOException; import java.io.IOException;
import java.time.OffsetDateTime;
import java.util.Comparator;
import java.util.List; import java.util.List;
public class SubscriptionManagerTest { public class SubscriptionManagerTest {
@ -58,7 +52,7 @@ public class SubscriptionManagerTest {
final ChannelInfo info = ChannelInfo.getInfo("https://www.youtube.com/c/3blue1brown"); final ChannelInfo info = ChannelInfo.getInfo("https://www.youtube.com/c/3blue1brown");
final SubscriptionEntity subscription = SubscriptionEntity.from(info); final SubscriptionEntity subscription = SubscriptionEntity.from(info);
manager.insertSubscription(subscription, info); manager.insertSubscription(subscription);
final SubscriptionEntity readSubscription = getAssertOneSubscriptionEntity(); final SubscriptionEntity readSubscription = getAssertOneSubscriptionEntity();
// the uid has changed, since the uid is chosen upon inserting, but the rest should match // the uid has changed, since the uid is chosen upon inserting, but the rest should match
@ -76,7 +70,7 @@ public class SubscriptionManagerTest {
final SubscriptionEntity subscription = SubscriptionEntity.from(info); final SubscriptionEntity subscription = SubscriptionEntity.from(info);
subscription.setNotificationMode(0); subscription.setNotificationMode(0);
manager.insertSubscription(subscription, info); manager.insertSubscription(subscription);
manager.updateNotificationMode(subscription.getServiceId(), subscription.getUrl(), 1) manager.updateNotificationMode(subscription.getServiceId(), subscription.getUrl(), 1)
.blockingAwait(); .blockingAwait();
final SubscriptionEntity anotherSubscription = getAssertOneSubscriptionEntity(); final SubscriptionEntity anotherSubscription = getAssertOneSubscriptionEntity();
@ -85,35 +79,4 @@ public class SubscriptionManagerTest {
assertEquals(subscription.getUrl(), anotherSubscription.getUrl()); assertEquals(subscription.getUrl(), anotherSubscription.getUrl());
assertEquals(1, anotherSubscription.getNotificationMode()); assertEquals(1, anotherSubscription.getNotificationMode());
} }
@Test
public void testRememberRecentStreams() throws ExtractionException, IOException {
final ChannelInfo info = ChannelInfo.getInfo("https://www.youtube.com/c/Polyphia");
final List<StreamInfoItem> relatedItems = List.of(
new StreamInfoItem(0, "a", "b", StreamType.VIDEO_STREAM),
new StreamInfoItem(1, "c", "d", StreamType.AUDIO_STREAM),
new StreamInfoItem(2, "e", "f", StreamType.AUDIO_LIVE_STREAM),
new StreamInfoItem(3, "g", "h", StreamType.LIVE_STREAM));
relatedItems.forEach(item -> {
// these two fields must be non-null for the insert to succeed
item.setUploaderUrl(info.getUrl());
item.setUploaderName(info.getName());
// the upload date must not be too much in the past for the item to actually be inserted
item.setUploadDate(new DateWrapper(OffsetDateTime.now()));
});
info.setRelatedItems(relatedItems);
final SubscriptionEntity subscription = SubscriptionEntity.from(info);
manager.insertSubscription(subscription, info);
final List<StreamEntity> streams = database.streamDAO().getAll().blockingFirst();
assertEquals(4, streams.size());
streams.sort(Comparator.comparing(StreamEntity::getServiceId));
for (int i = 0; i < 4; i++) {
assertEquals(relatedItems.get(0).getServiceId(), streams.get(0).getServiceId());
assertEquals(relatedItems.get(0).getUrl(), streams.get(0).getUrl());
assertEquals(relatedItems.get(0).getName(), streams.get(0).getTitle());
assertEquals(relatedItems.get(0).getStreamType(), streams.get(0).getStreamType());
}
}
} }

View file

@ -12,15 +12,21 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest import androidx.test.filters.MediumTest
import androidx.test.internal.runner.junit4.statement.UiThreadStatement import androidx.test.internal.runner.junit4.statement.UiThreadStatement
import org.junit.Assert import org.junit.Assert
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.MediaFormat import org.schabi.newpipe.extractor.MediaFormat
import org.schabi.newpipe.extractor.downloader.Response
import org.schabi.newpipe.extractor.stream.AudioStream import org.schabi.newpipe.extractor.stream.AudioStream
import org.schabi.newpipe.extractor.stream.Stream import org.schabi.newpipe.extractor.stream.Stream
import org.schabi.newpipe.extractor.stream.SubtitlesStream import org.schabi.newpipe.extractor.stream.SubtitlesStream
import org.schabi.newpipe.extractor.stream.VideoStream import org.schabi.newpipe.extractor.stream.VideoStream
import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper
@MediumTest @MediumTest
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
@ -84,7 +90,7 @@ class StreamItemAdapterTest {
@Test @Test
fun subtitleStreams_noIcon() { fun subtitleStreams_noIcon() {
val adapter = StreamItemAdapter<SubtitlesStream, Stream>( val adapter = StreamItemAdapter<SubtitlesStream, Stream>(
StreamItemAdapter.StreamSizeWrapper( StreamItemAdapter.StreamInfoWrapper(
(0 until 5).map { (0 until 5).map {
SubtitlesStream.Builder() SubtitlesStream.Builder()
.setContent("https://example.com", true) .setContent("https://example.com", true)
@ -105,7 +111,7 @@ class StreamItemAdapterTest {
@Test @Test
fun audioStreams_noIcon() { fun audioStreams_noIcon() {
val adapter = StreamItemAdapter<AudioStream, Stream>( val adapter = StreamItemAdapter<AudioStream, Stream>(
StreamItemAdapter.StreamSizeWrapper( StreamItemAdapter.StreamInfoWrapper(
(0 until 5).map { (0 until 5).map {
AudioStream.Builder() AudioStream.Builder()
.setId(Stream.ID_UNKNOWN) .setId(Stream.ID_UNKNOWN)
@ -123,12 +129,109 @@ class StreamItemAdapterTest {
} }
} }
@Test
fun retrieveMediaFormatFromFileTypeHeaders() {
val streams = getIncompleteAudioStreams(5)
val wrapper = StreamInfoWrapper(streams, context)
val retrieveMediaFormat = { stream: AudioStream, response: Response ->
StreamInfoWrapper.retrieveMediaFormatFromFileTypeHeaders(stream, wrapper, response)
}
val helper = AssertionHelper(streams, wrapper, retrieveMediaFormat)
helper.assertInvalidResponse(getResponse(mapOf(Pair("content-length", "mp3"))), 0)
helper.assertInvalidResponse(getResponse(mapOf(Pair("file-type", "mp0"))), 1)
helper.assertValidResponse(getResponse(mapOf(Pair("x-amz-meta-file-type", "aiff"))), 2, MediaFormat.AIFF)
helper.assertValidResponse(getResponse(mapOf(Pair("file-type", "mp3"))), 3, MediaFormat.MP3)
}
@Test
fun retrieveMediaFormatFromContentDispositionHeader() {
val streams = getIncompleteAudioStreams(11)
val wrapper = StreamInfoWrapper(streams, context)
val retrieveMediaFormat = { stream: AudioStream, response: Response ->
StreamInfoWrapper.retrieveMediaFormatFromContentDispositionHeader(stream, wrapper, response)
}
val helper = AssertionHelper(streams, wrapper, retrieveMediaFormat)
helper.assertInvalidResponse(getResponse(mapOf(Pair("content-length", "mp3"))), 0)
helper.assertInvalidResponse(
getResponse(mapOf(Pair("Content-Disposition", "filename=\"train.png\""))), 1
)
helper.assertInvalidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"data.csv\""))), 2
)
helper.assertInvalidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; filename=\"data.csv\""))), 3
)
helper.assertInvalidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"fieldName\"; filename*=\"filename.jpg\""))), 4
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Disposition", "filename=\"train.ogg\""))),
5, MediaFormat.OGG
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Disposition", "some-form-data; filename=\"audio.flac\""))),
6, MediaFormat.FLAC
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.aiff\"; filename=\"audio.aiff\""))),
7, MediaFormat.AIFF
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"alien?\"; filename*=UTF-8''%CE%B1%CE%BB%CE%B9%CF%B5%CE%BD.m4a"))),
8, MediaFormat.M4A
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.mp3\"; filename=\"audio.opus\"; filename*=UTF-8''alien.opus"))),
9, MediaFormat.OPUS
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Disposition", "form-data; name=\"audio.mp3\"; filename=\"audio.opus\"; filename*=\"UTF-8''alien.opus\""))),
10, MediaFormat.OPUS
)
}
@Test
fun retrieveMediaFormatFromContentTypeHeader() {
val streams = getIncompleteAudioStreams(12)
val wrapper = StreamInfoWrapper(streams, context)
val retrieveMediaFormat = { stream: AudioStream, response: Response ->
StreamInfoWrapper.retrieveMediaFormatFromContentTypeHeader(stream, wrapper, response)
}
val helper = AssertionHelper(streams, wrapper, retrieveMediaFormat)
helper.assertInvalidResponse(getResponse(mapOf(Pair("content-length", "984501"))), 0)
helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "audio/xyz"))), 1)
helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "mp3"))), 2)
helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "mp3"))), 3)
helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "audio/mpeg"))), 4)
helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "audio/aif"))), 5)
helper.assertInvalidResponse(getResponse(mapOf(Pair("Content-Type", "whatever"))), 6)
helper.assertInvalidResponse(getResponse(mapOf()), 7)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Type", "audio/flac"))), 8, MediaFormat.FLAC
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Type", "audio/wav"))), 9, MediaFormat.WAV
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Type", "audio/opus"))), 10, MediaFormat.OPUS
)
helper.assertValidResponse(
getResponse(mapOf(Pair("Content-Type", "audio/aiff"))), 11, MediaFormat.AIFF
)
}
/** /**
* @return a list of video streams, in which their video only property mirrors the provided * @return a list of video streams, in which their video only property mirrors the provided
* [videoOnly] vararg. * [videoOnly] vararg.
*/ */
private fun getVideoStreams(vararg videoOnly: Boolean) = private fun getVideoStreams(vararg videoOnly: Boolean) =
StreamItemAdapter.StreamSizeWrapper( StreamItemAdapter.StreamInfoWrapper(
videoOnly.map { videoOnly.map {
VideoStream.Builder() VideoStream.Builder()
.setId(Stream.ID_UNKNOWN) .setId(Stream.ID_UNKNOWN)
@ -161,6 +264,19 @@ class StreamItemAdapterTest {
} }
) )
private fun getIncompleteAudioStreams(size: Int): List<AudioStream> {
val list = ArrayList<AudioStream>(size)
for (i in 1..size) {
list.add(
AudioStream.Builder()
.setId(Stream.ID_UNKNOWN)
.setContent("https://example.com/$i", true)
.build()
)
}
return list
}
/** /**
* Checks whether the item at [position] in the [spinner] has the correct icon visibility when * Checks whether the item at [position] in the [spinner] has the correct icon visibility when
* it is shown in normal mode (selected) and in dropdown mode (user is choosing one of a list). * it is shown in normal mode (selected) and in dropdown mode (user is choosing one of a list).
@ -196,11 +312,56 @@ class StreamItemAdapterTest {
streams.forEachIndexed { index, stream -> streams.forEachIndexed { index, stream ->
val secondaryStreamHelper: SecondaryStreamHelper<T>? = stream?.let { val secondaryStreamHelper: SecondaryStreamHelper<T>? = stream?.let {
SecondaryStreamHelper( SecondaryStreamHelper(
StreamItemAdapter.StreamSizeWrapper(streams, context), StreamItemAdapter.StreamInfoWrapper(streams, context),
it it
) )
} }
put(index, secondaryStreamHelper) put(index, secondaryStreamHelper)
} }
} }
private fun getResponse(headers: Map<String, String>): Response {
val listHeaders = HashMap<String, List<String>>()
headers.forEach { entry ->
listHeaders[entry.key] = listOf(entry.value)
}
return Response(200, null, listHeaders, "", "")
}
/**
* Helper class for assertion related to extractions of [MediaFormat]s.
*/
class AssertionHelper<T : Stream>(
private val streams: List<T>,
private val wrapper: StreamInfoWrapper<T>,
private val retrieveMediaFormat: (stream: T, response: Response) -> Boolean
) {
/**
* Assert that an invalid response does not result in wrongly extracted [MediaFormat].
*/
fun assertInvalidResponse(
response: Response,
index: Int
) {
assertFalse(
"invalid header returns valid value", retrieveMediaFormat(streams[index], response)
)
assertNull("Media format extracted although stated otherwise", wrapper.getFormat(index))
}
/**
* Assert that a valid response results in correctly extracted and handled [MediaFormat].
*/
fun assertValidResponse(
response: Response,
index: Int,
format: MediaFormat
) {
assertTrue(
"header was not recognized", retrieveMediaFormat(streams[index], response)
)
assertEquals("Wrong media format extracted", format, wrapper.getFormat(index))
}
}
} }

View file

@ -25,6 +25,7 @@ import android.view.ViewGroup;
import androidx.annotation.IntDef; import androidx.annotation.IntDef;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.os.BundleCompat;
import androidx.lifecycle.Lifecycle; import androidx.lifecycle.Lifecycle;
import androidx.viewpager.widget.PagerAdapter; import androidx.viewpager.widget.PagerAdapter;
@ -284,7 +285,7 @@ public abstract class FragmentStatePagerAdapterMenuWorkaround extends PagerAdapt
Bundle state = null; Bundle state = null;
if (!mSavedState.isEmpty()) { if (!mSavedState.isEmpty()) {
state = new Bundle(); state = new Bundle();
state.putParcelableArray("states", mSavedState.toArray(new Fragment.SavedState[0])); state.putParcelableArrayList("states", mSavedState);
} }
for (int i = 0; i < mFragments.size(); i++) { for (int i = 0; i < mFragments.size(); i++) {
final Fragment f = mFragments.get(i); final Fragment f = mFragments.get(i);
@ -311,13 +312,12 @@ public abstract class FragmentStatePagerAdapterMenuWorkaround extends PagerAdapt
if (state != null) { if (state != null) {
final Bundle bundle = (Bundle) state; final Bundle bundle = (Bundle) state;
bundle.setClassLoader(loader); bundle.setClassLoader(loader);
final Parcelable[] fss = bundle.getParcelableArray("states"); final var states = BundleCompat.getParcelableArrayList(bundle, "states",
Fragment.SavedState.class);
mSavedState.clear(); mSavedState.clear();
mFragments.clear(); mFragments.clear();
if (fss != null) { if (states != null) {
for (final Parcelable parcelable : fss) { mSavedState.addAll(states);
mSavedState.add((Fragment.SavedState) parcelable);
}
} }
final Iterable<String> keys = bundle.keySet(); final Iterable<String> keys = bundle.keySet();
for (final String key : keys) { for (final String key : keys) {

View file

@ -20,9 +20,11 @@ import org.schabi.newpipe.extractor.downloader.Downloader;
import org.schabi.newpipe.ktx.ExceptionUtils; import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.settings.NewPipeSettings; import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.util.StateSaver; import org.schabi.newpipe.util.StateSaver;
import org.schabi.newpipe.util.image.PreferredImageQuality;
import java.io.IOException; import java.io.IOException;
import java.io.InterruptedIOException; import java.io.InterruptedIOException;
@ -99,8 +101,9 @@ public class App extends Application {
// Initialize image loader // Initialize image loader
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
PicassoHelper.init(this); PicassoHelper.init(this);
PicassoHelper.setShouldLoadImages( ImageStrategy.setPreferredImageQuality(PreferredImageQuality.fromPreferenceKey(this,
prefs.getBoolean(getString(R.string.download_thumbnail_key), true)); prefs.getString(getString(R.string.image_quality_key),
getString(R.string.image_quality_default))));
PicassoHelper.setIndicatorsEnabled(MainActivity.DEBUG PicassoHelper.setIndicatorsEnabled(MainActivity.DEBUG
&& prefs.getBoolean(getString(R.string.show_image_indicators_key), false)); && prefs.getBoolean(getString(R.string.show_image_indicators_key), false));

View file

@ -80,9 +80,29 @@ public abstract class BaseFragment extends Fragment {
// Init // Init
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
/**
* This method is called in {@link #onViewCreated(View, Bundle)} to initialize the views.
*
* <p>
* {@link #initListeners()} is called after this method to initialize the corresponding
* listeners.
* </p>
* @param rootView The inflated view for this fragment
* (provided by {@link #onViewCreated(View, Bundle)})
* @param savedInstanceState The saved state of this fragment
* (provided by {@link #onViewCreated(View, Bundle)})
*/
protected void initViews(final View rootView, final Bundle savedInstanceState) { protected void initViews(final View rootView, final Bundle savedInstanceState) {
} }
/**
* Initialize the listeners for this fragment.
*
* <p>
* This method is called after {@link #initViews(View, Bundle)}
* in {@link #onViewCreated(View, Bundle)}.
* </p>
*/
protected void initListeners() { protected void initListeners() {
} }
@ -100,9 +120,20 @@ public abstract class BaseFragment extends Fragment {
} }
} }
/**
* Finds the root fragment by looping through all of the parent fragments. The root fragment
* is supposed to be {@link org.schabi.newpipe.fragments.MainFragment}, and is the fragment that
* handles keeping the backstack of opened fragments in NewPipe, and also the player bottom
* sheet. This function therefore returns the fragment manager of said fragment.
*
* @return the fragment manager of the root fragment, i.e.
* {@link org.schabi.newpipe.fragments.MainFragment}
*/
protected FragmentManager getFM() { protected FragmentManager getFM() {
return getParentFragment() == null Fragment current = this;
? getFragmentManager() while (current.getParentFragment() != null) {
: getParentFragment().getFragmentManager(); current = current.getParentFragment();
}
return current.getFragmentManager();
} }
} }

View file

@ -44,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 +52,7 @@ import androidx.core.app.ActivityCompat;
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 +66,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;
@ -219,14 +223,14 @@ public class MainActivity extends AppCompatActivity {
final int currentServiceId = ServiceHelper.getSelectedServiceId(this); final int currentServiceId = ServiceHelper.getSelectedServiceId(this);
final StreamingService service = NewPipe.getService(currentServiceId); final StreamingService service = NewPipe.getService(currentServiceId);
int kioskId = 0; int kioskMenuItemId = 0;
for (final String ks : service.getKioskList().getAvailableKiosks()) { for (final String ks : service.getKioskList().getAvailableKiosks()) {
drawerLayoutBinding.navigation.getMenu() drawerLayoutBinding.navigation.getMenu()
.add(R.id.menu_tabs_group, kioskId, 0, KioskTranslator .add(R.id.menu_tabs_group, kioskMenuItemId, 0, KioskTranslator
.getTranslatedKioskName(ks, this)) .getTranslatedKioskName(ks, this))
.setIcon(KioskTranslator.getKioskIcon(ks)); .setIcon(KioskTranslator.getKioskIcon(ks));
kioskId++; kioskMenuItemId++;
} }
drawerLayoutBinding.navigation.getMenu() drawerLayoutBinding.navigation.getMenu()
@ -306,20 +310,16 @@ public class MainActivity extends AppCompatActivity {
NavigationHelper.openStatisticFragment(getSupportFragmentManager()); NavigationHelper.openStatisticFragment(getSupportFragmentManager());
break; break;
default: default:
final int currentServiceId = ServiceHelper.getSelectedServiceId(this); final StreamingService currentService = ServiceHelper.getSelectedService(this);
final StreamingService service = NewPipe.getService(currentServiceId); int kioskMenuItemId = 0;
String serviceName = ""; for (final String kioskId : currentService.getKioskList().getAvailableKiosks()) {
if (kioskMenuItemId == item.getItemId()) {
int kioskId = 0; NavigationHelper.openKioskFragment(getSupportFragmentManager(),
for (final String ks : service.getKioskList().getAvailableKiosks()) { currentService.getServiceId(), kioskId);
if (kioskId == item.getItemId()) { break;
serviceName = ks;
} }
kioskId++; kioskMenuItemId++;
} }
NavigationHelper.openKioskFragment(getSupportFragmentManager(), currentServiceId,
serviceName);
break; break;
} }
} }
@ -550,14 +550,21 @@ public class MainActivity extends AppCompatActivity {
// 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
if (bottomSheetHiddenOrCollapsed()) { if (bottomSheetHiddenOrCollapsed()) {
final Fragment fragment = getSupportFragmentManager() final FragmentManager fm = getSupportFragmentManager();
.findFragmentById(R.id.fragment_holder); 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) { if (fragment instanceof BackPressable) {
if (((BackPressable) fragment).onBackPressed()) { if (((BackPressable) fragment).onBackPressed()) {
return; 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 { } else {
@ -633,10 +640,17 @@ public class MainActivity extends AppCompatActivity {
* </pre> * </pre>
*/ */
private void onHomeButtonPressed() { private void onHomeButtonPressed() {
// If search fragment wasn't found in the backstack... final FragmentManager fm = getSupportFragmentManager();
if (!NavigationHelper.tryGotoSearchFragment(getSupportFragmentManager())) { final Fragment fragment = fm.findFragmentById(R.id.fragment_holder);
// ...go to the main fragment
NavigationHelper.gotoMainFragment(getSupportFragmentManager()); 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
NavigationHelper.gotoMainFragment(fm);
} }
} }
@ -832,6 +846,68 @@ public class MainActivity extends AppCompatActivity {
} }
} }
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);

View file

@ -75,7 +75,7 @@ public final class QueueItemMenuUtil {
return true; return true;
case R.id.menu_item_share: case R.id.menu_item_share:
shareText(context, item.getTitle(), item.getUrl(), shareText(context, item.getTitle(), item.getUrl(),
item.getThumbnailUrl()); item.getThumbnails());
return true; return true;
case R.id.menu_item_download: case R.id.menu_item_download:
fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(), fetchStreamInfoAndSaveToDatabase(context, item.getServiceId(), item.getUrl(),

View file

@ -45,6 +45,7 @@ import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.databinding.ListRadioIconItemBinding; import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding; import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding;
import org.schabi.newpipe.download.DownloadDialog; import org.schabi.newpipe.download.DownloadDialog;
import org.schabi.newpipe.download.LoadingDialog;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.ReCaptchaActivity; import org.schabi.newpipe.error.ReCaptchaActivity;
@ -64,6 +65,7 @@ import org.schabi.newpipe.extractor.exceptions.PrivateContentException;
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException; import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException; import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException;
import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException; import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.ktx.ExceptionUtils; import org.schabi.newpipe.ktx.ExceptionUtils;
@ -71,10 +73,11 @@ import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.helper.PlayerHelper; import org.schabi.newpipe.player.helper.PlayerHelper;
import org.schabi.newpipe.player.helper.PlayerHolder; import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue; 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.player.playqueue.PlaylistPlayQueue; 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.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;
@ -789,10 +792,10 @@ public class RouterActivity extends AppCompatActivity {
} }
} }
}, () -> { }, () ->
// this branch is executed if there is no activity context // this branch is executed if there is no activity context
inFlight(false); inFlight(false)
}); );
} }
<T> Single<T> pleaseWait(final Single<T> single) { <T> Single<T> pleaseWait(final Single<T> single) {
@ -812,19 +815,24 @@ public class RouterActivity extends AppCompatActivity {
@SuppressLint("CheckResult") @SuppressLint("CheckResult")
private void openDownloadDialog(final int currentServiceId, final String currentUrl) { private void openDownloadDialog(final int currentServiceId, final String currentUrl) {
inFlight(true); inFlight(true);
final LoadingDialog loadingDialog = new LoadingDialog(R.string.loading_metadata_title);
loadingDialog.show(getParentFragmentManager(), "loadingDialog");
disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true) disposables.add(ExtractorHelper.getStreamInfo(currentServiceId, currentUrl, true)
.subscribeOn(Schedulers.io()) .subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.compose(this::pleaseWait) .compose(this::pleaseWait)
.subscribe(result -> .subscribe(result ->
runOnVisible(ctx -> { runOnVisible(ctx -> {
loadingDialog.dismiss();
final FragmentManager fm = ctx.getSupportFragmentManager(); final FragmentManager fm = ctx.getSupportFragmentManager();
final DownloadDialog downloadDialog = new DownloadDialog(ctx, result); final DownloadDialog downloadDialog = new DownloadDialog(ctx, result);
// dismiss listener to be handled by FragmentManager // dismiss listener to be handled by FragmentManager
downloadDialog.show(fm, "downloadDialog"); downloadDialog.show(fm, "downloadDialog");
} }
), throwable -> runOnVisible(ctx -> ), throwable -> runOnVisible(ctx -> {
((RouterActivity) ctx).showUnsupportedUrlDialog(currentUrl)))); loadingDialog.dismiss();
((RouterActivity) ctx).showUnsupportedUrlDialog(currentUrl);
})));
} }
private void openAddToPlaylistDialog(final int currentServiceId, final String currentUrl) { private void openAddToPlaylistDialog(final int currentServiceId, final String currentUrl) {
@ -1016,7 +1024,16 @@ public class RouterActivity extends AppCompatActivity {
} }
playQueue = new SinglePlayQueue((StreamInfo) info); playQueue = new SinglePlayQueue((StreamInfo) info);
} else if (info instanceof ChannelInfo) { } else if (info instanceof ChannelInfo) {
playQueue = new ChannelPlayQueue((ChannelInfo) info); final Optional<ListLinkHandler> playableTab = ((ChannelInfo) info).getTabs()
.stream()
.filter(ChannelTabHelper::isStreamsTab)
.findFirst();
if (playableTab.isPresent()) {
playQueue = new ChannelTabPlayQueue(info.getServiceId(), playableTab.get());
} else {
return; // there is no playable tab
}
} else if (info instanceof PlaylistInfo) { } else if (info instanceof PlaylistInfo) {
playQueue = new PlaylistPlayQueue((PlaylistInfo) info); playQueue = new PlaylistPlayQueue((PlaylistInfo) info);
} else { } else {

View file

@ -116,7 +116,7 @@ class AboutActivity : AppCompatActivity() {
/** /**
* List of all software components. * List of all software components.
*/ */
private val SOFTWARE_COMPONENTS = arrayOf( private val SOFTWARE_COMPONENTS = arrayListOf(
SoftwareComponent( SoftwareComponent(
"ACRA", "2013", "Kevin Gaudin", "ACRA", "2013", "Kevin Gaudin",
"https://github.com/ACRA/acra", StandardLicenses.APACHE2 "https://github.com/ACRA/acra", StandardLicenses.APACHE2

View file

@ -1,30 +1,40 @@
package org.schabi.newpipe.about package org.schabi.newpipe.about
import android.os.Bundle import android.os.Bundle
import android.util.Base64
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.webkit.WebView
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment 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.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.R
import org.schabi.newpipe.databinding.FragmentLicensesBinding import org.schabi.newpipe.databinding.FragmentLicensesBinding
import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding
import org.schabi.newpipe.ktx.parcelableArrayList
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.external_communication.ShareUtils
/** /**
* Fragment containing the software licenses. * Fragment containing the software licenses.
*/ */
class LicenseFragment : Fragment() { class LicenseFragment : Fragment() {
private lateinit var softwareComponents: Array<SoftwareComponent> private lateinit var softwareComponents: List<SoftwareComponent>
private var activeLicense: License? = null private var activeSoftwareComponent: SoftwareComponent? = null
private val compositeDisposable = CompositeDisposable() private val compositeDisposable = CompositeDisposable()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
softwareComponents = arguments?.getParcelableArray(ARG_COMPONENTS) as Array<SoftwareComponent> softwareComponents = arguments?.parcelableArrayList<SoftwareComponent>(ARG_COMPONENTS)!!
activeLicense = savedInstanceState?.getSerializable(LICENSE_KEY) as? License .sortedBy { it.name } // Sort components by name
// Sort components by name activeSoftwareComponent = savedInstanceState?.getSerializable(SOFTWARE_COMPONENT_KEY) as? SoftwareComponent
softwareComponents.sortBy { it.name }
} }
override fun onDestroy() { override fun onDestroy() {
@ -39,9 +49,8 @@ class LicenseFragment : Fragment() {
): View { ): View {
val binding = FragmentLicensesBinding.inflate(inflater, container, false) val binding = FragmentLicensesBinding.inflate(inflater, container, false)
binding.licensesAppReadLicense.setOnClickListener { binding.licensesAppReadLicense.setOnClickListener {
activeLicense = StandardLicenses.GPL3
compositeDisposable.add( compositeDisposable.add(
showLicense(activity, StandardLicenses.GPL3) showLicense(NEWPIPE_SOFTWARE_COMPONENT)
) )
} }
for (component in softwareComponents) { for (component in softwareComponents) {
@ -57,27 +66,72 @@ class LicenseFragment : Fragment() {
val root: View = componentBinding.root val root: View = componentBinding.root
root.tag = component root.tag = component
root.setOnClickListener { root.setOnClickListener {
activeLicense = component.license
compositeDisposable.add( compositeDisposable.add(
showLicense(activity, component) showLicense(component)
) )
} }
binding.licensesSoftwareComponents.addView(root) binding.licensesSoftwareComponents.addView(root)
registerForContextMenu(root) registerForContextMenu(root)
} }
activeLicense?.let { compositeDisposable.add(showLicense(activity, it)) } activeSoftwareComponent?.let { compositeDisposable.add(showLicense(it)) }
return binding.root return binding.root
} }
override fun onSaveInstanceState(savedInstanceState: Bundle) { override fun onSaveInstanceState(savedInstanceState: Bundle) {
super.onSaveInstanceState(savedInstanceState) super.onSaveInstanceState(savedInstanceState)
activeLicense?.let { savedInstanceState.putSerializable(LICENSE_KEY, it) } 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")
Localization.assureCorrectAppLanguage(context)
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 { companion object {
private const val ARG_COMPONENTS = "components" private const val ARG_COMPONENTS = "components"
private const val LICENSE_KEY = "ACTIVE_LICENSE" private const val SOFTWARE_COMPONENT_KEY = "ACTIVE_SOFTWARE_COMPONENT"
fun newInstance(softwareComponents: Array<SoftwareComponent>): LicenseFragment { 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() val fragment = LicenseFragment()
fragment.arguments = bundleOf(ARG_COMPONENTS to softwareComponents) fragment.arguments = bundleOf(ARG_COMPONENTS to softwareComponents)
return fragment return fragment

View file

@ -1,17 +1,8 @@
package org.schabi.newpipe.about package org.schabi.newpipe.about
import android.content.Context import android.content.Context
import android.util.Base64
import android.webkit.WebView
import androidx.appcompat.app.AlertDialog
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.R import org.schabi.newpipe.R
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 java.io.IOException import java.io.IOException
/** /**
@ -20,7 +11,7 @@ import java.io.IOException
* @return String which contains a HTML formatted license page * @return String which contains a HTML formatted license page
* styled according to the context's theme * styled according to the context's theme
*/ */
private fun getFormattedLicense(context: Context, license: License): String { fun getFormattedLicense(context: Context, license: License): String {
try { try {
return context.assets.open(license.filename).bufferedReader().use { it.readText() } return context.assets.open(license.filename).bufferedReader().use { it.readText() }
// split the HTML file and insert the stylesheet into the HEAD of the file // split the HTML file and insert the stylesheet into the HEAD of the file
@ -34,7 +25,7 @@ private fun getFormattedLicense(context: Context, license: License): String {
* @param context the Android context * @param context the Android context
* @return String which is a CSS stylesheet according to the context's theme * @return String which is a CSS stylesheet according to the context's theme
*/ */
private fun getLicenseStylesheet(context: Context): String { fun getLicenseStylesheet(context: Context): String {
val isLightTheme = ThemeHelper.isLightThemeSelected(context) val isLightTheme = ThemeHelper.isLightThemeSelected(context)
val licenseBackgroundColor = getHexRGBColor( val licenseBackgroundColor = getHexRGBColor(
context, if (isLightTheme) R.color.light_license_background_color else R.color.dark_license_background_color context, if (isLightTheme) R.color.light_license_background_color else R.color.dark_license_background_color
@ -56,48 +47,6 @@ private fun getLicenseStylesheet(context: Context): String {
* @param color the color number from R.color * @param color the color number from R.color
* @return a six characters long String with hexadecimal RGB values * @return a six characters long String with hexadecimal RGB values
*/ */
private fun getHexRGBColor(context: Context, color: Int): String { fun getHexRGBColor(context: Context, color: Int): String {
return context.getString(color).substring(3) return context.getString(color).substring(3)
} }
fun showLicense(context: Context?, component: SoftwareComponent): Disposable {
return showLicense(context, component.license) {
setPositiveButton(R.string.dismiss) { dialog, _ ->
dialog.dismiss()
}
setNeutralButton(R.string.open_website_license) { _, _ ->
ShareUtils.openUrlInApp(context!!, component.link)
}
}
}
fun showLicense(context: Context?, license: License) = showLicense(context, license) {
setPositiveButton(R.string.ok) { dialog, _ -> dialog.dismiss() }
}
private fun showLicense(
context: Context?,
license: License,
block: AlertDialog.Builder.() -> AlertDialog.Builder
): Disposable {
return if (context == null) {
Disposable.empty()
} else {
Observable.fromCallable { getFormattedLicense(context, 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")
Localization.assureCorrectAppLanguage(context)
AlertDialog.Builder(context)
.setTitle(license.name)
.setView(webView)
.block()
.show()
}
}
}

View file

@ -2,6 +2,7 @@ package org.schabi.newpipe.about
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.io.Serializable
@Parcelize @Parcelize
class SoftwareComponent class SoftwareComponent
@ -13,4 +14,4 @@ constructor(
val link: String, val link: String,
val license: License, val license: License,
val version: String? = null val version: String? = null
) : Parcelable ) : Parcelable, Serializable

View file

@ -7,7 +7,7 @@ import java.time.Instant
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
object Converters { class Converters {
/** /**
* Convert a long value to a [OffsetDateTime]. * Convert a long value to a [OffsetDateTime].
* *
@ -47,6 +47,6 @@ object Converters {
@TypeConverter @TypeConverter
fun feedGroupIconOf(id: Int): FeedGroupIcon { fun feedGroupIconOf(id: Int): FeedGroupIcon {
return FeedGroupIcon.values().first { it.id == id } return FeedGroupIcon.entries.first { it.id == id }
} }
} }

View file

@ -93,18 +93,30 @@ abstract class FeedDAO {
uploadDateBefore: OffsetDateTime? uploadDateBefore: OffsetDateTime?
): Maybe<List<StreamWithState>> ): Maybe<List<StreamWithState>>
/**
* Remove links to streams that are older than the given date
* **but keep at least one stream per uploader**.
*
* One stream per uploader is kept because it is needed as reference
* when fetching new streams to check if they are new or not.
* @param offsetDateTime the newest date to keep, older streams are removed
*/
@Query( @Query(
""" """
DELETE FROM feed WHERE DELETE FROM feed
WHERE feed.stream_id IN (SELECT uid from (
SELECT s.uid,
(SELECT MAX(upload_date)
FROM streams s1
INNER JOIN feed f1
ON s1.uid = f1.stream_id
WHERE f1.subscription_id = f.subscription_id) max_upload_date
FROM streams s
INNER JOIN feed f
ON s.uid = f.stream_id
feed.stream_id IN ( WHERE s.upload_date < :offsetDateTime
SELECT s.uid FROM streams s AND s.upload_date <> max_upload_date))
INNER JOIN feed f
ON s.uid = f.stream_id
WHERE s.upload_date < :offsetDateTime
)
""" """
) )
abstract fun unlinkStreamsOlderThan(offsetDateTime: OffsetDateTime) abstract fun unlinkStreamsOlderThan(offsetDateTime: OffsetDateTime)

View file

@ -7,6 +7,7 @@ import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamStateEntity import org.schabi.newpipe.database.stream.model.StreamStateEntity
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.util.image.ImageStrategy
data class PlaylistStreamEntry( data class PlaylistStreamEntry(
@Embedded @Embedded
@ -28,7 +29,7 @@ data class PlaylistStreamEntry(
item.duration = streamEntity.duration item.duration = streamEntity.duration
item.uploaderName = streamEntity.uploader item.uploaderName = streamEntity.uploader
item.uploaderUrl = streamEntity.uploaderUrl item.uploaderUrl = streamEntity.uploaderUrl
item.thumbnailUrl = streamEntity.thumbnailUrl item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
return item return item
} }

View file

@ -11,6 +11,7 @@ import androidx.room.PrimaryKey;
import org.schabi.newpipe.database.playlist.PlaylistLocalItem; import org.schabi.newpipe.database.playlist.PlaylistLocalItem;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo; import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.image.ImageStrategy;
import static org.schabi.newpipe.database.LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM; import static org.schabi.newpipe.database.LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM;
import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_NAME; import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_NAME;
@ -69,8 +70,9 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
@Ignore @Ignore
public PlaylistRemoteEntity(final PlaylistInfo info) { public PlaylistRemoteEntity(final PlaylistInfo info) {
this(info.getServiceId(), info.getName(), info.getUrl(), this(info.getServiceId(), info.getName(), info.getUrl(),
info.getThumbnailUrl() == null // use uploader avatar when no thumbnail is available
? info.getUploaderAvatarUrl() : info.getThumbnailUrl(), ImageStrategy.imageListToDbUrl(info.getThumbnails().isEmpty()
? info.getUploaderAvatars() : info.getThumbnails()),
info.getUploaderName(), info.getStreamCount()); info.getUploaderName(), info.getStreamCount());
} }
@ -84,7 +86,10 @@ public class PlaylistRemoteEntity implements PlaylistLocalItem {
&& getStreamCount() == info.getStreamCount() && getStreamCount() == info.getStreamCount()
&& TextUtils.equals(getName(), info.getName()) && TextUtils.equals(getName(), info.getName())
&& TextUtils.equals(getUrl(), info.getUrl()) && TextUtils.equals(getUrl(), info.getUrl())
&& TextUtils.equals(getThumbnailUrl(), info.getThumbnailUrl()) // we want to update the local playlist data even when either the remote thumbnail
// URL changes, or the preferred image quality setting is changed by the user
&& TextUtils.equals(getThumbnailUrl(),
ImageStrategy.imageListToDbUrl(info.getThumbnails()))
&& TextUtils.equals(getUploader(), info.getUploaderName()); && TextUtils.equals(getUploader(), info.getUploaderName());
} }

View file

@ -7,6 +7,7 @@ import org.schabi.newpipe.database.history.model.StreamHistoryEntity
import org.schabi.newpipe.database.stream.model.StreamEntity import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS import org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.util.image.ImageStrategy
import java.time.OffsetDateTime import java.time.OffsetDateTime
class StreamStatisticsEntry( class StreamStatisticsEntry(
@ -30,7 +31,7 @@ class StreamStatisticsEntry(
item.duration = streamEntity.duration item.duration = streamEntity.duration
item.uploaderName = streamEntity.uploader item.uploaderName = streamEntity.uploader
item.uploaderUrl = streamEntity.uploaderUrl item.uploaderUrl = streamEntity.uploaderUrl
item.thumbnailUrl = streamEntity.thumbnailUrl item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
return item return item
} }

View file

@ -13,6 +13,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.extractor.stream.StreamType import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.player.playqueue.PlayQueueItem import org.schabi.newpipe.player.playqueue.PlayQueueItem
import org.schabi.newpipe.util.image.ImageStrategy
import java.io.Serializable import java.io.Serializable
import java.time.OffsetDateTime import java.time.OffsetDateTime
@ -67,7 +68,8 @@ data class StreamEntity(
constructor(item: StreamInfoItem) : this( constructor(item: StreamInfoItem) : this(
serviceId = item.serviceId, url = item.url, title = item.name, serviceId = item.serviceId, url = item.url, title = item.name,
streamType = item.streamType, duration = item.duration, uploader = item.uploaderName, streamType = item.streamType, duration = item.duration, uploader = item.uploaderName,
uploaderUrl = item.uploaderUrl, thumbnailUrl = item.thumbnailUrl, viewCount = item.viewCount, uploaderUrl = item.uploaderUrl,
thumbnailUrl = ImageStrategy.imageListToDbUrl(item.thumbnails), viewCount = item.viewCount,
textualUploadDate = item.textualUploadDate, uploadDate = item.uploadDate?.offsetDateTime(), textualUploadDate = item.textualUploadDate, uploadDate = item.uploadDate?.offsetDateTime(),
isUploadDateApproximation = item.uploadDate?.isApproximation isUploadDateApproximation = item.uploadDate?.isApproximation
) )
@ -76,7 +78,8 @@ data class StreamEntity(
constructor(info: StreamInfo) : this( constructor(info: StreamInfo) : this(
serviceId = info.serviceId, url = info.url, title = info.name, serviceId = info.serviceId, url = info.url, title = info.name,
streamType = info.streamType, duration = info.duration, uploader = info.uploaderName, streamType = info.streamType, duration = info.duration, uploader = info.uploaderName,
uploaderUrl = info.uploaderUrl, thumbnailUrl = info.thumbnailUrl, viewCount = info.viewCount, uploaderUrl = info.uploaderUrl,
thumbnailUrl = ImageStrategy.imageListToDbUrl(info.thumbnails), viewCount = info.viewCount,
textualUploadDate = info.textualUploadDate, uploadDate = info.uploadDate?.offsetDateTime(), textualUploadDate = info.textualUploadDate, uploadDate = info.uploadDate?.offsetDateTime(),
isUploadDateApproximation = info.uploadDate?.isApproximation isUploadDateApproximation = info.uploadDate?.isApproximation
) )
@ -85,7 +88,8 @@ data class StreamEntity(
constructor(item: PlayQueueItem) : this( constructor(item: PlayQueueItem) : this(
serviceId = item.serviceId, url = item.url, title = item.title, serviceId = item.serviceId, url = item.url, title = item.title,
streamType = item.streamType, duration = item.duration, uploader = item.uploader, streamType = item.streamType, duration = item.duration, uploader = item.uploader,
uploaderUrl = item.uploaderUrl, thumbnailUrl = item.thumbnailUrl uploaderUrl = item.uploaderUrl,
thumbnailUrl = ImageStrategy.imageListToDbUrl(item.thumbnails)
) )
fun toStreamInfoItem(): StreamInfoItem { fun toStreamInfoItem(): StreamInfoItem {
@ -93,7 +97,7 @@ data class StreamEntity(
item.duration = duration item.duration = duration
item.uploaderName = uploader item.uploaderName = uploader
item.uploaderUrl = uploaderUrl item.uploaderUrl = uploaderUrl
item.thumbnailUrl = thumbnailUrl item.thumbnails = ImageStrategy.dbUrlToImageList(thumbnailUrl)
if (viewCount != null) item.viewCount = viewCount as Long if (viewCount != null) item.viewCount = viewCount as Long
item.textualUploadDate = textualUploadDate item.textualUploadDate = textualUploadDate

View file

@ -10,6 +10,7 @@ import androidx.room.PrimaryKey;
import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.channel.ChannelInfoItem; import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.image.ImageStrategy;
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID; import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID;
import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE; import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE;
@ -57,8 +58,8 @@ public class SubscriptionEntity {
final SubscriptionEntity result = new SubscriptionEntity(); final SubscriptionEntity result = new SubscriptionEntity();
result.setServiceId(info.getServiceId()); result.setServiceId(info.getServiceId());
result.setUrl(info.getUrl()); result.setUrl(info.getUrl());
result.setData(info.getName(), info.getAvatarUrl(), info.getDescription(), result.setData(info.getName(), ImageStrategy.imageListToDbUrl(info.getAvatars()),
info.getSubscriberCount()); info.getDescription(), info.getSubscriberCount());
return result; return result;
} }
@ -138,7 +139,7 @@ public class SubscriptionEntity {
@Ignore @Ignore
public ChannelInfoItem toChannelInfoItem() { public ChannelInfoItem toChannelInfoItem() {
final ChannelInfoItem item = new ChannelInfoItem(getServiceId(), getUrl(), getName()); final ChannelInfoItem item = new ChannelInfoItem(getServiceId(), getUrl(), getName());
item.setThumbnailUrl(getAvatarUrl()); item.setThumbnails(ImageStrategy.dbUrlToImageList(getAvatarUrl()));
item.setSubscriberCount(getSubscriberCount()); item.setSubscriberCount(getSubscriberCount());
item.setDescription(getDescription()); item.setDescription(getDescription());
return item; return item;

View file

@ -69,7 +69,7 @@ import org.schabi.newpipe.util.SecondaryStreamHelper;
import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener; import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener;
import org.schabi.newpipe.util.SponsorBlockUtils; import org.schabi.newpipe.util.SponsorBlockUtils;
import org.schabi.newpipe.util.StreamItemAdapter; import org.schabi.newpipe.util.StreamItemAdapter;
import org.schabi.newpipe.util.StreamItemAdapter.StreamSizeWrapper; import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper;
import org.schabi.newpipe.util.AudioTrackAdapter; import org.schabi.newpipe.util.AudioTrackAdapter;
import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper; import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper;
import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.ThemeHelper;
@ -77,6 +77,7 @@ import org.schabi.newpipe.util.VideoSegment;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Objects; import java.util.Objects;
@ -103,9 +104,9 @@ public class DownloadDialog extends DialogFragment
@State @State
StreamInfo currentInfo; StreamInfo currentInfo;
@State @State
StreamSizeWrapper<VideoStream> wrappedVideoStreams; StreamInfoWrapper<VideoStream> wrappedVideoStreams;
@State @State
StreamSizeWrapper<SubtitlesStream> wrappedSubtitleStreams; StreamInfoWrapper<SubtitlesStream> wrappedSubtitleStreams;
@State @State
AudioTracksWrapper wrappedAudioTracks; AudioTracksWrapper wrappedAudioTracks;
@State @State
@ -195,8 +196,8 @@ public class DownloadDialog extends DialogFragment
wrappedAudioTracks.size() > 1 wrappedAudioTracks.size() > 1
); );
this.wrappedVideoStreams = new StreamSizeWrapper<>(videoStreams, context); this.wrappedVideoStreams = new StreamInfoWrapper<>(videoStreams, context);
this.wrappedSubtitleStreams = new StreamSizeWrapper<>( this.wrappedSubtitleStreams = new StreamInfoWrapper<>(
getStreamsOfSpecifiedDelivery(info.getSubtitles(), PROGRESSIVE_HTTP), context); getStreamsOfSpecifiedDelivery(info.getSubtitles(), PROGRESSIVE_HTTP), context);
this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams); this.selectedVideoIndex = ListHelper.getDefaultResolutionIndex(context, videoStreams);
@ -270,17 +271,17 @@ public class DownloadDialog extends DialogFragment
* Update the displayed video streams based on the selected audio track. * Update the displayed video streams based on the selected audio track.
*/ */
private void updateSecondaryStreams() { private void updateSecondaryStreams() {
final StreamSizeWrapper<AudioStream> audioStreams = getWrappedAudioStreams(); final StreamInfoWrapper<AudioStream> audioStreams = getWrappedAudioStreams();
final var secondaryStreams = new SparseArrayCompat<SecondaryStreamHelper<AudioStream>>(4); final var secondaryStreams = new SparseArrayCompat<SecondaryStreamHelper<AudioStream>>(4);
final List<VideoStream> videoStreams = wrappedVideoStreams.getStreamsList(); final List<VideoStream> videoStreams = wrappedVideoStreams.getStreamsList();
wrappedVideoStreams.resetSizes(); wrappedVideoStreams.resetInfo();
for (int i = 0; i < videoStreams.size(); i++) { for (int i = 0; i < videoStreams.size(); i++) {
if (!videoStreams.get(i).isVideoOnly()) { if (!videoStreams.get(i).isVideoOnly()) {
continue; continue;
} }
final AudioStream audioStream = SecondaryStreamHelper final AudioStream audioStream = SecondaryStreamHelper.getAudioStreamFor(
.getAudioStreamFor(audioStreams.getStreamsList(), videoStreams.get(i)); context, audioStreams.getStreamsList(), videoStreams.get(i));
if (audioStream != null) { if (audioStream != null) {
secondaryStreams.append(i, new SecondaryStreamHelper<>(audioStreams, audioStream)); secondaryStreams.append(i, new SecondaryStreamHelper<>(audioStreams, audioStream));
@ -410,7 +411,7 @@ public class DownloadDialog extends DialogFragment
private void fetchStreamsSize() { private void fetchStreamsSize() {
disposables.clear(); disposables.clear();
disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedVideoStreams) disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedVideoStreams)
.subscribe(result -> { .subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
== R.id.video_button) { == R.id.video_button) {
@ -420,7 +421,7 @@ public class DownloadDialog extends DialogFragment
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
"Downloading video stream size", "Downloading video stream size",
currentInfo.getServiceId())))); currentInfo.getServiceId()))));
disposables.add(StreamSizeWrapper.fetchSizeForWrapper(getWrappedAudioStreams()) disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(getWrappedAudioStreams())
.subscribe(result -> { .subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
== R.id.audio_button) { == R.id.audio_button) {
@ -430,7 +431,7 @@ public class DownloadDialog extends DialogFragment
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG, new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
"Downloading audio stream size", "Downloading audio stream size",
currentInfo.getServiceId())))); currentInfo.getServiceId()))));
disposables.add(StreamSizeWrapper.fetchSizeForWrapper(wrappedSubtitleStreams) disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedSubtitleStreams)
.subscribe(result -> { .subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId() if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
== R.id.subtitle_button) { == R.id.subtitle_button) {
@ -738,9 +739,9 @@ public class DownloadDialog extends DialogFragment
dialogBinding.subtitleButton.setEnabled(enabled); dialogBinding.subtitleButton.setEnabled(enabled);
} }
private StreamSizeWrapper<AudioStream> getWrappedAudioStreams() { private StreamInfoWrapper<AudioStream> getWrappedAudioStreams() {
if (selectedAudioTrackIndex < 0 || selectedAudioTrackIndex > wrappedAudioTracks.size()) { if (selectedAudioTrackIndex < 0 || selectedAudioTrackIndex > wrappedAudioTracks.size()) {
return StreamSizeWrapper.empty(); return StreamInfoWrapper.empty();
} }
return wrappedAudioTracks.getTracksList().get(selectedAudioTrackIndex); return wrappedAudioTracks.getTracksList().get(selectedAudioTrackIndex);
} }
@ -780,7 +781,7 @@ public class DownloadDialog extends DialogFragment
} }
private void showFailedDialog(@StringRes final int msg) { private void showFailedDialog(@StringRes final int msg) {
assureCorrectAppLanguage(getContext()); assureCorrectAppLanguage(requireContext());
new AlertDialog.Builder(context) new AlertDialog.Builder(context)
.setTitle(R.string.general_error) .setTitle(R.string.general_error)
.setMessage(msg) .setMessage(msg)
@ -813,7 +814,7 @@ public class DownloadDialog extends DialogFragment
filenameTmp += "opus"; filenameTmp += "opus";
} else if (format != null) { } else if (format != null) {
mimeTmp = format.mimeType; mimeTmp = format.mimeType;
filenameTmp += format.suffix; filenameTmp += format.getSuffix();
} }
break; break;
case R.id.video_button: case R.id.video_button:
@ -822,7 +823,7 @@ public class DownloadDialog extends DialogFragment
format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat(); format = videoStreamsAdapter.getItem(selectedVideoIndex).getFormat();
if (format != null) { if (format != null) {
mimeTmp = format.mimeType; mimeTmp = format.mimeType;
filenameTmp += format.suffix; filenameTmp += format.getSuffix();
} }
break; break;
case R.id.subtitle_button: case R.id.subtitle_button:
@ -834,9 +835,9 @@ public class DownloadDialog extends DialogFragment
} }
if (format == MediaFormat.TTML) { if (format == MediaFormat.TTML) {
filenameTmp += MediaFormat.SRT.suffix; filenameTmp += MediaFormat.SRT.getSuffix();
} else if (format != null) { } else if (format != null) {
filenameTmp += format.suffix; filenameTmp += format.getSuffix();
} }
break; break;
default: default:
@ -1066,7 +1067,7 @@ public class DownloadDialog extends DialogFragment
final char kind; final char kind;
int threads = dialogBinding.threads.getProgress() + 1; int threads = dialogBinding.threads.getProgress() + 1;
final String[] urls; final String[] urls;
final MissionRecoveryInfo[] recoveryInfo; final List<MissionRecoveryInfo> recoveryInfo;
String psName = null; String psName = null;
String[] psArgs = null; String[] psArgs = null;
long nearLength = 0; long nearLength = 0;
@ -1131,9 +1132,7 @@ public class DownloadDialog extends DialogFragment
urls = new String[] { urls = new String[] {
selectedStream.getContent() selectedStream.getContent()
}; };
recoveryInfo = new MissionRecoveryInfo[] { recoveryInfo = List.of(new MissionRecoveryInfo(selectedStream));
new MissionRecoveryInfo(selectedStream)
};
} else { } else {
if (secondaryStream.getDeliveryMethod() != PROGRESSIVE_HTTP) { if (secondaryStream.getDeliveryMethod() != PROGRESSIVE_HTTP) {
throw new IllegalArgumentException("Unsupported stream delivery format" throw new IllegalArgumentException("Unsupported stream delivery format"
@ -1143,13 +1142,14 @@ public class DownloadDialog extends DialogFragment
urls = new String[] { urls = new String[] {
selectedStream.getContent(), secondaryStream.getContent() selectedStream.getContent(), secondaryStream.getContent()
}; };
recoveryInfo = new MissionRecoveryInfo[] {new MissionRecoveryInfo(selectedStream), recoveryInfo = List.of(
new MissionRecoveryInfo(secondaryStream)}; new MissionRecoveryInfo(selectedStream),
new MissionRecoveryInfo(secondaryStream)
);
} }
DownloadManagerService.startMission(context, urls, storage, kind, threads, DownloadManagerService.startMission(context, urls, storage, kind, threads,
currentInfo.getUrl(), psName, psArgs, nearLength, recoveryInfo, currentInfo.getUrl(), psName, psArgs, nearLength, new ArrayList<>(recoveryInfo), segments);
segments);
Toast.makeText(context, getString(R.string.download_has_started), Toast.makeText(context, getString(R.string.download_has_started),
Toast.LENGTH_SHORT).show(); Toast.LENGTH_SHORT).show();

View file

@ -0,0 +1,87 @@
package org.schabi.newpipe.download;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.widget.Toolbar;
import androidx.fragment.app.DialogFragment;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.DownloadLoadingDialogBinding;
/**
* This class contains a dialog which shows a loading indicator and has a customizable title.
*/
public class LoadingDialog extends DialogFragment {
private static final String TAG = "LoadingDialog";
private static final boolean DEBUG = MainActivity.DEBUG;
private DownloadLoadingDialogBinding dialogLoadingBinding;
private final @StringRes int title;
/**
* Create a new LoadingDialog.
*
* <p>
* The dialog contains a loading indicator and has a customizable title.
* <br/>
* Use {@code show()} to display the dialog to the user.
* </p>
*
* @param title an informative title shown in the dialog's toolbar
*/
public LoadingDialog(final @StringRes int title) {
this.title = title;
}
@Override
public void onCreate(@Nullable final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
if (DEBUG) {
Log.d(TAG, "onCreate() called with: "
+ "savedInstanceState = [" + savedInstanceState + "]");
}
this.setCancelable(false);
}
@Override
public View onCreateView(
@NonNull final LayoutInflater inflater,
final ViewGroup container,
final Bundle savedInstanceState) {
if (DEBUG) {
Log.d(TAG, "onCreateView() called with: "
+ "inflater = [" + inflater + "], container = [" + container + "], "
+ "savedInstanceState = [" + savedInstanceState + "]");
}
return inflater.inflate(R.layout.download_loading_dialog, container);
}
@Override
public void onViewCreated(@NonNull final View view, @Nullable final Bundle savedInstanceState) {
super.onViewCreated(view, savedInstanceState);
dialogLoadingBinding = DownloadLoadingDialogBinding.bind(view);
initToolbar(dialogLoadingBinding.toolbarLayout.toolbar);
}
private void initToolbar(final Toolbar toolbar) {
if (DEBUG) {
Log.d(TAG, "initToolbar() called with: toolbar = [" + toolbar + "]");
}
toolbar.setTitle(requireContext().getString(title));
toolbar.setNavigationOnClickListener(v -> dismiss());
}
@Override
public void onDestroyView() {
dialogLoadingBinding = null;
super.onDestroyView();
}
}

View file

@ -17,6 +17,7 @@ import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar; import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.IntentCompat;
import com.grack.nanojson.JsonWriter; import com.grack.nanojson.JsonWriter;
@ -105,7 +106,7 @@ public class ErrorActivity extends AppCompatActivity {
actionBar.setDisplayShowTitleEnabled(true); actionBar.setDisplayShowTitleEnabled(true);
} }
errorInfo = intent.getParcelableExtra(ERROR_INFO); errorInfo = IntentCompat.getParcelableExtra(intent, ERROR_INFO, ErrorInfo.class);
// important add guru meditation // important add guru meditation
addGuruMeditation(); addGuruMeditation();

View file

@ -11,7 +11,6 @@ import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
import org.schabi.newpipe.extractor.exceptions.ExtractionException import org.schabi.newpipe.extractor.exceptions.ExtractionException
import org.schabi.newpipe.extractor.services.youtube.extractors.YoutubeStreamExtractor.DeobfuscateException
import org.schabi.newpipe.ktx.isNetworkRelated import org.schabi.newpipe.ktx.isNetworkRelated
import org.schabi.newpipe.util.ServiceHelper import org.schabi.newpipe.util.ServiceHelper
@ -96,7 +95,6 @@ class ErrorInfo(
throwable is ContentNotAvailableException -> R.string.content_not_available throwable is ContentNotAvailableException -> R.string.content_not_available
throwable != null && throwable.isNetworkRelated -> R.string.network_error throwable != null && throwable.isNetworkRelated -> R.string.network_error
throwable is ContentNotSupportedException -> R.string.content_not_supported throwable is ContentNotSupportedException -> R.string.content_not_supported
throwable is DeobfuscateException -> R.string.youtube_signature_deobfuscation_error
throwable is ExtractionException -> R.string.parsing_error throwable is ExtractionException -> R.string.parsing_error
throwable is ExoPlaybackException -> { throwable is ExoPlaybackException -> {
when (throwable.type) { when (throwable.type) {

View file

@ -19,6 +19,7 @@ public enum UserAction {
REQUESTED_PLAYLIST("requested playlist"), REQUESTED_PLAYLIST("requested playlist"),
REQUESTED_KIOSK("requested kiosk"), REQUESTED_KIOSK("requested kiosk"),
REQUESTED_COMMENTS("requested comments"), REQUESTED_COMMENTS("requested comments"),
REQUESTED_COMMENT_REPLIES("requested comment replies"),
REQUESTED_FEED("requested feed"), REQUESTED_FEED("requested feed"),
REQUESTED_BOOKMARK("bookmark"), REQUESTED_BOOKMARK("bookmark"),
DELETE_FROM_HISTORY("delete from history"), DELETE_FROM_HISTORY("delete from history"),

View file

@ -1,12 +1,16 @@
package org.schabi.newpipe.fragments; package org.schabi.newpipe.fragments;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
import android.os.Bundle; import android.os.Bundle;
import android.util.Log; import android.util.Log;
import android.view.View; import android.view.View;
import android.widget.ProgressBar; import android.widget.ProgressBar;
import android.widget.TextView;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.fragment.app.Fragment; import androidx.fragment.app.Fragment;
import org.schabi.newpipe.BaseFragment; import org.schabi.newpipe.BaseFragment;
@ -20,15 +24,15 @@ import java.util.concurrent.atomic.AtomicBoolean;
import icepick.State; import icepick.State;
import static org.schabi.newpipe.ktx.ViewUtils.animate;
public abstract class BaseStateFragment<I> extends BaseFragment implements ViewContract<I> { public abstract class BaseStateFragment<I> extends BaseFragment implements ViewContract<I> {
@State @State
protected AtomicBoolean wasLoading = new AtomicBoolean(); protected AtomicBoolean wasLoading = new AtomicBoolean();
protected AtomicBoolean isLoading = new AtomicBoolean(); protected AtomicBoolean isLoading = new AtomicBoolean();
@Nullable @Nullable
private View emptyStateView; protected View emptyStateView;
@Nullable
protected TextView emptyStateMessageView;
@Nullable @Nullable
private ProgressBar loadingProgressBar; private ProgressBar loadingProgressBar;
@ -65,6 +69,7 @@ public abstract class BaseStateFragment<I> extends BaseFragment implements ViewC
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);
emptyStateView = rootView.findViewById(R.id.empty_state_view); emptyStateView = rootView.findViewById(R.id.empty_state_view);
emptyStateMessageView = rootView.findViewById(R.id.empty_state_message);
loadingProgressBar = rootView.findViewById(R.id.loading_progress_bar); loadingProgressBar = rootView.findViewById(R.id.loading_progress_bar);
errorPanelHelper = new ErrorPanelHelper(this, rootView, this::onRetryButtonClicked); errorPanelHelper = new ErrorPanelHelper(this, rootView, this::onRetryButtonClicked);
} }
@ -75,6 +80,8 @@ public abstract class BaseStateFragment<I> extends BaseFragment implements ViewC
if (errorPanelHelper != null) { if (errorPanelHelper != null) {
errorPanelHelper.dispose(); errorPanelHelper.dispose();
} }
emptyStateView = null;
emptyStateMessageView = null;
} }
protected void onRetryButtonClicked() { protected void onRetryButtonClicked() {
@ -189,6 +196,12 @@ public abstract class BaseStateFragment<I> extends BaseFragment implements ViewC
errorPanelHelper.showTextError(errorString); errorPanelHelper.showTextError(errorString);
} }
protected void setEmptyStateMessage(@StringRes final int text) {
if (emptyStateMessageView != null) {
emptyStateMessageView.setText(text);
}
}
public final void hideErrorPanel() { public final void hideErrorPanel() {
errorPanelHelper.hide(); errorPanelHelper.hide();
lastPanelError = null; lastPanelError = null;

View file

@ -38,6 +38,7 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.FragmentMainBinding; import org.schabi.newpipe.databinding.FragmentMainBinding;
import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
import org.schabi.newpipe.settings.tabs.Tab; import org.schabi.newpipe.settings.tabs.Tab;
import org.schabi.newpipe.settings.tabs.TabsManager; import org.schabi.newpipe.settings.tabs.TabsManager;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
@ -139,6 +140,12 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
} }
} }
@Override
public void onDestroyView() {
super.onDestroyView();
binding = null;
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Menu // Menu
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@ -187,7 +194,6 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
} }
binding.pager.setAdapter(null); binding.pager.setAdapter(null);
binding.pager.setOffscreenPageLimit(tabsList.size());
binding.pager.setAdapter(pagerAdapter); binding.pager.setAdapter(pagerAdapter);
updateTabsIconAndDescription(); updateTabsIconAndDescription();
@ -211,6 +217,12 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
setTitle(tabsList.get(tabPosition).getTabName(requireContext())); setTitle(tabsList.get(tabPosition).getTabName(requireContext()));
} }
public void commitPlaylistTabs() {
pagerAdapter.getLocalPlaylistFragments()
.stream()
.forEach(LocalPlaylistFragment::commitChanges);
}
private void updateTabLayoutPosition() { private void updateTabLayoutPosition() {
final ScrollableTabLayout tabLayout = binding.mainTabLayout; final ScrollableTabLayout tabLayout = binding.mainTabLayout;
final ViewPager viewPager = binding.pager; final ViewPager viewPager = binding.pager;
@ -262,10 +274,18 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
updateTitleForTab(tab.getPosition()); updateTitleForTab(tab.getPosition());
} }
private static final class SelectedTabsPagerAdapter public static final class SelectedTabsPagerAdapter
extends FragmentStatePagerAdapterMenuWorkaround { extends FragmentStatePagerAdapterMenuWorkaround {
private final Context context; private final Context context;
private final List<Tab> internalTabsList; private final List<Tab> internalTabsList;
/**
* Keep reference to LocalPlaylistFragments, because their data can be modified by the user
* during runtime and changes are not committed immediately. However, in some cases,
* the changes need to be committed immediately by calling
* {@link LocalPlaylistFragment#commitChanges()}.
* The fragments are removed when {@link LocalPlaylistFragment#onDestroy()} is called.
*/
private final List<LocalPlaylistFragment> localPlaylistFragments = new ArrayList<>();
private SelectedTabsPagerAdapter(final Context context, private SelectedTabsPagerAdapter(final Context context,
final FragmentManager fragmentManager, final FragmentManager fragmentManager,
@ -292,9 +312,17 @@ public class MainFragment extends BaseFragment implements TabLayout.OnTabSelecte
((BaseFragment) fragment).useAsFrontPage(true); ((BaseFragment) fragment).useAsFrontPage(true);
} }
if (fragment instanceof LocalPlaylistFragment) {
localPlaylistFragments.add((LocalPlaylistFragment) fragment);
}
return fragment; return fragment;
} }
public List<LocalPlaylistFragment> getLocalPlaylistFragments() {
return localPlaylistFragments;
}
@Override @Override
public int getItemPosition(@NonNull final Object object) { public int getItemPosition(@NonNull final Object object) {
// Causes adapter to reload all Fragments when // Causes adapter to reload all Fragments when

View file

@ -0,0 +1,285 @@
package org.schabi.newpipe.fragments.detail;
import static android.text.TextUtils.isEmpty;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD;
import android.graphics.Typeface;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.text.style.ClickableSpan;
import android.text.style.StyleSpan;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.appcompat.widget.TooltipCompat;
import androidx.core.text.HtmlCompat;
import com.google.android.material.chip.Chip;
import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.FragmentDescriptionBinding;
import org.schabi.newpipe.databinding.ItemMetadataBinding;
import org.schabi.newpipe.databinding.ItemMetadataTagsBinding;
import org.schabi.newpipe.extractor.Image;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.text.TextLinkifier;
import java.util.List;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public abstract class BaseDescriptionFragment extends BaseFragment {
private final CompositeDisposable descriptionDisposables = new CompositeDisposable();
protected FragmentDescriptionBinding binding;
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
binding = FragmentDescriptionBinding.inflate(inflater, container, false);
setupDescription();
setupMetadata(inflater, binding.detailMetadataLayout);
addTagsMetadataItem(inflater, binding.detailMetadataLayout);
return binding.getRoot();
}
@Override
public void onDestroy() {
descriptionDisposables.clear();
super.onDestroy();
}
/**
* Get the description to display.
* @return description object
*/
@Nullable
protected abstract Description getDescription();
/**
* Get the streaming service. Used for generating description links.
* @return streaming service
*/
@Nullable
protected abstract StreamingService getService();
/**
* Get the streaming service ID. Used for tag links.
* @return service ID
*/
protected abstract int getServiceId();
/**
* Get the URL of the described video or audio, used to generate description links.
* @return stream URL
*/
@Nullable
protected abstract String getStreamUrl();
/**
* Get the list of tags to display below the description.
* @return tag list
*/
@Nullable
public abstract List<String> getTags();
/**
* Add additional metadata to display.
* @param inflater LayoutInflater
* @param layout detailMetadataLayout
*/
protected abstract void setupMetadata(LayoutInflater inflater, LinearLayout layout);
private void setupDescription() {
final Description description = getDescription();
if (description == null || isEmpty(description.getContent())
|| description == Description.EMPTY_DESCRIPTION) {
binding.detailDescriptionView.setVisibility(View.GONE);
binding.detailSelectDescriptionButton.setVisibility(View.GONE);
return;
}
// start with disabled state. This also loads description content (!)
disableDescriptionSelection();
binding.detailSelectDescriptionButton.setOnClickListener(v -> {
if (binding.detailDescriptionNoteView.getVisibility() == View.VISIBLE) {
disableDescriptionSelection();
} else {
// enable selection only when button is clicked to prevent flickering
enableDescriptionSelection();
}
});
}
private void enableDescriptionSelection() {
binding.detailDescriptionNoteView.setVisibility(View.VISIBLE);
binding.detailDescriptionView.setTextIsSelectable(true);
final String buttonLabel = getString(R.string.description_select_disable);
binding.detailSelectDescriptionButton.setContentDescription(buttonLabel);
TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel);
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_close);
}
private void disableDescriptionSelection() {
// show description content again, otherwise some links are not clickable
final Description description = getDescription();
if (description != null) {
TextLinkifier.fromDescription(binding.detailDescriptionView,
description, HtmlCompat.FROM_HTML_MODE_LEGACY,
getService(), getStreamUrl(),
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
}
binding.detailDescriptionNoteView.setVisibility(View.GONE);
binding.detailDescriptionView.setTextIsSelectable(false);
final String buttonLabel = getString(R.string.description_select_enable);
binding.detailSelectDescriptionButton.setContentDescription(buttonLabel);
TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel);
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all);
}
protected void addMetadataItem(final LayoutInflater inflater,
final LinearLayout layout,
final boolean linkifyContent,
@StringRes final int type,
@Nullable final String content) {
if (isBlank(content)) {
return;
}
final ItemMetadataBinding itemBinding =
ItemMetadataBinding.inflate(inflater, layout, false);
itemBinding.metadataTypeView.setText(type);
itemBinding.metadataTypeView.setOnLongClickListener(v -> {
ShareUtils.copyToClipboard(requireContext(), content);
return true;
});
if (linkifyContent) {
TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null,
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
} else {
itemBinding.metadataContentView.setText(content);
}
itemBinding.metadataContentView.setClickable(true);
layout.addView(itemBinding.getRoot());
}
private String imageSizeToText(final int heightOrWidth) {
if (heightOrWidth < 0) {
return getString(R.string.question_mark);
} else {
return String.valueOf(heightOrWidth);
}
}
protected void addImagesMetadataItem(final LayoutInflater inflater,
final LinearLayout layout,
@StringRes final int type,
final List<Image> images) {
final String preferredImageUrl = ImageStrategy.choosePreferredImage(images);
if (preferredImageUrl == null) {
return; // null will be returned in case there is no image
}
final ItemMetadataBinding itemBinding =
ItemMetadataBinding.inflate(inflater, layout, false);
itemBinding.metadataTypeView.setText(type);
final SpannableStringBuilder urls = new SpannableStringBuilder();
for (final Image image : images) {
if (urls.length() != 0) {
urls.append(", ");
}
final int entryBegin = urls.length();
if (image.getHeight() != Image.HEIGHT_UNKNOWN
|| image.getWidth() != Image.WIDTH_UNKNOWN
// if even the resolution level is unknown, ?x? will be shown
|| image.getEstimatedResolutionLevel() == Image.ResolutionLevel.UNKNOWN) {
urls.append(imageSizeToText(image.getHeight()));
urls.append('x');
urls.append(imageSizeToText(image.getWidth()));
} else {
switch (image.getEstimatedResolutionLevel()) {
case LOW:
urls.append(getString(R.string.image_quality_low));
break;
default: // unreachable, Image.ResolutionLevel.UNKNOWN is already filtered out
case MEDIUM:
urls.append(getString(R.string.image_quality_medium));
break;
case HIGH:
urls.append(getString(R.string.image_quality_high));
break;
}
}
urls.setSpan(new ClickableSpan() {
@Override
public void onClick(@NonNull final View widget) {
ShareUtils.openUrlInBrowser(requireContext(), image.getUrl());
}
}, entryBegin, urls.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
if (preferredImageUrl.equals(image.getUrl())) {
urls.setSpan(new StyleSpan(Typeface.BOLD), entryBegin, urls.length(),
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
}
}
itemBinding.metadataContentView.setText(urls);
itemBinding.metadataContentView.setMovementMethod(LinkMovementMethod.getInstance());
layout.addView(itemBinding.getRoot());
}
private void addTagsMetadataItem(final LayoutInflater inflater, final LinearLayout layout) {
final List<String> tags = getTags();
if (tags != null && !tags.isEmpty()) {
final var itemBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false);
tags.stream().sorted(String.CASE_INSENSITIVE_ORDER).forEach(tag -> {
final Chip chip = (Chip) inflater.inflate(R.layout.chip,
itemBinding.metadataTagsChips, false);
chip.setText(tag);
chip.setOnClickListener(this::onTagClick);
chip.setOnLongClickListener(this::onTagLongClick);
itemBinding.metadataTagsChips.addView(chip);
});
layout.addView(itemBinding.getRoot());
}
}
private void onTagClick(final View chip) {
if (getParentFragment() != null) {
NavigationHelper.openSearchFragment(getParentFragment().getParentFragmentManager(),
getServiceId(), ((Chip) chip).getText().toString());
}
}
private boolean onTagLongClick(final View chip) {
ShareUtils.copyToClipboard(requireContext(), ((Chip) chip).getText().toString());
return true;
}
}

View file

@ -1,46 +1,29 @@
package org.schabi.newpipe.fragments.detail; package org.schabi.newpipe.fragments.detail;
import static android.text.TextUtils.isEmpty;
import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT; import static org.schabi.newpipe.extractor.stream.StreamExtractor.NO_AGE_LIMIT;
import static org.schabi.newpipe.extractor.utils.Utils.isBlank;
import static org.schabi.newpipe.util.Localization.getAppLocale; import static org.schabi.newpipe.util.Localization.getAppLocale;
import static org.schabi.newpipe.util.text.TextLinkifier.SET_LINK_MOVEMENT_METHOD;
import android.os.Bundle;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.View; import android.view.View;
import android.view.ViewGroup;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.annotation.StringRes; import androidx.annotation.StringRes;
import androidx.appcompat.widget.TooltipCompat;
import androidx.core.text.HtmlCompat;
import com.google.android.material.chip.Chip;
import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.FragmentDescriptionBinding; import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.databinding.ItemMetadataBinding;
import org.schabi.newpipe.databinding.ItemMetadataTagsBinding;
import org.schabi.newpipe.extractor.stream.Description; import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils; import java.util.List;
import org.schabi.newpipe.util.text.TextLinkifier;
import icepick.State; import icepick.State;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
public class DescriptionFragment extends BaseFragment { public class DescriptionFragment extends BaseDescriptionFragment {
@State @State
StreamInfo streamInfo = null; StreamInfo streamInfo = null;
final CompositeDisposable descriptionDisposables = new CompositeDisposable();
FragmentDescriptionBinding binding;
public DescriptionFragment() { public DescriptionFragment() {
} }
@ -49,86 +32,64 @@ public class DescriptionFragment extends BaseFragment {
this.streamInfo = streamInfo; this.streamInfo = streamInfo;
} }
@Nullable
@Override @Override
public View onCreateView(@NonNull final LayoutInflater inflater, protected Description getDescription() {
@Nullable final ViewGroup container, if (streamInfo == null) {
@Nullable final Bundle savedInstanceState) { return null;
binding = FragmentDescriptionBinding.inflate(inflater, container, false);
if (streamInfo != null) {
setupUploadDate();
setupDescription();
setupMetadata(inflater, binding.detailMetadataLayout);
} }
return binding.getRoot(); return streamInfo.getDescription();
}
@Nullable
@Override
protected StreamingService getService() {
if (streamInfo == null) {
return null;
}
return streamInfo.getService();
} }
@Override @Override
public void onDestroy() { protected int getServiceId() {
descriptionDisposables.clear(); if (streamInfo == null) {
super.onDestroy(); return -1;
}
return streamInfo.getServiceId();
} }
@Nullable
@Override
protected String getStreamUrl() {
if (streamInfo == null) {
return null;
}
return streamInfo.getUrl();
}
private void setupUploadDate() { @Nullable
if (streamInfo.getUploadDate() != null) { @Override
public List<String> getTags() {
if (streamInfo == null) {
return null;
}
return streamInfo.getTags();
}
@Override
protected void setupMetadata(final LayoutInflater inflater,
final LinearLayout layout) {
if (streamInfo != null && streamInfo.getUploadDate() != null) {
binding.detailUploadDateView.setText(Localization binding.detailUploadDateView.setText(Localization
.localizeUploadDate(activity, streamInfo.getUploadDate().offsetDateTime())); .localizeUploadDate(activity, streamInfo.getUploadDate().offsetDateTime()));
} else { } else {
binding.detailUploadDateView.setVisibility(View.GONE); binding.detailUploadDateView.setVisibility(View.GONE);
} }
}
if (streamInfo == null) {
private void setupDescription() {
final Description description = streamInfo.getDescription();
if (description == null || isEmpty(description.getContent())
|| description == Description.EMPTY_DESCRIPTION) {
binding.detailDescriptionView.setVisibility(View.GONE);
binding.detailSelectDescriptionButton.setVisibility(View.GONE);
return; return;
} }
// start with disabled state. This also loads description content (!)
disableDescriptionSelection();
binding.detailSelectDescriptionButton.setOnClickListener(v -> {
if (binding.detailDescriptionNoteView.getVisibility() == View.VISIBLE) {
disableDescriptionSelection();
} else {
// enable selection only when button is clicked to prevent flickering
enableDescriptionSelection();
}
});
}
private void enableDescriptionSelection() {
binding.detailDescriptionNoteView.setVisibility(View.VISIBLE);
binding.detailDescriptionView.setTextIsSelectable(true);
final String buttonLabel = getString(R.string.description_select_disable);
binding.detailSelectDescriptionButton.setContentDescription(buttonLabel);
TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel);
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_close);
}
private void disableDescriptionSelection() {
// show description content again, otherwise some links are not clickable
TextLinkifier.fromDescription(binding.detailDescriptionView,
streamInfo.getDescription(), HtmlCompat.FROM_HTML_MODE_LEGACY,
streamInfo.getService(), streamInfo.getUrl(),
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
binding.detailDescriptionNoteView.setVisibility(View.GONE);
binding.detailDescriptionView.setTextIsSelectable(false);
final String buttonLabel = getString(R.string.description_select_enable);
binding.detailSelectDescriptionButton.setContentDescription(buttonLabel);
TooltipCompat.setTooltipText(binding.detailSelectDescriptionButton, buttonLabel);
binding.detailSelectDescriptionButton.setImageResource(R.drawable.ic_select_all);
}
private void setupMetadata(final LayoutInflater inflater,
final LinearLayout layout) {
addMetadataItem(inflater, layout, false, R.string.metadata_category, addMetadataItem(inflater, layout, false, R.string.metadata_category,
streamInfo.getCategory()); streamInfo.getCategory());
@ -151,69 +112,13 @@ public class DescriptionFragment extends BaseFragment {
streamInfo.getSupportInfo()); streamInfo.getSupportInfo());
addMetadataItem(inflater, layout, true, R.string.metadata_host, addMetadataItem(inflater, layout, true, R.string.metadata_host,
streamInfo.getHost()); streamInfo.getHost());
addMetadataItem(inflater, layout, true, R.string.metadata_thumbnail_url,
streamInfo.getThumbnailUrl());
addTagsMetadataItem(inflater, layout); addImagesMetadataItem(inflater, layout, R.string.metadata_thumbnails,
} streamInfo.getThumbnails());
addImagesMetadataItem(inflater, layout, R.string.metadata_uploader_avatars,
private void addMetadataItem(final LayoutInflater inflater, streamInfo.getUploaderAvatars());
final LinearLayout layout, addImagesMetadataItem(inflater, layout, R.string.metadata_subchannel_avatars,
final boolean linkifyContent, streamInfo.getSubChannelAvatars());
@StringRes final int type,
@Nullable final String content) {
if (isBlank(content)) {
return;
}
final ItemMetadataBinding itemBinding =
ItemMetadataBinding.inflate(inflater, layout, false);
itemBinding.metadataTypeView.setText(type);
itemBinding.metadataTypeView.setOnLongClickListener(v -> {
ShareUtils.copyToClipboard(requireContext(), content);
return true;
});
if (linkifyContent) {
TextLinkifier.fromPlainText(itemBinding.metadataContentView, content, null, null,
descriptionDisposables, SET_LINK_MOVEMENT_METHOD);
} else {
itemBinding.metadataContentView.setText(content);
}
itemBinding.metadataContentView.setClickable(true);
layout.addView(itemBinding.getRoot());
}
private void addTagsMetadataItem(final LayoutInflater inflater, final LinearLayout layout) {
if (streamInfo.getTags() != null && !streamInfo.getTags().isEmpty()) {
final var itemBinding = ItemMetadataTagsBinding.inflate(inflater, layout, false);
streamInfo.getTags().stream().sorted(String.CASE_INSENSITIVE_ORDER).forEach(tag -> {
final Chip chip = (Chip) inflater.inflate(R.layout.chip,
itemBinding.metadataTagsChips, false);
chip.setText(tag);
chip.setOnClickListener(this::onTagClick);
chip.setOnLongClickListener(this::onTagLongClick);
itemBinding.metadataTagsChips.addView(chip);
});
layout.addView(itemBinding.getRoot());
}
}
private void onTagClick(final View chip) {
if (getParentFragment() != null) {
NavigationHelper.openSearchFragment(getParentFragment().getParentFragmentManager(),
streamInfo.getServiceId(), ((Chip) chip).getText().toString());
}
}
private boolean onTagLongClick(final View chip) {
ShareUtils.copyToClipboard(requireContext(), ((Chip) chip).getText().toString());
return true;
} }
private void addPrivacyMetadataItem(final LayoutInflater inflater, final LinearLayout layout) { private void addPrivacyMetadataItem(final LayoutInflater inflater, final LinearLayout layout) {

View file

@ -24,7 +24,6 @@ import android.content.pm.ActivityInfo;
import android.database.ContentObserver; import android.database.ContentObserver;
import android.graphics.Color; import android.graphics.Color;
import android.graphics.Rect; import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.net.Uri; import android.net.Uri;
import android.os.Build; import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
@ -54,6 +53,7 @@ import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.Toolbar; import androidx.appcompat.widget.Toolbar;
import androidx.coordinatorlayout.widget.CoordinatorLayout; import androidx.coordinatorlayout.widget.CoordinatorLayout;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.preference.PreferenceManager; import androidx.preference.PreferenceManager;
import com.google.android.exoplayer2.PlaybackException; import com.google.android.exoplayer2.PlaybackException;
@ -71,8 +71,10 @@ import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.ReCaptchaActivity; import org.schabi.newpipe.error.ReCaptchaActivity;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.Image;
import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.AudioStream;
@ -83,11 +85,13 @@ import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.fragments.BackPressable; import org.schabi.newpipe.fragments.BackPressable;
import org.schabi.newpipe.fragments.BaseStateFragment; import org.schabi.newpipe.fragments.BaseStateFragment;
import org.schabi.newpipe.fragments.EmptyFragment; import org.schabi.newpipe.fragments.EmptyFragment;
import org.schabi.newpipe.fragments.MainFragment;
import org.schabi.newpipe.fragments.list.comments.CommentsFragment; import org.schabi.newpipe.fragments.list.comments.CommentsFragment;
import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment; import org.schabi.newpipe.fragments.list.videos.RelatedItemsFragment;
import org.schabi.newpipe.ktx.AnimationType; import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.PlayerService; import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.player.PlayerType;
@ -109,10 +113,11 @@ import org.schabi.newpipe.util.ListHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PermissionHelper; import org.schabi.newpipe.util.PermissionHelper;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.StreamTypeUtil; import org.schabi.newpipe.util.StreamTypeUtil;
import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.util.PlayButtonHelper;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Iterator; import java.util.Iterator;
@ -471,10 +476,23 @@ public final class VideoDetailFragment
binding.detailControlsBackground.setOnClickListener(v -> openBackgroundPlayer(false)); binding.detailControlsBackground.setOnClickListener(v -> openBackgroundPlayer(false));
binding.detailControlsPopup.setOnClickListener(v -> openPopupPlayer(false)); binding.detailControlsPopup.setOnClickListener(v -> openPopupPlayer(false));
binding.detailControlsPlaylistAppend.setOnClickListener(makeOnClickListener(info -> binding.detailControlsPlaylistAppend.setOnClickListener(makeOnClickListener(info -> {
if (getFM() != null && currentInfo != null) {
final Fragment fragment = getParentFragmentManager().
findFragmentById(R.id.fragment_holder);
// commit previous pending changes to database
if (fragment instanceof LocalPlaylistFragment) {
((LocalPlaylistFragment) fragment).commitChanges();
} else if (fragment instanceof MainFragment) {
((MainFragment) fragment).commitPlaylistTabs();
}
disposables.add(PlaylistDialog.createCorrespondingDialog(requireContext(), disposables.add(PlaylistDialog.createCorrespondingDialog(requireContext(),
List.of(new StreamEntity(info)), List.of(new StreamEntity(info)),
dialog -> dialog.show(getParentFragmentManager(), TAG))))); dialog -> dialog.show(getParentFragmentManager(), TAG)));
}
}));
binding.detailControlsDownload.setOnClickListener(v -> { binding.detailControlsDownload.setOnClickListener(v -> {
if (PermissionHelper.checkStoragePermissions(activity, if (PermissionHelper.checkStoragePermissions(activity,
PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) { PermissionHelper.DOWNLOAD_DIALOG_REQUEST_CODE)) {
@ -483,7 +501,7 @@ public final class VideoDetailFragment
}); });
binding.detailControlsShare.setOnClickListener(makeOnClickListener(info -> binding.detailControlsShare.setOnClickListener(makeOnClickListener(info ->
ShareUtils.shareText(requireContext(), info.getName(), info.getUrl(), ShareUtils.shareText(requireContext(), info.getName(), info.getUrl(),
info.getThumbnailUrl()))); info.getThumbnails())));
binding.detailControlsOpenInBrowser.setOnClickListener(makeOnClickListener(info -> binding.detailControlsOpenInBrowser.setOnClickListener(makeOnClickListener(info ->
ShareUtils.openUrlInBrowser(requireContext(), info.getUrl()))); ShareUtils.openUrlInBrowser(requireContext(), info.getUrl())));
binding.detailControlsPlayWithKodi.setOnClickListener(makeOnClickListener(info -> binding.detailControlsPlayWithKodi.setOnClickListener(makeOnClickListener(info ->
@ -536,9 +554,11 @@ public final class VideoDetailFragment
})); }));
binding.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(info -> binding.detailControlsBackground.setOnLongClickListener(makeOnLongClickListener(info ->
openBackgroundPlayer(true))); openBackgroundPlayer(true)
));
binding.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(info -> binding.detailControlsPopup.setOnLongClickListener(makeOnLongClickListener(info ->
openPopupPlayer(true))); openPopupPlayer(true)
));
binding.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(info -> binding.detailControlsDownload.setOnLongClickListener(makeOnLongClickListener(info ->
NavigationHelper.openDownloads(activity))); NavigationHelper.openDownloads(activity)));
@ -621,8 +641,7 @@ public final class VideoDetailFragment
final View.OnTouchListener controlsTouchListener = (view, motionEvent) -> { final View.OnTouchListener controlsTouchListener = (view, motionEvent) -> {
if (motionEvent.getAction() == MotionEvent.ACTION_DOWN if (motionEvent.getAction() == MotionEvent.ACTION_DOWN
&& PreferenceManager.getDefaultSharedPreferences(activity) && PlayButtonHelper.shouldShowHoldToAppendTip(activity)) {
.getBoolean(getString(R.string.show_hold_to_append_key), true)) {
animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, 0, () -> animate(binding.touchAppendDetail, true, 250, AnimationType.ALPHA, 0, () ->
animate(binding.touchAppendDetail, false, 1500, AnimationType.ALPHA, 1000)); animate(binding.touchAppendDetail, false, 1500, AnimationType.ALPHA, 1000));
@ -722,7 +741,7 @@ public final class VideoDetailFragment
final boolean isPlayerStopped = !isPlayerAvailable() || player.isStopped(); final boolean isPlayerStopped = !isPlayerAvailable() || player.isStopped();
if (playQueueItem != null && isPlayerStopped) { if (playQueueItem != null && isPlayerStopped) {
updateOverlayData(playQueueItem.getTitle(), updateOverlayData(playQueueItem.getTitle(),
playQueueItem.getUploader(), playQueueItem.getThumbnailUrl()); playQueueItem.getUploader(), playQueueItem.getThumbnails());
} }
} }
@ -995,6 +1014,20 @@ public final class VideoDetailFragment
updateTabLayoutVisibility(); updateTabLayoutVisibility();
} }
public void scrollToComment(final CommentsInfoItem comment) {
final int commentsTabPos = pageAdapter.getItemPositionByTitle(COMMENTS_TAB_TAG);
final Fragment fragment = pageAdapter.getItem(commentsTabPos);
if (!(fragment instanceof CommentsFragment)) {
return;
}
// unexpand the app bar only if scrolling to the comment succeeded
if (((CommentsFragment) fragment).scrollToComment(comment)) {
binding.appBarLayout.setExpanded(false, false);
binding.viewPager.setCurrentItem(commentsTabPos, false);
}
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Play Utils // Play Utils
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@ -1464,11 +1497,6 @@ public final class VideoDetailFragment
displayUploaderAsSubChannel(info); displayUploaderAsSubChannel(info);
} }
final Drawable buddyDrawable =
AppCompatResources.getDrawable(activity, R.drawable.placeholder_person);
binding.detailSubChannelThumbnailView.setImageDrawable(buddyDrawable);
binding.detailUploaderThumbnailView.setImageDrawable(buddyDrawable);
if (info.getViewCount() >= 0) { if (info.getViewCount() >= 0) {
if (info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) { if (info.getStreamType().equals(StreamType.AUDIO_LIVE_STREAM)) {
binding.detailViewCountView.setText(Localization.listeningCount(activity, binding.detailViewCountView.setText(Localization.listeningCount(activity,
@ -1555,13 +1583,13 @@ public final class VideoDetailFragment
binding.detailSecondaryControlPanel.setVisibility(View.GONE); binding.detailSecondaryControlPanel.setVisibility(View.GONE);
checkUpdateProgressInfo(info); checkUpdateProgressInfo(info);
PicassoHelper.loadDetailsThumbnail(info.getThumbnailUrl()).tag(PICASSO_VIDEO_DETAILS_TAG) PicassoHelper.loadDetailsThumbnail(info.getThumbnails()).tag(PICASSO_VIDEO_DETAILS_TAG)
.into(binding.detailThumbnailImageView); .into(binding.detailThumbnailImageView);
showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView, showMetaInfoInTextView(info.getMetaInfo(), binding.detailMetaInfoTextView,
binding.detailMetaInfoSeparator, disposables); binding.detailMetaInfoSeparator, disposables);
if (!isPlayerAvailable() || player.isStopped()) { if (!isPlayerAvailable() || player.isStopped()) {
updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnailUrl()); updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails());
} }
if (!info.getErrors().isEmpty()) { if (!info.getErrors().isEmpty()) {
@ -1606,7 +1634,7 @@ public final class VideoDetailFragment
binding.detailUploaderTextView.setVisibility(View.GONE); binding.detailUploaderTextView.setVisibility(View.GONE);
} }
PicassoHelper.loadAvatar(info.getUploaderAvatarUrl()).tag(PICASSO_VIDEO_DETAILS_TAG) PicassoHelper.loadAvatar(info.getUploaderAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG)
.into(binding.detailSubChannelThumbnailView); .into(binding.detailSubChannelThumbnailView);
binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE);
binding.detailUploaderThumbnailView.setVisibility(View.GONE); binding.detailUploaderThumbnailView.setVisibility(View.GONE);
@ -1638,10 +1666,10 @@ public final class VideoDetailFragment
binding.detailUploaderTextView.setVisibility(View.GONE); binding.detailUploaderTextView.setVisibility(View.GONE);
} }
PicassoHelper.loadAvatar(info.getSubChannelAvatarUrl()).tag(PICASSO_VIDEO_DETAILS_TAG) PicassoHelper.loadAvatar(info.getSubChannelAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG)
.into(binding.detailSubChannelThumbnailView); .into(binding.detailSubChannelThumbnailView);
binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE); binding.detailSubChannelThumbnailView.setVisibility(View.VISIBLE);
PicassoHelper.loadAvatar(info.getUploaderAvatarUrl()).tag(PICASSO_VIDEO_DETAILS_TAG) PicassoHelper.loadAvatar(info.getUploaderAvatars()).tag(PICASSO_VIDEO_DETAILS_TAG)
.into(binding.detailUploaderThumbnailView); .into(binding.detailUploaderThumbnailView);
binding.detailUploaderThumbnailView.setVisibility(View.VISIBLE); binding.detailUploaderThumbnailView.setVisibility(View.VISIBLE);
} }
@ -1816,7 +1844,7 @@ public final class VideoDetailFragment
return; return;
} }
updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnailUrl()); updateOverlayData(info.getName(), info.getUploaderName(), info.getThumbnails());
if (currentInfo != null && info.getUrl().equals(currentInfo.getUrl())) { if (currentInfo != null && info.getUrl().equals(currentInfo.getUrl())) {
return; return;
} }
@ -1845,7 +1873,7 @@ public final class VideoDetailFragment
if (currentInfo != null) { if (currentInfo != null) {
updateOverlayData(currentInfo.getName(), updateOverlayData(currentInfo.getName(),
currentInfo.getUploaderName(), currentInfo.getUploaderName(),
currentInfo.getThumbnailUrl()); currentInfo.getThumbnails());
} }
updateOverlayPlayQueueButtonVisibility(); updateOverlayPlayQueueButtonVisibility();
} }
@ -2210,7 +2238,7 @@ public final class VideoDetailFragment
playerHolder.stopService(); playerHolder.stopService();
setInitialData(0, null, "", null); setInitialData(0, null, "", null);
currentInfo = null; currentInfo = null;
updateOverlayData(null, null, null); updateOverlayData(null, null, List.of());
} }
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
@ -2392,11 +2420,11 @@ public final class VideoDetailFragment
private void updateOverlayData(@Nullable final String overlayTitle, private void updateOverlayData(@Nullable final String overlayTitle,
@Nullable final String uploader, @Nullable final String uploader,
@Nullable final String thumbnailUrl) { @NonNull final List<Image> thumbnails) {
binding.overlayTitleTextView.setText(isEmpty(overlayTitle) ? "" : overlayTitle); binding.overlayTitleTextView.setText(isEmpty(overlayTitle) ? "" : overlayTitle);
binding.overlayChannelTextView.setText(isEmpty(uploader) ? "" : uploader); binding.overlayChannelTextView.setText(isEmpty(uploader) ? "" : uploader);
binding.overlayThumbnail.setImageDrawable(null); binding.overlayThumbnail.setImageDrawable(null);
PicassoHelper.loadDetailsThumbnail(thumbnailUrl).tag(PICASSO_VIDEO_DETAILS_TAG) PicassoHelper.loadDetailsThumbnail(thumbnails).tag(PICASSO_VIDEO_DETAILS_TAG)
.into(binding.overlayThumbnail); .into(binding.overlayThumbnail);
} }

View file

@ -1,5 +1,7 @@
package org.schabi.newpipe.fragments.list; package org.schabi.newpipe.fragments.list;
import static org.schabi.newpipe.extractor.ServiceList.SoundCloud;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
@ -7,13 +9,13 @@ import android.view.View;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.ListInfo;
import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.Page;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
import org.schabi.newpipe.util.Constants; import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.views.NewPipeRecyclerView; import org.schabi.newpipe.views.NewPipeRecyclerView;
@ -229,13 +231,11 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
if (!result.getRelatedItems().isEmpty()) { if (!result.getRelatedItems().isEmpty()) {
infoListAdapter.addInfoItemList(result.getRelatedItems()); infoListAdapter.addInfoItemList(result.getRelatedItems());
showListFooter(hasMoreItems()); showListFooter(hasMoreItems());
} else if (hasMoreItems()) {
loadMoreItems();
} else { } else {
infoListAdapter.clearStreamItemList(); infoListAdapter.clearStreamItemList();
// showEmptyState should be called only if there is no item as showEmptyState();
// well as no header in infoListAdapter
if (!(result instanceof ChannelInfo && infoListAdapter.getItemCount() == 1)) {
showEmptyState();
}
} }
} }
@ -252,6 +252,20 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
} }
} }
@Override
public void showEmptyState() {
// show "no streams" for SoundCloud; otherwise "no videos"
// showing "no live streams" is handled in KioskFragment
if (emptyStateView != null) {
if (currentInfo.getService() == SoundCloud) {
setEmptyStateMessage(R.string.no_streams);
} else {
setEmptyStateMessage(R.string.no_videos);
}
}
super.showEmptyState();
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Utils // Utils
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/

View file

@ -0,0 +1,107 @@
package org.schabi.newpipe.fragments.list.channel;
import static org.schabi.newpipe.extractor.stream.StreamExtractor.UNKNOWN_SUBSCRIBER_COUNT;
import android.content.Context;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.widget.LinearLayout;
import androidx.annotation.Nullable;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.fragments.detail.BaseDescriptionFragment;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.Localization;
import java.util.List;
import icepick.State;
public class ChannelAboutFragment extends BaseDescriptionFragment {
@State
protected ChannelInfo channelInfo;
public static ChannelAboutFragment getInstance(final ChannelInfo channelInfo) {
final ChannelAboutFragment fragment = new ChannelAboutFragment();
fragment.channelInfo = channelInfo;
return fragment;
}
public ChannelAboutFragment() {
super();
}
@Override
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
binding.constraintLayout.setPadding(0, DeviceUtils.dpToPx(8, requireContext()), 0, 0);
}
@Nullable
@Override
protected Description getDescription() {
if (channelInfo == null) {
return null;
}
return new Description(channelInfo.getDescription(), Description.PLAIN_TEXT);
}
@Nullable
@Override
protected StreamingService getService() {
if (channelInfo == null) {
return null;
}
return channelInfo.getService();
}
@Override
protected int getServiceId() {
if (channelInfo == null) {
return -1;
}
return channelInfo.getServiceId();
}
@Nullable
@Override
protected String getStreamUrl() {
return null;
}
@Nullable
@Override
public List<String> getTags() {
if (channelInfo == null) {
return null;
}
return channelInfo.getTags();
}
@Override
protected void setupMetadata(final LayoutInflater inflater,
final LinearLayout layout) {
// There is no upload date available for channels, so hide the relevant UI element
binding.detailUploadDateView.setVisibility(View.GONE);
if (channelInfo == null) {
return;
}
final Context context = getContext();
if (channelInfo.getSubscriberCount() != UNKNOWN_SUBSCRIBER_COUNT) {
addMetadataItem(inflater, layout, false, R.string.metadata_subscribers,
Localization.localizeNumber(context, channelInfo.getSubscriberCount()));
}
addImagesMetadataItem(inflater, layout, R.string.metadata_avatars,
channelInfo.getAvatars());
addImagesMetadataItem(inflater, layout, R.string.metadata_banners,
channelInfo.getBanners());
}
}

View file

@ -5,6 +5,7 @@ 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 android.content.Context; import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Color; import android.graphics.Color;
import android.os.Bundle; import android.os.Bundle;
import android.text.TextUtils; import android.text.TextUtils;
@ -16,51 +17,50 @@ import android.view.MenuInflater;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.Button;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.core.content.ContextCompat; import androidx.core.content.ContextCompat;
import androidx.core.graphics.ColorUtils;
import androidx.preference.PreferenceManager;
import com.google.android.material.snackbar.Snackbar; import com.google.android.material.snackbar.Snackbar;
import com.google.android.material.tabs.TabLayout;
import com.jakewharton.rxbinding4.view.RxView; import com.jakewharton.rxbinding4.view.RxView;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.NotificationMode; import org.schabi.newpipe.database.subscription.NotificationMode;
import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.databinding.ChannelHeaderBinding;
import org.schabi.newpipe.databinding.FragmentChannelBinding; import org.schabi.newpipe.databinding.FragmentChannelBinding;
import org.schabi.newpipe.databinding.PlaylistControlBinding;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException; import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.fragments.BaseStateFragment;
import org.schabi.newpipe.fragments.detail.TabAdapter;
import org.schabi.newpipe.ktx.AnimationType; import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.local.subscription.SubscriptionManager;
import org.schabi.newpipe.local.feed.notifications.NotificationHelper; import org.schabi.newpipe.local.feed.notifications.NotificationHelper;
import org.schabi.newpipe.player.PlayerType; import org.schabi.newpipe.local.subscription.SubscriptionManager;
import org.schabi.newpipe.player.playqueue.ChannelPlayQueue; import org.schabi.newpipe.util.ChannelTabHelper;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.StateSaver;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
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 java.util.List; import java.util.List;
import java.util.Queue;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import icepick.State;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers; import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable; import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.functions.Action; import io.reactivex.rxjava3.functions.Action;
@ -68,29 +68,37 @@ import io.reactivex.rxjava3.functions.Consumer;
import io.reactivex.rxjava3.functions.Function; import io.reactivex.rxjava3.functions.Function;
import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.schedulers.Schedulers;
public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, ChannelInfo> public class ChannelFragment extends BaseStateFragment<ChannelInfo>
implements View.OnClickListener { implements StateSaver.WriteRead {
private static final int BUTTON_DEBOUNCE_INTERVAL = 100; private static final int BUTTON_DEBOUNCE_INTERVAL = 100;
private static final String PICASSO_CHANNEL_TAG = "PICASSO_CHANNEL_TAG"; private static final String PICASSO_CHANNEL_TAG = "PICASSO_CHANNEL_TAG";
@State
protected int serviceId = Constants.NO_SERVICE_ID;
@State
protected String name;
@State
protected String url;
private ChannelInfo currentInfo;
private Disposable currentWorker;
private final CompositeDisposable disposables = new CompositeDisposable(); private final CompositeDisposable disposables = new CompositeDisposable();
private Disposable subscribeButtonMonitor; private Disposable subscribeButtonMonitor;
private SubscriptionManager subscriptionManager;
private int lastTab;
private boolean channelContentNotSupported = false; private boolean channelContentNotSupported = false;
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Views // Views
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
private SubscriptionManager subscriptionManager; private FragmentChannelBinding binding;
private TabAdapter tabAdapter;
private FragmentChannelBinding channelBinding;
private ChannelHeaderBinding headerBinding;
private PlaylistControlBinding playlistControlBinding;
private MenuItem menuRssButton; private MenuItem menuRssButton;
private MenuItem menuNotifyButton; private MenuItem menuNotifyButton;
private SubscriptionEntity channelSubscription;
public static ChannelFragment getInstance(final int serviceId, final String url, public static ChannelFragment getInstance(final int serviceId, final String url,
final String name) { final String name) {
@ -99,22 +107,23 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
return instance; return instance;
} }
public ChannelFragment() { private void setInitialData(final int sid, final String u, final String title) {
super(UserAction.REQUESTED_CHANNEL); this.serviceId = sid;
this.url = u;
this.name = !TextUtils.isEmpty(title) ? title : "";
} }
@Override
public void onResume() {
super.onResume();
if (activity != null && useAsFrontPage) {
setTitle(currentInfo != null ? currentInfo.getName() : name);
}
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// LifeCycle // LifeCycle
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setHasOptionsMenu(true);
}
@Override @Override
public void onAttach(@NonNull final Context context) { public void onAttach(@NonNull final Context context) {
super.onAttach(context); super.onAttach(context);
@ -125,49 +134,58 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
public View onCreateView(@NonNull final LayoutInflater inflater, public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container, @Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) { @Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_channel, container, false); binding = FragmentChannelBinding.inflate(inflater, container, false);
return binding.getRoot();
} }
@Override @Override // called from onViewCreated in BaseFragment.onViewCreated
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) { protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.onViewCreated(rootView, savedInstanceState); super.initViews(rootView, savedInstanceState);
channelBinding = FragmentChannelBinding.bind(rootView);
showContentNotSupportedIfNeeded();
}
@Override tabAdapter = new TabAdapter(getChildFragmentManager());
public void onDestroy() { binding.viewPager.setAdapter(tabAdapter);
super.onDestroy(); binding.tabLayout.setupWithViewPager(binding.viewPager);
disposables.clear();
if (subscribeButtonMonitor != null) { setTitle(name);
subscribeButtonMonitor.dispose(); binding.channelTitleView.setText(name);
if (!ImageStrategy.shouldLoadImages()) {
// do not waste space for the banner if it is not going to be loaded
binding.channelBannerImage.setImageDrawable(null);
} }
channelBinding = null;
headerBinding = null;
playlistControlBinding = null;
}
/*//////////////////////////////////////////////////////////////////////////
// Init
//////////////////////////////////////////////////////////////////////////*/
@Override
protected Supplier<View> getListHeaderSupplier() {
headerBinding = ChannelHeaderBinding
.inflate(activity.getLayoutInflater(), itemsList, false);
playlistControlBinding = headerBinding.playlistControl;
return headerBinding::getRoot;
} }
@Override @Override
protected void initListeners() { protected void initListeners() {
super.initListeners(); super.initListeners();
headerBinding.subChannelTitleView.setOnClickListener(this); final View.OnClickListener openSubChannel = v -> {
headerBinding.subChannelAvatarView.setOnClickListener(this); if (!TextUtils.isEmpty(currentInfo.getParentChannelUrl())) {
try {
NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(),
currentInfo.getParentChannelUrl(),
currentInfo.getParentChannelName());
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e);
}
} else if (DEBUG) {
Log.i(TAG, "Can't open parent channel because we got no channel URL");
}
};
binding.subChannelAvatarView.setOnClickListener(openSubChannel);
binding.subChannelTitleView.setOnClickListener(openSubChannel);
} }
@Override
public void onDestroy() {
super.onDestroy();
if (currentWorker != null) {
currentWorker.dispose();
}
disposables.clear();
binding = null;
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Menu // Menu
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@ -176,32 +194,33 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
public void onCreateOptionsMenu(@NonNull final Menu menu, public void onCreateOptionsMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) { @NonNull final MenuInflater inflater) {
super.onCreateOptionsMenu(menu, inflater); super.onCreateOptionsMenu(menu, inflater);
final ActionBar supportActionBar = activity.getSupportActionBar(); inflater.inflate(R.menu.menu_channel, menu);
if (useAsFrontPage && supportActionBar != null) {
supportActionBar.setDisplayHomeAsUpEnabled(false);
} else {
inflater.inflate(R.menu.menu_channel, menu);
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "onCreateOptionsMenu() called with: " Log.d(TAG, "onCreateOptionsMenu() called with: "
+ "menu = [" + menu + "], inflater = [" + inflater + "]"); + "menu = [" + menu + "], inflater = [" + inflater + "]");
}
menuRssButton = menu.findItem(R.id.menu_item_rss);
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
} }
} }
@Override @Override
public boolean onOptionsItemSelected(final MenuItem item) { public void onPrepareOptionsMenu(@NonNull final Menu menu) {
super.onPrepareOptionsMenu(menu);
menuRssButton = menu.findItem(R.id.menu_item_rss);
menuNotifyButton = menu.findItem(R.id.menu_item_notify);
updateNotifyButton(channelSubscription);
}
@Override
public boolean onOptionsItemSelected(@NonNull final MenuItem item) {
switch (item.getItemId()) { switch (item.getItemId()) {
case R.id.action_settings:
NavigationHelper.openSettings(requireContext());
break;
case R.id.menu_item_notify: case R.id.menu_item_notify:
final boolean value = !item.isChecked(); final boolean value = !item.isChecked();
item.setEnabled(false); item.setEnabled(false);
setNotify(value); setNotify(value);
break; break;
case R.id.action_settings:
NavigationHelper.openSettings(requireContext());
break;
case R.id.menu_item_rss: case R.id.menu_item_rss:
if (currentInfo != null) { if (currentInfo != null) {
ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl()); ShareUtils.openUrlInApp(requireContext(), currentInfo.getFeedUrl());
@ -215,7 +234,7 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
case R.id.menu_item_share: case R.id.menu_item_share:
if (currentInfo != null) { if (currentInfo != null) {
ShareUtils.shareText(requireContext(), name, currentInfo.getOriginalUrl(), ShareUtils.shareText(requireContext(), name, currentInfo.getOriginalUrl(),
currentInfo.getAvatarUrl()); currentInfo.getAvatars());
} }
break; break;
default: default:
@ -224,13 +243,14 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
return true; return true;
} }
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Channel Subscription // Channel Subscription
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
private void monitorSubscription(final ChannelInfo info) { private void monitorSubscription(final ChannelInfo info) {
final Consumer<Throwable> onError = (Throwable throwable) -> { final Consumer<Throwable> onError = (Throwable throwable) -> {
animate(headerBinding.channelSubscribeButton, false, 100); animate(binding.channelSubscribeButton, false, 100);
showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_GET, showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_GET,
"Get subscription status", currentInfo)); "Get subscription status", currentInfo));
}; };
@ -263,10 +283,9 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
}, onError)); }, onError));
} }
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription, private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription) {
final ChannelInfo info) {
return (@NonNull Object o) -> { return (@NonNull Object o) -> {
subscriptionManager.insertSubscription(subscription, info); subscriptionManager.insertSubscription(subscription);
return o; return o;
}; };
} }
@ -298,8 +317,7 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
.subscribe(onComplete, onError)); .subscribe(onComplete, onError));
} }
private Disposable monitorSubscribeButton(final Button subscribeButton, private Disposable monitorSubscribeButton(final Function<Object, Object> action) {
final Function<Object, Object> action) {
final Consumer<Object> onNext = (@NonNull Object o) -> { final Consumer<Object> onNext = (@NonNull Object o) -> {
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "Changed subscription status to this channel!"); Log.d(TAG, "Changed subscription status to this channel!");
@ -311,7 +329,7 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
"Changing subscription for " + currentInfo.getUrl(), currentInfo)); "Changing subscription for " + currentInfo.getUrl(), currentInfo));
/* Emit clicks from main thread unto io thread */ /* Emit clicks from main thread unto io thread */
return RxView.clicks(subscribeButton) return RxView.clicks(binding.channelSubscribeButton)
.subscribeOn(AndroidSchedulers.mainThread()) .subscribeOn(AndroidSchedulers.mainThread())
.observeOn(Schedulers.io()) .observeOn(Schedulers.io())
.debounce(BUTTON_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS) // Ignore rapid clicks .debounce(BUTTON_DEBOUNCE_INTERVAL, TimeUnit.MILLISECONDS) // Ignore rapid clicks
@ -337,20 +355,20 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
channel.setServiceId(info.getServiceId()); channel.setServiceId(info.getServiceId());
channel.setUrl(info.getUrl()); channel.setUrl(info.getUrl());
channel.setData(info.getName(), channel.setData(info.getName(),
info.getAvatarUrl(), ImageStrategy.imageListToDbUrl(info.getAvatars()),
info.getDescription(), info.getDescription(),
info.getSubscriberCount()); info.getSubscriberCount());
channelSubscription = null;
updateNotifyButton(null); updateNotifyButton(null);
subscribeButtonMonitor = monitorSubscribeButton( subscribeButtonMonitor = monitorSubscribeButton(mapOnSubscribe(channel));
headerBinding.channelSubscribeButton, mapOnSubscribe(channel, info));
} else { } else {
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "Found subscription to this channel!"); Log.d(TAG, "Found subscription to this channel!");
} }
final SubscriptionEntity subscription = subscriptionEntities.get(0); channelSubscription = subscriptionEntities.get(0);
updateNotifyButton(subscription); updateNotifyButton(channelSubscription);
subscribeButtonMonitor = monitorSubscribeButton( subscribeButtonMonitor =
headerBinding.channelSubscribeButton, mapOnUnsubscribe(subscription)); monitorSubscribeButton(mapOnUnsubscribe(channelSubscription));
} }
}; };
} }
@ -361,34 +379,33 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
+ "isSubscribed = [" + isSubscribed + "]"); + "isSubscribed = [" + isSubscribed + "]");
} }
final boolean isButtonVisible = headerBinding.channelSubscribeButton.getVisibility() final boolean isButtonVisible = binding.channelSubscribeButton.getVisibility()
== View.VISIBLE; == View.VISIBLE;
final int backgroundDuration = isButtonVisible ? 300 : 0; final int backgroundDuration = isButtonVisible ? 300 : 0;
final int textDuration = isButtonVisible ? 200 : 0; final int textDuration = isButtonVisible ? 200 : 0;
final int subscribeBackground = ThemeHelper
.resolveColorFromAttr(activity, R.attr.colorPrimary);
final int subscribeText = ContextCompat.getColor(activity, R.color.subscribe_text_color);
final int subscribedBackground = ContextCompat final int subscribedBackground = ContextCompat
.getColor(activity, R.color.subscribed_background_color); .getColor(activity, R.color.subscribed_background_color);
final int subscribedText = ContextCompat.getColor(activity, R.color.subscribed_text_color); final int subscribedText = ContextCompat.getColor(activity, R.color.subscribed_text_color);
final int subscribeBackground = ColorUtils.blendARGB(ThemeHelper
.resolveColorFromAttr(activity, R.attr.colorPrimary), subscribedBackground, 0.35f);
final int subscribeText = ContextCompat.getColor(activity, R.color.subscribe_text_color);
if (!isSubscribed) { if (isSubscribed) {
headerBinding.channelSubscribeButton.setText(R.string.subscribe_button_title); binding.channelSubscribeButton.setText(R.string.subscribed_button_title);
animateBackgroundColor(headerBinding.channelSubscribeButton, backgroundDuration, animateBackgroundColor(binding.channelSubscribeButton, backgroundDuration,
subscribedBackground, subscribeBackground);
animateTextColor(headerBinding.channelSubscribeButton, textDuration, subscribedText,
subscribeText);
} else {
headerBinding.channelSubscribeButton.setText(R.string.subscribed_button_title);
animateBackgroundColor(headerBinding.channelSubscribeButton, backgroundDuration,
subscribeBackground, subscribedBackground); subscribeBackground, subscribedBackground);
animateTextColor(headerBinding.channelSubscribeButton, textDuration, subscribeText, animateTextColor(binding.channelSubscribeButton, textDuration, subscribeText,
subscribedText); subscribedText);
} else {
binding.channelSubscribeButton.setText(R.string.subscribe_button_title);
animateBackgroundColor(binding.channelSubscribeButton, backgroundDuration,
subscribedBackground, subscribeBackground);
animateTextColor(binding.channelSubscribeButton, textDuration, subscribedText,
subscribeText);
} }
animate(headerBinding.channelSubscribeButton, true, 100, animate(binding.channelSubscribeButton, true, 100, AnimationType.LIGHT_SCALE_AND_ALPHA);
AnimationType.LIGHT_SCALE_AND_ALPHA);
} }
private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) { private void updateNotifyButton(@Nullable final SubscriptionEntity subscription) {
@ -424,108 +441,179 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
* Show a snackbar with the option to enable notifications on new streams for this channel. * Show a snackbar with the option to enable notifications on new streams for this channel.
*/ */
private void showNotifySnackbar() { private void showNotifySnackbar() {
Snackbar.make(itemsList, R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG) Snackbar.make(binding.getRoot(), R.string.you_successfully_subscribed, Snackbar.LENGTH_LONG)
.setAction(R.string.get_notified, v -> setNotify(true)) .setAction(R.string.get_notified, v -> setNotify(true))
.setActionTextColor(Color.YELLOW) .setActionTextColor(Color.YELLOW)
.show(); .show();
} }
/*//////////////////////////////////////////////////////////////////////////
// Load and handle
//////////////////////////////////////////////////////////////////////////*/
@Override
protected Single<ListExtractor.InfoItemsPage<StreamInfoItem>> loadMoreItemsLogic() {
return ExtractorHelper.getMoreChannelItems(serviceId, url, currentNextPage);
}
@Override
protected Single<ChannelInfo> loadResult(final boolean forceLoad) {
return ExtractorHelper.getChannelInfo(serviceId, url, forceLoad);
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// OnClick // Init
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override private void updateTabs() {
public void onClick(final View v) { tabAdapter.clearAllItems();
if (isLoading.get() || currentInfo == null) {
return;
}
switch (v.getId()) { if (currentInfo != null && !channelContentNotSupported) {
case R.id.sub_channel_avatar_view: final Context context = requireContext();
case R.id.sub_channel_title_view: final SharedPreferences preferences = PreferenceManager
if (!TextUtils.isEmpty(currentInfo.getParentChannelUrl())) { .getDefaultSharedPreferences(context);
try {
NavigationHelper.openChannelFragment(getFM(), currentInfo.getServiceId(), for (final ListLinkHandler linkHandler : currentInfo.getTabs()) {
currentInfo.getParentChannelUrl(), final String tab = linkHandler.getContentFilters().get(0);
currentInfo.getParentChannelName()); if (ChannelTabHelper.showChannelTab(context, preferences, tab)) {
} catch (final Exception e) { final ChannelTabFragment channelTabFragment =
ErrorUtil.showUiErrorSnackbar(this, "Opening channel fragment", e); ChannelTabFragment.getInstance(serviceId, linkHandler, name);
} channelTabFragment.useAsFrontPage(useAsFrontPage);
} else if (DEBUG) { tabAdapter.addFragment(channelTabFragment,
Log.i(TAG, "Can't open parent channel because we got no channel URL"); context.getString(ChannelTabHelper.getTranslationKey(tab)));
} }
break; }
if (ChannelTabHelper.showChannelTab(
context, preferences, R.string.show_channel_tabs_about)) {
tabAdapter.addFragment(
ChannelAboutFragment.getInstance(currentInfo),
context.getString(R.string.channel_tab_about));
}
}
tabAdapter.notifyDataSetUpdate();
for (int i = 0; i < tabAdapter.getCount(); i++) {
binding.tabLayout.getTabAt(i).setText(tabAdapter.getItemTitle(i));
}
// Restore previously selected tab
final TabLayout.Tab ltab = binding.tabLayout.getTabAt(lastTab);
if (ltab != null) {
binding.tabLayout.selectTab(ltab);
} }
} }
/*//////////////////////////////////////////////////////////////////////////
// State Saving
//////////////////////////////////////////////////////////////////////////*/
@Override
public String generateSuffix() {
return null;
}
@Override
public void writeTo(final Queue<Object> objectsToSave) {
objectsToSave.add(currentInfo);
objectsToSave.add(binding == null ? 0 : binding.tabLayout.getSelectedTabPosition());
}
@Override
public void readFrom(@NonNull final Queue<Object> savedObjects) {
currentInfo = (ChannelInfo) savedObjects.poll();
lastTab = (Integer) savedObjects.poll();
}
@Override
public void onSaveInstanceState(final @NonNull Bundle outState) {
super.onSaveInstanceState(outState);
if (binding != null) {
outState.putInt("LastTab", binding.tabLayout.getSelectedTabPosition());
}
}
@Override
protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) {
super.onRestoreInstanceState(savedInstanceState);
lastTab = savedInstanceState.getInt("LastTab", 0);
}
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Contract // Contract
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override
protected void doInitialLoadLogic() {
if (currentInfo == null) {
startLoading(false);
} else {
handleResult(currentInfo);
}
}
@Override
public void startLoading(final boolean forceLoad) {
super.startLoading(forceLoad);
currentInfo = null;
updateTabs();
if (currentWorker != null) {
currentWorker.dispose();
}
runWorker(forceLoad);
}
private void runWorker(final boolean forceLoad) {
currentWorker = ExtractorHelper.getChannelInfo(serviceId, url, forceLoad)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(result -> {
isLoading.set(false);
handleResult(result);
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_CHANNEL,
url == null ? "No URL" : url, serviceId)));
}
@Override @Override
public void showLoading() { public void showLoading() {
super.showLoading(); super.showLoading();
PicassoHelper.cancelTag(PICASSO_CHANNEL_TAG); PicassoHelper.cancelTag(PICASSO_CHANNEL_TAG);
animate(headerBinding.channelSubscribeButton, false, 100); animate(binding.channelSubscribeButton, false, 100);
} }
@Override @Override
public void handleResult(@NonNull final ChannelInfo result) { public void handleResult(@NonNull final ChannelInfo result) {
super.handleResult(result); super.handleResult(result);
currentInfo = result;
setInitialData(result.getServiceId(), result.getOriginalUrl(), result.getName());
headerBinding.getRoot().setVisibility(View.VISIBLE); if (ImageStrategy.shouldLoadImages() && !result.getBanners().isEmpty()) {
PicassoHelper.loadBanner(result.getBannerUrl()).tag(PICASSO_CHANNEL_TAG) PicassoHelper.loadBanner(result.getBanners()).tag(PICASSO_CHANNEL_TAG)
.into(headerBinding.channelBannerImage); .into(binding.channelBannerImage);
PicassoHelper.loadAvatar(result.getAvatarUrl()).tag(PICASSO_CHANNEL_TAG) } else {
.into(headerBinding.channelAvatarView); // do not waste space for the banner, if the user disabled images or there is not one
PicassoHelper.loadAvatar(result.getParentChannelAvatarUrl()).tag(PICASSO_CHANNEL_TAG) binding.channelBannerImage.setImageDrawable(null);
.into(headerBinding.subChannelAvatarView); }
headerBinding.channelSubscriberView.setVisibility(View.VISIBLE); PicassoHelper.loadAvatar(result.getAvatars()).tag(PICASSO_CHANNEL_TAG)
.into(binding.channelAvatarView);
PicassoHelper.loadAvatar(result.getParentChannelAvatars()).tag(PICASSO_CHANNEL_TAG)
.into(binding.subChannelAvatarView);
binding.channelTitleView.setText(result.getName());
binding.channelSubscriberView.setVisibility(View.VISIBLE);
if (result.getSubscriberCount() >= 0) { if (result.getSubscriberCount() >= 0) {
headerBinding.channelSubscriberView.setText(Localization binding.channelSubscriberView.setText(Localization
.shortSubscriberCount(activity, result.getSubscriberCount())); .shortSubscriberCount(activity, result.getSubscriberCount()));
} else { } else {
headerBinding.channelSubscriberView.setText(R.string.subscribers_count_not_available); binding.channelSubscriberView.setText(R.string.subscribers_count_not_available);
} }
if (!TextUtils.isEmpty(currentInfo.getParentChannelName())) { if (!TextUtils.isEmpty(currentInfo.getParentChannelName())) {
headerBinding.subChannelTitleView.setText(String.format( binding.subChannelTitleView.setText(String.format(
getString(R.string.channel_created_by), getString(R.string.channel_created_by),
currentInfo.getParentChannelName()) currentInfo.getParentChannelName())
); );
headerBinding.subChannelTitleView.setVisibility(View.VISIBLE); binding.subChannelTitleView.setVisibility(View.VISIBLE);
headerBinding.subChannelAvatarView.setVisibility(View.VISIBLE); binding.subChannelAvatarView.setVisibility(View.VISIBLE);
} else {
headerBinding.subChannelTitleView.setVisibility(View.GONE);
} }
if (menuRssButton != null) { if (menuRssButton != null) {
menuRssButton.setVisible(!TextUtils.isEmpty(result.getFeedUrl())); menuRssButton.setVisible(!TextUtils.isEmpty(result.getFeedUrl()));
} }
// PlaylistControls should be visible only if there is some item in
// infoListAdapter other than header
if (infoListAdapter.getItemCount() != 1) {
playlistControlBinding.getRoot().setVisibility(View.VISIBLE);
} else {
playlistControlBinding.getRoot().setVisibility(View.GONE);
}
channelContentNotSupported = false; channelContentNotSupported = false;
for (final Throwable throwable : result.getErrors()) { for (final Throwable throwable : result.getErrors()) {
if (throwable instanceof ContentNotSupportedException) { if (throwable instanceof ContentNotSupportedException) {
@ -539,62 +627,21 @@ public class ChannelFragment extends BaseListInfoFragment<StreamInfoItem, Channe
if (subscribeButtonMonitor != null) { if (subscribeButtonMonitor != null) {
subscribeButtonMonitor.dispose(); subscribeButtonMonitor.dispose();
} }
updateTabs();
updateSubscription(result); updateSubscription(result);
monitorSubscription(result); monitorSubscription(result);
playlistControlBinding.playlistCtrlPlayAllButton
.setOnClickListener(view -> NavigationHelper
.playOnMainPlayer(activity, getPlayQueue()));
playlistControlBinding.playlistCtrlPlayPopupButton
.setOnClickListener(view -> NavigationHelper
.playOnPopupPlayer(activity, getPlayQueue(), false));
playlistControlBinding.playlistCtrlPlayBgButton
.setOnClickListener(view -> NavigationHelper
.playOnBackgroundPlayer(activity, getPlayQueue(), false));
playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.POPUP);
return true;
});
playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.AUDIO);
return true;
});
} }
private void showContentNotSupportedIfNeeded() { private void showContentNotSupportedIfNeeded() {
// channelBinding might not be initialized when handleResult() is called // channelBinding might not be initialized when handleResult() is called
// (e.g. after rotating the screen, #6696) // (e.g. after rotating the screen, #6696)
if (!channelContentNotSupported || channelBinding == null) { if (!channelContentNotSupported || binding == null) {
return; return;
} }
channelBinding.errorContentNotSupported.setVisibility(View.VISIBLE); binding.errorContentNotSupported.setVisibility(View.VISIBLE);
channelBinding.channelKaomoji.setText("(︶︹︺)"); binding.channelKaomoji.setText("(︶︹︺)");
channelBinding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f); binding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f);
channelBinding.channelNoVideos.setVisibility(View.GONE);
}
private PlayQueue getPlayQueue() {
final List<StreamInfoItem> streamItems = infoListAdapter.getItemsList().stream()
.filter(StreamInfoItem.class::isInstance)
.map(StreamInfoItem.class::cast)
.collect(Collectors.toList());
return new ChannelPlayQueue(currentInfo.getServiceId(), currentInfo.getUrl(),
currentInfo.getNextPage(), streamItems, 0);
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
@Override
public void setTitle(final String title) {
super.setTitle(title);
if (!useAsFrontPage) {
headerBinding.channelTitleView.setText(title);
}
} }
} }

View file

@ -0,0 +1,164 @@
package org.schabi.newpipe.fragments.list.channel;
import android.os.Bundle;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.PlaylistControlBinding;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo;
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.linkhandler.ReadyChannelTabListLinkHandler;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
import org.schabi.newpipe.player.playqueue.ChannelTabPlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.util.ChannelTabHelper;
import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.PlayButtonHelper;
import java.util.List;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import icepick.State;
import io.reactivex.rxjava3.core.Single;
public class ChannelTabFragment extends BaseListInfoFragment<InfoItem, ChannelTabInfo>
implements PlaylistControlViewHolder {
// states must be protected and not private for IcePick being able to access them
@State
protected ListLinkHandler tabHandler;
@State
protected String channelName;
private PlaylistControlBinding playlistControlBinding;
@NonNull
public static ChannelTabFragment getInstance(final int serviceId,
final ListLinkHandler tabHandler,
final String channelName) {
final ChannelTabFragment instance = new ChannelTabFragment();
instance.serviceId = serviceId;
instance.tabHandler = tabHandler;
instance.channelName = channelName;
return instance;
}
public ChannelTabFragment() {
super(UserAction.REQUESTED_CHANNEL);
}
/*//////////////////////////////////////////////////////////////////////////
// LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@Override
public void onCreate(final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setHasOptionsMenu(false);
}
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_channel_tab, container, false);
}
@Override
public void onDestroyView() {
super.onDestroyView();
playlistControlBinding = null;
}
@Override
protected Supplier<View> getListHeaderSupplier() {
if (ChannelTabHelper.isStreamsTab(tabHandler)) {
playlistControlBinding = PlaylistControlBinding
.inflate(activity.getLayoutInflater(), itemsList, false);
return playlistControlBinding::getRoot;
}
return null;
}
@Override
protected Single<ChannelTabInfo> loadResult(final boolean forceLoad) {
return ExtractorHelper.getChannelTab(serviceId, tabHandler, forceLoad);
}
@Override
protected Single<ListExtractor.InfoItemsPage<InfoItem>> loadMoreItemsLogic() {
return ExtractorHelper.getMoreChannelTabItems(serviceId, tabHandler, currentNextPage);
}
@Override
public void setTitle(final String title) {
// The channel name is displayed as title in the toolbar.
// The title is always a description of the content of the tab fragment.
// It should be unique for each channel because multiple channel tabs
// can be added to the main page. Therefore, the channel name is used.
// Using the title variable would cause the title to be the same for all channel tabs.
super.setTitle(channelName);
}
@Override
public void handleResult(@NonNull final ChannelTabInfo result) {
super.handleResult(result);
// FIXME this is a really hacky workaround, to avoid storing useless data in the fragment
// state. The problem is, `ReadyChannelTabListLinkHandler` might contain raw JSON data that
// uses a lot of memory (e.g. ~800KB for YouTube). While 800KB doesn't seem much, if
// you combine just a couple of channel tab fragments you easily go over the 1MB
// save&restore transaction limit, and get `TransactionTooLargeException`s. A proper
// solution would require rethinking about `ReadyChannelTabListLinkHandler`s.
if (tabHandler instanceof ReadyChannelTabListLinkHandler) {
try {
// once `handleResult` is called, the parsed data was already saved to cache, so
// we can discard any raw data in ReadyChannelTabListLinkHandler and create a
// link handler with identical properties, but without any raw data
tabHandler = result.getService()
.getChannelTabLHFactory()
.fromQuery(tabHandler.getId(), tabHandler.getContentFilters(),
tabHandler.getSortFilter());
} catch (final ParsingException e) {
// silently ignore the error, as the app can continue to function normally
Log.w(TAG, "Could not recreate channel tab handler", e);
}
}
if (playlistControlBinding != null) {
// PlaylistControls should be visible only if there is some item in
// infoListAdapter other than header
if (infoListAdapter.getItemCount() > 1) {
playlistControlBinding.getRoot().setVisibility(View.VISIBLE);
} else {
playlistControlBinding.getRoot().setVisibility(View.GONE);
}
PlayButtonHelper.initPlaylistControlClickListener(
activity, playlistControlBinding, this);
}
}
public PlayQueue getPlayQueue() {
final List<StreamInfoItem> streamItems = infoListAdapter.getItemsList().stream()
.filter(StreamInfoItem.class::isInstance)
.map(StreamInfoItem.class::cast)
.collect(Collectors.toList());
return new ChannelTabPlayQueue(currentInfo.getServiceId(), tabHandler,
currentInfo.getNextPage(), streamItems, 0);
}
}

View file

@ -0,0 +1,168 @@
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 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.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.text.TextLinkifier;
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();
private 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
PicassoHelper.loadAvatar(item.getUploaderAvatars()).into(binding.authorAvatar);
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);
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;
}
}

View file

@ -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
}
}

View file

@ -110,4 +110,14 @@ public class CommentsFragment extends BaseListInfoFragment<CommentsInfoItem, Com
protected ItemViewMode getItemViewMode() { protected ItemViewMode getItemViewMode() {
return ItemViewMode.LIST; 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;
}
} }

View file

@ -16,11 +16,13 @@ import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.exceptions.ExtractionException; import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.kiosk.KioskInfo; import org.schabi.newpipe.extractor.kiosk.KioskInfo;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory; import org.schabi.newpipe.extractor.linkhandler.ListLinkHandlerFactory;
import org.schabi.newpipe.extractor.localization.ContentCountry; import org.schabi.newpipe.extractor.localization.ContentCountry;
import org.schabi.newpipe.extractor.services.media_ccc.extractors.MediaCCCLiveStreamKiosk;
import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ExtractorHelper;
@ -161,4 +163,14 @@ public class KioskFragment extends BaseListInfoFragment<StreamInfoItem, KioskInf
name = kioskTranslatedName; name = kioskTranslatedName;
setTitle(kioskTranslatedName); setTitle(kioskTranslatedName);
} }
@Override
public void showEmptyState() {
// show "no live streams" for live stream kiosk
super.showEmptyState();
if (MediaCCCLiveStreamKiosk.KIOSK_ID.equals(currentInfo.getId())
&& ServiceList.MediaCCC.getServiceId() == currentInfo.getServiceId()) {
setEmptyStateMessage(R.string.no_live_streams);
}
}
} }

View file

@ -0,0 +1,11 @@
package org.schabi.newpipe.fragments.list.playlist;
import org.schabi.newpipe.player.playqueue.PlayQueue;
/**
* Interface for {@code R.layout.playlist_control} view holders
* to give access to the play queue.
*/
public interface PlaylistControlViewHolder {
PlayQueue getPlayQueue();
}

View file

@ -43,14 +43,14 @@ import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import org.schabi.newpipe.local.dialog.PlaylistDialog; import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.local.playlist.RemotePlaylistManager; import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue; import org.schabi.newpipe.player.playqueue.PlaylistPlayQueue;
import org.schabi.newpipe.util.ExtractorHelper; import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.PlayButtonHelper;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
@ -64,7 +64,8 @@ import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, PlaylistInfo> { public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, PlaylistInfo>
implements PlaylistControlViewHolder {
private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG"; private static final String PICASSO_PLAYLIST_TAG = "PICASSO_PLAYLIST_TAG";
@ -233,7 +234,7 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
break; break;
case R.id.menu_item_share: case R.id.menu_item_share:
ShareUtils.shareText(requireContext(), name, url, ShareUtils.shareText(requireContext(), name, url,
currentInfo == null ? null : currentInfo.getThumbnailUrl()); currentInfo == null ? List.of() : currentInfo.getThumbnails());
break; break;
case R.id.menu_item_bookmark: case R.id.menu_item_bookmark:
onBookmarkClicked(); onBookmarkClicked();
@ -298,7 +299,6 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
playlistControlBinding.getRoot().setVisibility(View.VISIBLE); playlistControlBinding.getRoot().setVisibility(View.VISIBLE);
final String avatarUrl = result.getUploaderAvatarUrl();
if (result.getServiceId() == ServiceList.YouTube.getServiceId() if (result.getServiceId() == ServiceList.YouTube.getServiceId()
&& (YoutubeParsingHelper.isYoutubeMixId(result.getId()) && (YoutubeParsingHelper.isYoutubeMixId(result.getId())
|| YoutubeParsingHelper.isYoutubeMusicMixId(result.getId()))) { || YoutubeParsingHelper.isYoutubeMusicMixId(result.getId()))) {
@ -314,7 +314,7 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
R.drawable.ic_radio) R.drawable.ic_radio)
); );
} else { } else {
PicassoHelper.loadAvatar(avatarUrl).tag(PICASSO_PLAYLIST_TAG) PicassoHelper.loadAvatar(result.getUploaderAvatars()).tag(PICASSO_PLAYLIST_TAG)
.into(headerBinding.uploaderAvatarView); .into(headerBinding.uploaderAvatarView);
} }
@ -332,25 +332,10 @@ public class PlaylistFragment extends BaseListInfoFragment<StreamInfoItem, Playl
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe(getPlaylistBookmarkSubscriber()); .subscribe(getPlaylistBookmarkSubscriber());
playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view -> PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this);
NavigationHelper.playOnMainPlayer(activity, getPlayQueue()));
playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view ->
NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false));
playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view ->
NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false));
playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.POPUP);
return true;
});
playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.AUDIO);
return true;
});
} }
private PlayQueue getPlayQueue() { public PlayQueue getPlayQueue() {
return getPlayQueue(0); return getPlayQueue(0);
} }

View file

@ -167,6 +167,10 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
/*////////////////////////////////////////////////////////////////////////*/ /*////////////////////////////////////////////////////////////////////////*/
/**
* TextWatcher to remove rich-text formatting on the search EditText when pasting content
* from the clipboard.
*/
private TextWatcher textWatcher; private TextWatcher textWatcher;
public static SearchFragment getInstance(final int serviceId, final String searchString) { public static SearchFragment getInstance(final int serviceId, final String searchString) {
@ -583,11 +587,13 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
@Override @Override
public void beforeTextChanged(final CharSequence s, final int start, public void beforeTextChanged(final CharSequence s, final int start,
final int count, final int after) { final int count, final int after) {
// Do nothing, old text is already clean
} }
@Override @Override
public void onTextChanged(final CharSequence s, final int start, public void onTextChanged(final CharSequence s, final int start,
final int before, final int count) { final int before, final int count) {
// Changes are handled in afterTextChanged; CharSequence cannot be changed here.
} }
@Override @Override

View file

@ -21,18 +21,17 @@ import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment; import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.info_list.ItemViewMode; import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.ktx.ViewUtils; import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.util.RelatedItemInfo;
import java.io.Serializable; import java.io.Serializable;
import java.util.function.Supplier; import java.util.function.Supplier;
import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.core.Single;
public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, RelatedItemInfo> public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, RelatedItemsInfo>
implements SharedPreferences.OnSharedPreferenceChangeListener { implements SharedPreferences.OnSharedPreferenceChangeListener {
private static final String INFO_KEY = "related_info_key"; private static final String INFO_KEY = "related_info_key";
private RelatedItemInfo relatedItemInfo; private RelatedItemsInfo relatedItemsInfo;
/*////////////////////////////////////////////////////////////////////////// /*//////////////////////////////////////////////////////////////////////////
// Views // Views
@ -69,7 +68,7 @@ public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, Related
@Override @Override
protected Supplier<View> getListHeaderSupplier() { protected Supplier<View> getListHeaderSupplier() {
if (relatedItemInfo == null || relatedItemInfo.getRelatedItems() == null) { if (relatedItemsInfo == null || relatedItemsInfo.getRelatedItems() == null) {
return null; return null;
} }
@ -97,8 +96,8 @@ public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, Related
//////////////////////////////////////////////////////////////////////////*/ //////////////////////////////////////////////////////////////////////////*/
@Override @Override
protected Single<RelatedItemInfo> loadResult(final boolean forceLoad) { protected Single<RelatedItemsInfo> loadResult(final boolean forceLoad) {
return Single.fromCallable(() -> relatedItemInfo); return Single.fromCallable(() -> relatedItemsInfo);
} }
@Override @Override
@ -110,7 +109,7 @@ public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, Related
} }
@Override @Override
public void handleResult(@NonNull final RelatedItemInfo result) { public void handleResult(@NonNull final RelatedItemsInfo result) {
super.handleResult(result); super.handleResult(result);
if (headerBinding != null) { if (headerBinding != null) {
@ -137,23 +136,23 @@ public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, Related
private void setInitialData(final StreamInfo info) { private void setInitialData(final StreamInfo info) {
super.setInitialData(info.getServiceId(), info.getUrl(), info.getName()); super.setInitialData(info.getServiceId(), info.getUrl(), info.getName());
if (this.relatedItemInfo == null) { if (this.relatedItemsInfo == null) {
this.relatedItemInfo = RelatedItemInfo.getInfo(info); this.relatedItemsInfo = new RelatedItemsInfo(info);
} }
} }
@Override @Override
public void onSaveInstanceState(@NonNull final Bundle outState) { public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState); super.onSaveInstanceState(outState);
outState.putSerializable(INFO_KEY, relatedItemInfo); outState.putSerializable(INFO_KEY, relatedItemsInfo);
} }
@Override @Override
protected void onRestoreInstanceState(@NonNull final Bundle savedState) { protected void onRestoreInstanceState(@NonNull final Bundle savedState) {
super.onRestoreInstanceState(savedState); super.onRestoreInstanceState(savedState);
final Serializable serializable = savedState.getSerializable(INFO_KEY); final Serializable serializable = savedState.getSerializable(INFO_KEY);
if (serializable instanceof RelatedItemInfo) { if (serializable instanceof RelatedItemsInfo) {
this.relatedItemInfo = (RelatedItemInfo) serializable; this.relatedItemsInfo = (RelatedItemsInfo) serializable;
} }
} }

View file

@ -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()));
}
}

View file

@ -13,8 +13,7 @@ import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem;
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.CommentsInfoItemHolder; import org.schabi.newpipe.info_list.holder.CommentInfoItemHolder;
import org.schabi.newpipe.info_list.holder.CommentsMiniInfoItemHolder;
import org.schabi.newpipe.info_list.holder.InfoItemHolder; import org.schabi.newpipe.info_list.holder.InfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistInfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistMiniInfoItemHolder;
@ -87,8 +86,7 @@ public class InfoItemBuilder {
return useMiniVariant ? new PlaylistMiniInfoItemHolder(this, parent) return useMiniVariant ? new PlaylistMiniInfoItemHolder(this, parent)
: new PlaylistInfoItemHolder(this, parent); : new PlaylistInfoItemHolder(this, parent);
case COMMENT: case COMMENT:
return useMiniVariant ? new CommentsMiniInfoItemHolder(this, parent) return new CommentInfoItemHolder(this, parent);
: new CommentsInfoItemHolder(this, parent);
default: default:
throw new RuntimeException("InfoType not expected = " + infoType.name()); throw new RuntimeException("InfoType not expected = " + infoType.name());
} }

View file

@ -21,8 +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.CommentsInfoItemHolder; import org.schabi.newpipe.info_list.holder.CommentInfoItemHolder;
import org.schabi.newpipe.info_list.holder.CommentsMiniInfoItemHolder;
import org.schabi.newpipe.info_list.holder.InfoItemHolder; import org.schabi.newpipe.info_list.holder.InfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistCardInfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistCardInfoItemHolder;
import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder; import org.schabi.newpipe.info_list.holder.PlaylistGridInfoItemHolder;
@ -79,8 +78,7 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
private static final int PLAYLIST_HOLDER_TYPE = 0x301; private static final int PLAYLIST_HOLDER_TYPE = 0x301;
private static final int GRID_PLAYLIST_HOLDER_TYPE = 0x302; private static final int GRID_PLAYLIST_HOLDER_TYPE = 0x302;
private static final int CARD_PLAYLIST_HOLDER_TYPE = 0x303; private static final int CARD_PLAYLIST_HOLDER_TYPE = 0x303;
private static final int MINI_COMMENT_HOLDER_TYPE = 0x400; private static final int COMMENT_HOLDER_TYPE = 0x400;
private static final int COMMENT_HOLDER_TYPE = 0x401;
private final LayoutInflater layoutInflater; private final LayoutInflater layoutInflater;
private final InfoItemBuilder infoItemBuilder; private final InfoItemBuilder infoItemBuilder;
@ -271,7 +269,7 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
return PLAYLIST_HOLDER_TYPE; return PLAYLIST_HOLDER_TYPE;
} }
case COMMENT: case COMMENT:
return useMiniVariant ? MINI_COMMENT_HOLDER_TYPE : COMMENT_HOLDER_TYPE; return COMMENT_HOLDER_TYPE;
default: default:
return -1; return -1;
} }
@ -320,10 +318,8 @@ public class InfoListAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolde
return new PlaylistGridInfoItemHolder(infoItemBuilder, parent); return new PlaylistGridInfoItemHolder(infoItemBuilder, parent);
case CARD_PLAYLIST_HOLDER_TYPE: case CARD_PLAYLIST_HOLDER_TYPE:
return new PlaylistCardInfoItemHolder(infoItemBuilder, parent); return new PlaylistCardInfoItemHolder(infoItemBuilder, parent);
case MINI_COMMENT_HOLDER_TYPE:
return new CommentsMiniInfoItemHolder(infoItemBuilder, parent);
case COMMENT_HOLDER_TYPE: case COMMENT_HOLDER_TYPE:
return new CommentsInfoItemHolder(infoItemBuilder, parent); return new CommentInfoItemHolder(infoItemBuilder, parent);
default: default:
return new FallbackViewHolder(new View(parent.getContext())); return new FallbackViewHolder(new View(parent.getContext()));
} }

View file

@ -8,7 +8,7 @@ import com.xwray.groupie.Item
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.stream.StreamSegment import org.schabi.newpipe.extractor.stream.StreamSegment
import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.PicassoHelper import org.schabi.newpipe.util.image.PicassoHelper
class StreamSegmentItem( class StreamSegmentItem(
private val item: StreamSegment, private val item: StreamSegment,

View file

@ -104,7 +104,7 @@ public enum StreamDialogDefaultEntry {
SHARE(R.string.share, (fragment, item) -> SHARE(R.string.share, (fragment, item) ->
ShareUtils.shareText(fragment.requireContext(), item.getName(), item.getUrl(), ShareUtils.shareText(fragment.requireContext(), item.getName(), item.getUrl(),
item.getThumbnailUrl())), item.getThumbnails())),
/** /**
* Opens a {@link DownloadDialog} after fetching some stream info. * Opens a {@link DownloadDialog} after fetching some stream info.

View file

@ -13,7 +13,7 @@ import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
import org.schabi.newpipe.extractor.utils.Utils; import org.schabi.newpipe.extractor.utils.Utils;
import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
public class ChannelMiniInfoItemHolder extends InfoItemHolder { public class ChannelMiniInfoItemHolder extends InfoItemHolder {
@ -56,7 +56,7 @@ public class ChannelMiniInfoItemHolder extends InfoItemHolder {
itemAdditionalDetailView.setText(getDetailLine(item)); itemAdditionalDetailView.setText(getDetailLine(item));
} }
PicassoHelper.loadAvatar(item.getThumbnailUrl()).into(itemThumbnailView); PicassoHelper.loadAvatar(item.getThumbnails()).into(itemThumbnailView);
itemView.setOnClickListener(view -> { itemView.setOnClickListener(view -> {
if (itemBuilder.getOnChannelSelectedListener() != null) { if (itemBuilder.getOnChannelSelectedListener() != null) {

View file

@ -1,37 +1,36 @@
package org.schabi.newpipe.info_list.holder; package org.schabi.newpipe.info_list.holder;
import static android.text.TextUtils.isEmpty; import static android.text.TextUtils.isEmpty;
import static org.schabi.newpipe.util.ServiceHelper.getServiceById;
import android.graphics.Paint; import android.graphics.Paint;
import android.text.Layout; import android.text.Layout;
import android.text.method.LinkMovementMethod; import android.text.method.LinkMovementMethod;
import android.text.style.URLSpan; import android.text.style.URLSpan;
import android.util.Log;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.Button;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.RelativeLayout; import android.widget.RelativeLayout;
import android.widget.TextView; import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.text.HtmlCompat; import androidx.core.text.HtmlCompat;
import androidx.fragment.app.FragmentActivity;
import org.schabi.newpipe.R; import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.extractor.InfoItem; import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.ServiceList;
import org.schabi.newpipe.extractor.StreamingService; import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.comments.CommentsInfoItem; import org.schabi.newpipe.extractor.comments.CommentsInfoItem;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
import org.schabi.newpipe.extractor.stream.Description; import org.schabi.newpipe.extractor.stream.Description;
import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.DeviceUtils; import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
import org.schabi.newpipe.util.text.CommentTextOnTouchListener; import org.schabi.newpipe.util.text.CommentTextOnTouchListener;
import org.schabi.newpipe.util.text.TextLinkifier; import org.schabi.newpipe.util.text.TextLinkifier;
@ -40,8 +39,7 @@ import java.util.function.Consumer;
import io.reactivex.rxjava3.disposables.CompositeDisposable; import io.reactivex.rxjava3.disposables.CompositeDisposable;
public class CommentsMiniInfoItemHolder extends InfoItemHolder { public class CommentInfoItemHolder extends InfoItemHolder {
private static final String TAG = "CommentsMiniIIHolder";
private static final String ELLIPSIS = ""; private static final String ELLIPSIS = "";
private static final int COMMENT_DEFAULT_LINES = 2; private static final int COMMENT_DEFAULT_LINES = 2;
@ -56,23 +54,34 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
private final RelativeLayout itemRoot; private final RelativeLayout itemRoot;
private final ImageView itemThumbnailView; private final ImageView itemThumbnailView;
private final TextView itemContentView; private final TextView itemContentView;
private final ImageView itemThumbsUpView;
private final TextView itemLikesCountView; private final TextView itemLikesCountView;
private final TextView itemPublishedTime; private final TextView itemTitleView;
private final ImageView itemHeartView;
private final ImageView itemPinnedView;
private final Button repliesButton;
private final CompositeDisposable disposables = new CompositeDisposable(); private final CompositeDisposable disposables = new CompositeDisposable();
@Nullable private Description commentText; @Nullable
@Nullable private StreamingService streamService; private Description commentText;
@Nullable private String streamUrl; @Nullable
private StreamingService streamService;
@Nullable
private String streamUrl;
CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder, final int layoutId, public CommentInfoItemHolder(final InfoItemBuilder infoItemBuilder,
final ViewGroup parent) { final ViewGroup parent) {
super(infoItemBuilder, layoutId, parent); super(infoItemBuilder, R.layout.list_comment_item, parent);
itemRoot = itemView.findViewById(R.id.itemRoot); itemRoot = itemView.findViewById(R.id.itemRoot);
itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView); itemThumbnailView = itemView.findViewById(R.id.itemThumbnailView);
itemLikesCountView = itemView.findViewById(R.id.detail_thumbs_up_count_view);
itemPublishedTime = itemView.findViewById(R.id.itemPublishedTime);
itemContentView = itemView.findViewById(R.id.itemCommentContentView); 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() commentHorizontalPadding = (int) infoItemBuilder.getContext()
.getResources().getDimension(R.dimen.comments_horizontal_padding); .getResources().getDimension(R.dimen.comments_horizontal_padding);
@ -84,11 +93,6 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS); ellipsisWidthPx = paintAtContentSize.measureText(ELLIPSIS);
} }
public CommentsMiniInfoItemHolder(final InfoItemBuilder infoItemBuilder,
final ViewGroup parent) {
this(infoItemBuilder, R.layout.list_comments_mini_item, parent);
}
@Override @Override
public void updateFromItem(final InfoItem infoItem, public void updateFromItem(final InfoItem infoItem,
final HistoryRecordManager historyRecordManager) { final HistoryRecordManager historyRecordManager) {
@ -97,8 +101,10 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
} }
final CommentsInfoItem item = (CommentsInfoItem) infoItem; final CommentsInfoItem item = (CommentsInfoItem) infoItem;
PicassoHelper.loadAvatar(item.getUploaderAvatarUrl()).into(itemThumbnailView);
if (PicassoHelper.getShouldLoadImages()) { // load the author avatar
PicassoHelper.loadAvatar(item.getUploaderAvatars()).into(itemThumbnailView);
if (ImageStrategy.shouldLoadImages()) {
itemThumbnailView.setVisibility(View.VISIBLE); itemThumbnailView.setVisibility(View.VISIBLE);
itemRoot.setPadding(commentVerticalPadding, commentVerticalPadding, itemRoot.setPadding(commentVerticalPadding, commentVerticalPadding,
commentVerticalPadding, commentVerticalPadding); commentVerticalPadding, commentVerticalPadding);
@ -107,18 +113,33 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
itemRoot.setPadding(commentHorizontalPadding, commentVerticalPadding, itemRoot.setPadding(commentHorizontalPadding, commentVerticalPadding,
commentHorizontalPadding, commentVerticalPadding); commentHorizontalPadding, commentVerticalPadding);
} }
itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item)); itemThumbnailView.setOnClickListener(view -> openCommentAuthor(item));
try {
streamService = NewPipe.getService(item.getServiceId()); // setup the top row, with pinned icon, author name and comment date
} catch (final ExtractionException e) { itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
// should never happen itemTitleView.setText(Localization.concatenateStrings(item.getUploaderName(),
ErrorUtil.showUiErrorSnackbar(itemBuilder.getContext(), "Getting StreamingService", e); Localization.relativeTimeOrTextual(itemBuilder.getContext(), item.getUploadDate(),
Log.w(TAG, "Cannot obtain service from comment service id, defaulting to YouTube", e); item.getTextualUploadDate())));
streamService = ServiceList.YouTube;
}
// 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
streamService = getServiceById(item.getServiceId());
streamUrl = item.getUrl(); streamUrl = item.getUrl();
commentText = item.getCommentText(); commentText = item.getCommentText();
ellipsize(); ellipsize();
@ -126,22 +147,6 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
//noinspection ClickableViewAccessibility //noinspection ClickableViewAccessibility
itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE); itemContentView.setOnTouchListener(CommentTextOnTouchListener.INSTANCE);
if (item.getLikeCount() >= 0) {
itemLikesCountView.setText(
Localization.shortCount(
itemBuilder.getContext(),
item.getLikeCount()));
} else {
itemLikesCountView.setText("-");
}
if (item.getUploadDate() != null) {
itemPublishedTime.setText(Localization.relativeTime(item.getUploadDate()
.offsetDateTime()));
} else {
itemPublishedTime.setText(item.getTextualUploadDate());
}
itemView.setOnClickListener(view -> { itemView.setOnClickListener(view -> {
toggleEllipsize(); toggleEllipsize();
if (itemBuilder.getOnCommentsSelectedListener() != null) { if (itemBuilder.getOnCommentsSelectedListener() != null) {
@ -149,7 +154,6 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
} }
}); });
itemView.setOnLongClickListener(view -> { itemView.setOnLongClickListener(view -> {
if (DeviceUtils.isTv(itemBuilder.getContext())) { if (DeviceUtils.isTv(itemBuilder.getContext())) {
openCommentAuthor(item); openCommentAuthor(item);
@ -163,20 +167,14 @@ public class CommentsMiniInfoItemHolder extends InfoItemHolder {
}); });
} }
private void openCommentAuthor(final CommentsInfoItem item) { private void openCommentAuthor(@NonNull final CommentsInfoItem item) {
if (isEmpty(item.getUploaderUrl())) { NavigationHelper.openCommentAuthorIfPresent((FragmentActivity) itemBuilder.getContext(),
return; item);
} }
final AppCompatActivity activity = (AppCompatActivity) itemBuilder.getContext();
try { private void openCommentReplies(@NonNull final CommentsInfoItem item) {
NavigationHelper.openChannelFragment( NavigationHelper.openCommentRepliesFragment((FragmentActivity) itemBuilder.getContext(),
activity.getSupportFragmentManager(), item);
item.getServiceId(),
item.getUploaderUrl(),
item.getUploaderName());
} catch (final Exception e) {
ErrorUtil.showUiErrorSnackbar(activity, "Opening channel fragment", e);
}
} }
private void allowLinkFocus() { private void allowLinkFocus() {

View file

@ -1,63 +0,0 @@
package org.schabi.newpipe.info_list.holder;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;
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;
/*
* Created by Christian Schabesberger on 12.02.17.
*
* Copyright (C) Christian Schabesberger 2016 <chris.schabesberger@mailbox.org>
* ChannelInfoItemHolder .java is part of NewPipe.
*
* NewPipe 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.
*
* NewPipe 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 NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
public class CommentsInfoItemHolder extends CommentsMiniInfoItemHolder {
public final TextView itemTitleView;
private final ImageView itemHeartView;
private final ImageView itemPinnedView;
public CommentsInfoItemHolder(final InfoItemBuilder infoItemBuilder, final ViewGroup parent) {
super(infoItemBuilder, R.layout.list_comments_item, parent);
itemTitleView = itemView.findViewById(R.id.itemTitleView);
itemHeartView = itemView.findViewById(R.id.detail_heart_image_view);
itemPinnedView = itemView.findViewById(R.id.detail_pinned_view);
}
@Override
public void updateFromItem(final InfoItem infoItem,
final HistoryRecordManager historyRecordManager) {
super.updateFromItem(infoItem, historyRecordManager);
if (!(infoItem instanceof CommentsInfoItem)) {
return;
}
final CommentsInfoItem item = (CommentsInfoItem) infoItem;
itemTitleView.setText(item.getUploaderName());
itemHeartView.setVisibility(item.isHeartedByUploader() ? View.VISIBLE : View.GONE);
itemPinnedView.setVisibility(item.isPinned() ? View.VISIBLE : View.GONE);
}
}

View file

@ -9,7 +9,7 @@ import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem; import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem;
import org.schabi.newpipe.info_list.InfoItemBuilder; import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
public class PlaylistMiniInfoItemHolder extends InfoItemHolder { public class PlaylistMiniInfoItemHolder extends InfoItemHolder {
@ -46,7 +46,7 @@ public class PlaylistMiniInfoItemHolder extends InfoItemHolder {
.localizeStreamCountMini(itemStreamCountView.getContext(), item.getStreamCount())); .localizeStreamCountMini(itemStreamCountView.getContext(), item.getStreamCount()));
itemUploaderView.setText(item.getUploaderName()); itemUploaderView.setText(item.getUploaderName());
PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView); PicassoHelper.loadPlaylistThumbnail(item.getThumbnails()).into(itemThumbnailView);
itemView.setOnClickListener(view -> { itemView.setOnClickListener(view -> {
if (itemBuilder.getOnPlaylistSelectedListener() != null) { if (itemBuilder.getOnPlaylistSelectedListener() != null) {

View file

@ -12,10 +12,6 @@ import org.schabi.newpipe.info_list.InfoItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import androidx.preference.PreferenceManager;
import static org.schabi.newpipe.MainActivity.DEBUG;
/* /*
* Created by Christian Schabesberger on 01.08.16. * Created by Christian Schabesberger on 01.08.16.
* <p> * <p>
@ -81,7 +77,9 @@ public class StreamInfoItemHolder extends StreamMiniInfoItemHolder {
} }
} }
final String uploadDate = getFormattedRelativeUploadDate(infoItem); final String uploadDate = Localization.relativeTimeOrTextual(itemBuilder.getContext(),
infoItem.getUploadDate(),
infoItem.getTextualUploadDate());
if (!TextUtils.isEmpty(uploadDate)) { if (!TextUtils.isEmpty(uploadDate)) {
if (viewsAndDate.isEmpty()) { if (viewsAndDate.isEmpty()) {
return uploadDate; return uploadDate;
@ -92,20 +90,4 @@ public class StreamInfoItemHolder extends StreamMiniInfoItemHolder {
return viewsAndDate; return viewsAndDate;
} }
private String getFormattedRelativeUploadDate(final StreamInfoItem infoItem) {
if (infoItem.getUploadDate() != null) {
String formattedRelativeTime = Localization
.relativeTime(infoItem.getUploadDate().offsetDateTime());
if (DEBUG && PreferenceManager.getDefaultSharedPreferences(itemBuilder.getContext())
.getBoolean(itemBuilder.getContext()
.getString(R.string.show_original_time_ago_key), false)) {
formattedRelativeTime += " (" + infoItem.getTextualUploadDate() + ")";
}
return formattedRelativeTime;
} else {
return infoItem.getTextualUploadDate();
}
}
} }

View file

@ -16,7 +16,7 @@ import org.schabi.newpipe.ktx.ViewUtils;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.DependentPreferenceHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.StreamTypeUtil; import org.schabi.newpipe.util.StreamTypeUtil;
import org.schabi.newpipe.views.AnimatedProgressBar; import org.schabi.newpipe.views.AnimatedProgressBar;
@ -87,7 +87,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
} }
// Default thumbnail is shown on error, while loading and if the url is empty // Default thumbnail is shown on error, while loading and if the url is empty
PicassoHelper.loadThumbnail(item.getThumbnailUrl()).into(itemThumbnailView); PicassoHelper.loadThumbnail(item.getThumbnails()).into(itemThumbnailView);
itemView.setOnClickListener(view -> { itemView.setOnClickListener(view -> {
if (itemBuilder.getOnStreamSelectedListener() != null) { if (itemBuilder.getOnStreamSelectedListener() != null) {

View file

@ -0,0 +1,9 @@
package org.schabi.newpipe.ktx
import android.os.Bundle
import android.os.Parcelable
import androidx.core.os.BundleCompat
inline fun <reified T : Parcelable> Bundle.parcelableArrayList(key: String?): ArrayList<T>? {
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
}

View file

@ -91,11 +91,6 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
// Fragment LifeCycle - Views // Fragment LifeCycle - Views
/////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////
@Override
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
}
@Override @Override
protected void initListeners() { protected void initListeners() {
super.initListeners(); super.initListeners();

View file

@ -38,7 +38,6 @@ import android.view.ViewGroup
import android.widget.Button import android.widget.Button
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.math.MathUtils
import androidx.core.os.bundleOf import androidx.core.os.bundleOf
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelProvider
@ -60,6 +59,7 @@ import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.databinding.FragmentFeedBinding import org.schabi.newpipe.databinding.FragmentFeedBinding
import org.schabi.newpipe.error.ErrorInfo import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.error.ErrorUtil
import org.schabi.newpipe.error.UserAction import org.schabi.newpipe.error.UserAction
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
@ -453,24 +453,33 @@ class FeedFragment : BaseStateFragment<FeedState>() {
if (t is FeedLoadService.RequestException && if (t is FeedLoadService.RequestException &&
t.cause is ContentNotAvailableException t.cause is ContentNotAvailableException
) { ) {
Single.fromCallable { disposables.add(
NewPipeDatabase.getInstance(requireContext()).subscriptionDAO() Single.fromCallable {
.getSubscription(t.subscriptionId) NewPipeDatabase.getInstance(requireContext()).subscriptionDAO()
}.subscribeOn(Schedulers.io()) .getSubscription(t.subscriptionId)
.observeOn(AndroidSchedulers.mainThread()) }
.subscribe( .subscribeOn(Schedulers.io())
{ subscriptionEntity -> .observeOn(AndroidSchedulers.mainThread())
handleFeedNotAvailable( .subscribe(
subscriptionEntity, { subscriptionEntity ->
t.cause, handleFeedNotAvailable(
errors.subList(i + 1, errors.size) subscriptionEntity,
) t.cause,
}, errors.subList(i + 1, errors.size)
{ throwable -> Log.e(TAG, "Unable to process", throwable) } )
) },
return // this will be called on the remaining errors by handleFeedNotAvailable() { throwable -> Log.e(TAG, "Unable to process", throwable) }
)
)
// this will be called on the remaining errors by handleFeedNotAvailable()
return@handleItemsErrors
} }
} }
if (errors.isNotEmpty()) {
// if no error was a ContentNotAvailableException, show a general error snackbar
ErrorUtil.showSnackbar(this, ErrorInfo(errors, UserAction.REQUESTED_FEED, ""))
}
} }
private fun handleFeedNotAvailable( private fun handleFeedNotAvailable(
@ -579,7 +588,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
// state until the user scrolls them out of the visible area which causes a update/bind-call // state until the user scrolls them out of the visible area which causes a update/bind-call
groupAdapter.notifyItemRangeChanged( groupAdapter.notifyItemRangeChanged(
0, 0,
MathUtils.clamp(highlightCount, lastNewItemsCount, groupAdapter.itemCount) highlightCount.coerceIn(lastNewItemsCount, groupAdapter.itemCount)
) )
if (highlightCount > 0) { if (highlightCount > 0) {
@ -598,9 +607,13 @@ class FeedFragment : BaseStateFragment<FeedState>() {
execOnEnd = { execOnEnd = {
// Disabled animations would result in immediately hiding the button // Disabled animations would result in immediately hiding the button
// after it showed up // after it showed up
if (DeviceUtils.hasAnimationsAnimatorDurationEnabled(context)) { // Context can be null in some cases, so we have to make sure it is not null in
// Hide the new items-"popup" after 10s // order to avoid a NullPointerException
hideNewItemsLoaded(true, 10000) context?.let {
if (DeviceUtils.hasAnimationsAnimatorDurationEnabled(it)) {
// Hide the new items button after 10s
hideNewItemsLoaded(true, 10000)
}
} }
} }
) )

View file

@ -13,9 +13,9 @@ sealed class FeedState {
data class LoadedState( data class LoadedState(
val items: List<StreamItem>, val items: List<StreamItem>,
val oldestUpdate: OffsetDateTime? = null, val oldestUpdate: OffsetDateTime?,
val notLoadedCount: Long, val notLoadedCount: Long,
val itemsErrors: List<Throwable> = emptyList() val itemsErrors: List<Throwable>
) : FeedState() ) : FeedState()
data class ErrorState( data class ErrorState(

View file

@ -86,7 +86,7 @@ class FeedViewModel(
.subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) -> .subscribe { (event, listFromDB, notLoadedCount, oldestUpdate) ->
mutableStateLiveData.postValue( mutableStateLiveData.postValue(
when (event) { when (event) {
is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount) is IdleEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, listOf())
is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage) is ProgressEvent -> FeedState.ProgressState(event.currentProgress, event.maxProgress, event.progressMessage)
is SuccessResultEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, event.itemsErrors) is SuccessResultEvent -> FeedState.LoadedState(listFromDB.map { e -> StreamItem(e) }, oldestUpdate, notLoadedCount, event.itemsErrors)
is ErrorResultEvent -> FeedState.ErrorState(event.error) is ErrorResultEvent -> FeedState.ErrorState(event.error)

View file

@ -18,8 +18,8 @@ import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_AUDIO_STREAM
import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_STREAM import org.schabi.newpipe.extractor.stream.StreamType.POST_LIVE_STREAM
import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM import org.schabi.newpipe.extractor.stream.StreamType.VIDEO_STREAM
import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.PicassoHelper
import org.schabi.newpipe.util.StreamTypeUtil import org.schabi.newpipe.util.StreamTypeUtil
import org.schabi.newpipe.util.image.PicassoHelper
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.function.Consumer import java.util.function.Consumer

View file

@ -22,7 +22,7 @@ import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.service.FeedUpdateInfo import org.schabi.newpipe.local.feed.service.FeedUpdateInfo
import org.schabi.newpipe.util.NavigationHelper import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.PicassoHelper import org.schabi.newpipe.util.image.PicassoHelper
/** /**
* Helper for everything related to show notifications about new streams to the user. * Helper for everything related to show notifications about new streams to the user.
@ -58,7 +58,7 @@ class NotificationHelper(val context: Context) {
.setAutoCancel(true) .setAutoCancel(true)
.setCategory(NotificationCompat.CATEGORY_SOCIAL) .setCategory(NotificationCompat.CATEGORY_SOCIAL)
.setGroupSummary(true) .setGroupSummary(true)
.setGroup(data.listInfo.url) .setGroup(data.url)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
// Build a summary notification for Android versions < 7.0 // Build a summary notification for Android versions < 7.0
@ -73,7 +73,7 @@ class NotificationHelper(val context: Context) {
context, context,
data.pseudoId, data.pseudoId,
NavigationHelper NavigationHelper
.getChannelIntent(context, data.listInfo.serviceId, data.listInfo.url) .getChannelIntent(context, data.serviceId, data.url)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK), .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
0, 0,
false false
@ -88,7 +88,7 @@ class NotificationHelper(val context: Context) {
// Show individual stream notifications, set channel icon only if there is actually // Show individual stream notifications, set channel icon only if there is actually
// one // one
showStreamNotifications(newStreams, data.listInfo.serviceId, bitmap) showStreamNotifications(newStreams, data.serviceId, bitmap)
// Show summary notification // Show summary notification
manager.notify(data.pseudoId, summaryBuilder.build()) manager.notify(data.pseudoId, summaryBuilder.build())
@ -97,7 +97,7 @@ class NotificationHelper(val context: Context) {
override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) { override fun onBitmapFailed(e: Exception, errorDrawable: Drawable) {
// Show individual stream notifications // Show individual stream notifications
showStreamNotifications(newStreams, data.listInfo.serviceId, null) showStreamNotifications(newStreams, data.serviceId, null)
// Show summary notification // Show summary notification
manager.notify(data.pseudoId, summaryBuilder.build()) manager.notify(data.pseudoId, summaryBuilder.build())
iconLoadingTargets.remove(this) // allow it to be garbage-collected iconLoadingTargets.remove(this) // allow it to be garbage-collected

View file

@ -137,7 +137,7 @@ class NotificationWorker(
.enqueueUniquePeriodicWork( .enqueueUniquePeriodicWork(
WORK_TAG, WORK_TAG,
if (force) { if (force) {
ExistingPeriodicWorkPolicy.REPLACE ExistingPeriodicWorkPolicy.CANCEL_AND_REENQUEUE
} else { } else {
ExistingPeriodicWorkPolicy.KEEP ExistingPeriodicWorkPolicy.KEEP
}, },

View file

@ -26,7 +26,7 @@ object FeedEventManager {
} }
sealed class Event { sealed class Event {
object IdleEvent : Event() data object IdleEvent : Event()
data class ProgressEvent(val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0) : Event() { data class ProgressEvent(val currentProgress: Int = -1, val maxProgress: Int = -1, @StringRes val progressMessage: Int = 0) : Event() {
constructor(@StringRes progressMessage: Int) : this(-1, -1, progressMessage) constructor(@StringRes progressMessage: Int) : this(-1, -1, progressMessage)
} }

View file

@ -1,6 +1,7 @@
package org.schabi.newpipe.local.feed.service package org.schabi.newpipe.local.feed.service
import android.content.Context import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Completable
@ -13,11 +14,17 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.extractor.ListInfo import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.extractor.Info
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.feed.FeedInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.FeedDatabaseManager import org.schabi.newpipe.local.feed.FeedDatabaseManager
import org.schabi.newpipe.local.subscription.SubscriptionManager import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.util.ExtractorHelper import org.schabi.newpipe.util.ChannelTabHelper
import org.schabi.newpipe.util.ExtractorHelper.getChannelInfo
import org.schabi.newpipe.util.ExtractorHelper.getChannelTab
import org.schabi.newpipe.util.ExtractorHelper.getMoreChannelTabItems
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
@ -75,7 +82,9 @@ class FeedLoadManager(private val context: Context) {
* subscriptions which have not been updated within the feed updated threshold * subscriptions which have not been updated within the feed updated threshold
*/ */
val outdatedSubscriptions = when (groupId) { val outdatedSubscriptions = when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(outdatedThreshold) FeedGroupEntity.GROUP_ALL_ID -> feedDatabaseManager.outdatedSubscriptions(
outdatedThreshold
)
GROUP_NOTIFICATION_ENABLED -> feedDatabaseManager.outdatedSubscriptionsWithNotificationMode( GROUP_NOTIFICATION_ENABLED -> feedDatabaseManager.outdatedSubscriptionsWithNotificationMode(
outdatedThreshold, NotificationMode.ENABLED outdatedThreshold, NotificationMode.ENABLED
) )
@ -101,52 +110,7 @@ class FeedLoadManager(private val context: Context) {
.runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2) .runOn(Schedulers.io(), PARALLEL_EXTRACTIONS * 2)
.filter { !cancelSignal.get() } .filter { !cancelSignal.get() }
.map { subscriptionEntity -> .map { subscriptionEntity ->
var error: Throwable? = null loadStreams(subscriptionEntity, useFeedExtractor, defaultSharedPreferences)
try {
// check for and load new streams
// either by using the dedicated feed method or by getting the channel info
val listInfo = if (useFeedExtractor) {
ExtractorHelper
.getFeedInfoFallbackToChannelInfo(
subscriptionEntity.serviceId,
subscriptionEntity.url
)
.onErrorReturn {
error = it // store error, otherwise wrapped into RuntimeException
throw it
}
.blockingGet()
} else {
ExtractorHelper
.getChannelInfo(
subscriptionEntity.serviceId,
subscriptionEntity.url,
true
)
.onErrorReturn {
error = it // store error, otherwise wrapped into RuntimeException
throw it
}
.blockingGet()
} as ListInfo<StreamInfoItem>
return@map Notification.createOnNext(
FeedUpdateInfo(
subscriptionEntity,
listInfo
)
)
} catch (e: Throwable) {
if (error == null) {
// do this to prevent blockingGet() from wrapping into RuntimeException
error = e
}
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
val wrapper =
FeedLoadService.RequestException(subscriptionEntity.uid, request, error!!)
return@map Notification.createOnError<FeedUpdateInfo>(wrapper)
}
} }
.sequential() .sequential()
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
@ -164,7 +128,112 @@ class FeedLoadManager(private val context: Context) {
} }
private fun broadcastProgress() { private fun broadcastProgress() {
FeedEventManager.postEvent(FeedEventManager.Event.ProgressEvent(currentProgress.get(), maxProgress.get())) FeedEventManager.postEvent(
FeedEventManager.Event.ProgressEvent(
currentProgress.get(),
maxProgress.get()
)
)
}
private fun loadStreams(
subscriptionEntity: SubscriptionEntity,
useFeedExtractor: Boolean,
defaultSharedPreferences: SharedPreferences
): Notification<FeedUpdateInfo> {
var error: Throwable? = null
val storeOriginalErrorAndRethrow = { e: Throwable ->
// keep original to prevent blockingGet() from wrapping it into RuntimeException
error = e
throw e
}
try {
// check for and load new streams
// either by using the dedicated feed method or by getting the channel info
var originalInfo: Info? = null
var streams: List<StreamInfoItem>? = null
val errors = ArrayList<Throwable>()
if (useFeedExtractor) {
NewPipe.getService(subscriptionEntity.serviceId)
.getFeedExtractor(subscriptionEntity.url)
?.also { feedExtractor ->
// the user wants to use a feed extractor and there is one, use it
val feedInfo = FeedInfo.getInfo(feedExtractor)
errors.addAll(feedInfo.errors)
originalInfo = feedInfo
streams = feedInfo.relatedItems
}
}
if (originalInfo == null) {
// use the normal channel tabs extractor if either the user wants it, or
// the current service does not have a dedicated feed extractor
val channelInfo = getChannelInfo(
subscriptionEntity.serviceId,
subscriptionEntity.url, true
)
.onErrorReturn(storeOriginalErrorAndRethrow)
.blockingGet()
errors.addAll(channelInfo.errors)
originalInfo = channelInfo
streams = channelInfo.tabs
.filter { tab ->
ChannelTabHelper.fetchFeedChannelTab(
context,
defaultSharedPreferences,
tab
)
}
.map {
Pair(
getChannelTab(subscriptionEntity.serviceId, it, true)
.onErrorReturn(storeOriginalErrorAndRethrow)
.blockingGet(),
it
)
}
.flatMap { (channelTabInfo, linkHandler) ->
errors.addAll(channelTabInfo.errors)
if (channelTabInfo.relatedItems.isEmpty() &&
channelTabInfo.nextPage != null
) {
val infoItemsPage = getMoreChannelTabItems(
subscriptionEntity.serviceId,
linkHandler, channelTabInfo.nextPage
)
.blockingGet()
errors.addAll(infoItemsPage.errors)
return@flatMap infoItemsPage.items
} else {
return@flatMap channelTabInfo.relatedItems
}
}
.filterIsInstance<StreamInfoItem>()
}
return Notification.createOnNext(
FeedUpdateInfo(
subscriptionEntity,
originalInfo!!,
streams!!,
errors,
)
)
} catch (e: Throwable) {
val request = "${subscriptionEntity.serviceId}:${subscriptionEntity.url}"
val wrapper = FeedLoadService.RequestException(
subscriptionEntity.uid,
request,
// do this to prevent blockingGet() from wrapping into RuntimeException
error ?: e
)
return Notification.createOnError(wrapper)
}
} }
/** /**
@ -203,24 +272,24 @@ class FeedLoadManager(private val context: Context) {
for (notification in list) { for (notification in list) {
when { when {
notification.isOnNext -> { notification.isOnNext -> {
val subscriptionId = notification.value!!.uid val info = notification.value!!
val info = notification.value!!.listInfo
notification.value!!.newStreams = filterNewStreams( notification.value!!.newStreams = filterNewStreams(info.streams)
notification.value!!.listInfo.relatedItems
)
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems) feedDatabaseManager.upsertAll(info.uid, info.streams)
subscriptionManager.updateFromInfo(subscriptionId, info) subscriptionManager.updateFromInfo(info)
if (info.errors.isNotEmpty()) { if (info.errors.isNotEmpty()) {
feedResultsHolder.addErrors( feedResultsHolder.addErrors(
FeedLoadService.RequestException.wrapList( info.errors.map {
subscriptionId, FeedLoadService.RequestException(
info info.uid,
) "${info.serviceId}:${info.url}",
it
)
}
) )
feedDatabaseManager.markAsOutdated(subscriptionId) feedDatabaseManager.markAsOutdated(info.uid)
} }
} }
notification.isOnError -> { notification.isOnError -> {

View file

@ -39,8 +39,6 @@ import org.schabi.newpipe.App
import org.schabi.newpipe.MainActivity.DEBUG import org.schabi.newpipe.MainActivity.DEBUG
import org.schabi.newpipe.R import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.extractor.ListInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.Event.ErrorResultEvent
import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent import org.schabi.newpipe.local.feed.service.FeedEventManager.postEvent
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -126,17 +124,7 @@ class FeedLoadService : Service() {
// Loading & Handling // Loading & Handling
// ///////////////////////////////////////////////////////////////////////// // /////////////////////////////////////////////////////////////////////////
class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause) { class RequestException(val subscriptionId: Long, message: String, cause: Throwable) : Exception(message, cause)
companion object {
fun wrapList(subscriptionId: Long, info: ListInfo<StreamInfoItem>): List<Throwable> {
val toReturn = ArrayList<Throwable>(info.errors.size)
info.errors.mapTo(toReturn) {
RequestException(subscriptionId, info.serviceId.toString() + ":" + info.url, it)
}
return toReturn
}
}
}
// ///////////////////////////////////////////////////////////////////////// // /////////////////////////////////////////////////////////////////////////
// Notification // Notification

View file

@ -2,33 +2,58 @@ package org.schabi.newpipe.local.feed.service
import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.extractor.ListInfo import org.schabi.newpipe.extractor.Info
import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.util.image.ImageStrategy
/**
* Instances of this class might stay around in memory for some time while fetching the feed,
* because of [FeedLoadManager.BUFFER_COUNT_BEFORE_INSERT]. Therefore this class should contain
* as little data as possible to avoid out of memory errors. In particular, avoid storing whole
* [ChannelInfo] objects, as they might contain raw JSON info in ready channel tabs link handlers.
*/
data class FeedUpdateInfo( data class FeedUpdateInfo(
val uid: Long, val uid: Long,
@NotificationMode @NotificationMode
val notificationMode: Int, val notificationMode: Int,
val name: String, val name: String,
val avatarUrl: String, val avatarUrl: String,
val listInfo: ListInfo<StreamInfoItem>, val url: String,
val serviceId: Int,
// description and subscriberCount are null if the constructor info is from the fast feed method
val description: String?,
val subscriberCount: Long?,
val streams: List<StreamInfoItem>,
val errors: List<Throwable>,
) { ) {
constructor( constructor(
subscription: SubscriptionEntity, subscription: SubscriptionEntity,
listInfo: ListInfo<StreamInfoItem>, info: Info,
streams: List<StreamInfoItem>,
errors: List<Throwable>,
) : this( ) : this(
uid = subscription.uid, uid = subscription.uid,
notificationMode = subscription.notificationMode, notificationMode = subscription.notificationMode,
name = subscription.name, name = info.name,
avatarUrl = subscription.avatarUrl, avatarUrl = (info as? ChannelInfo)?.avatars?.let {
listInfo = listInfo, // if the newly fetched info is not from fast feed, then it contains updated avatars
ImageStrategy.imageListToDbUrl(it)
} ?: subscription.avatarUrl,
url = info.url,
serviceId = info.serviceId,
// there is no description and subscriberCount in the fast feed
description = (info as? ChannelInfo)?.description,
subscriberCount = (info as? ChannelInfo)?.subscriberCount,
streams = streams,
errors = errors,
) )
/** /**
* Integer id, can be used as notification id, etc. * Integer id, can be used as notification id, etc.
*/ */
val pseudoId: Int val pseudoId: Int
get() = listInfo.url.hashCode() get() = url.hashCode()
lateinit var newStreams: List<StreamInfoItem> lateinit var newStreams: List<StreamInfoItem>
} }

View file

@ -28,14 +28,16 @@ import org.schabi.newpipe.databinding.StatisticPlaylistControlBinding;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog; import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.settings.HistorySettingsFragment; import org.schabi.newpipe.settings.HistorySettingsFragment;
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.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.util.PlayButtonHelper;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
@ -49,7 +51,8 @@ import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
public class StatisticsPlaylistFragment public class StatisticsPlaylistFragment
extends BaseLocalListFragment<List<StreamStatisticsEntry>, Void> { extends BaseLocalListFragment<List<StreamStatisticsEntry>, Void>
implements PlaylistControlViewHolder {
private final CompositeDisposable disposables = new CompositeDisposable(); private final CompositeDisposable disposables = new CompositeDisposable();
@State @State
Parcelable itemsListState; Parcelable itemsListState;
@ -195,14 +198,9 @@ public class StatisticsPlaylistFragment
if (itemListAdapter != null) { if (itemListAdapter != null) {
itemListAdapter.unsetSelectedListener(); itemListAdapter.unsetSelectedListener();
} }
if (playlistControlBinding != null) {
playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(null);
playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(null);
playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(null);
headerBinding = null; headerBinding = null;
playlistControlBinding = null; playlistControlBinding = null;
}
if (databaseSubscription != null) { if (databaseSubscription != null) {
databaseSubscription.cancel(); databaseSubscription.cancel();
@ -276,12 +274,8 @@ public class StatisticsPlaylistFragment
itemsListState = null; itemsListState = null;
} }
playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view -> PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this);
NavigationHelper.playOnMainPlayer(activity, getPlayQueue()));
playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view ->
NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false));
playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view ->
NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false));
headerBinding.sortButton.setOnClickListener(view -> toggleSortMode()); headerBinding.sortButton.setOnClickListener(view -> toggleSortMode());
hideLoading(); hideLoading();
@ -374,7 +368,7 @@ public class StatisticsPlaylistFragment
} }
} }
private PlayQueue getPlayQueue() { public PlayQueue getPlayQueue() {
return getPlayQueue(0); return getPlayQueue(0);
} }

View file

@ -8,7 +8,7 @@ import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry; import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;

View file

@ -16,7 +16,7 @@ import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.DependentPreferenceHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.views.AnimatedProgressBar; import org.schabi.newpipe.views.AnimatedProgressBar;

View file

@ -16,7 +16,7 @@ import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.DependentPreferenceHelper;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.views.AnimatedProgressBar; import org.schabi.newpipe.views.AnimatedProgressBar;

View file

@ -8,7 +8,7 @@ import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
import org.schabi.newpipe.local.LocalItemBuilder; import org.schabi.newpipe.local.LocalItemBuilder;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.ServiceHelper;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;

View file

@ -22,7 +22,6 @@ import android.widget.Toast;
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.preference.PreferenceManager;
import androidx.recyclerview.widget.ItemTouchHelper; import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView; import androidx.recyclerview.widget.RecyclerView;
import androidx.viewbinding.ViewBinding; import androidx.viewbinding.ViewBinding;
@ -42,16 +41,18 @@ import org.schabi.newpipe.databinding.PlaylistControlBinding;
import org.schabi.newpipe.error.ErrorInfo; import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.fragments.MainFragment;
import org.schabi.newpipe.fragments.list.playlist.PlaylistControlViewHolder;
import org.schabi.newpipe.info_list.dialog.InfoItemDialog; import org.schabi.newpipe.info_list.dialog.InfoItemDialog;
import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry; import org.schabi.newpipe.info_list.dialog.StreamDialogDefaultEntry;
import org.schabi.newpipe.local.BaseLocalListFragment; import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.local.history.HistoryRecordManager; import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue; import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture; import org.schabi.newpipe.util.OnClickGesture;
import org.schabi.newpipe.util.PlayButtonHelper;
import org.schabi.newpipe.util.external_communication.ShareUtils; import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.ArrayList; import java.util.ArrayList;
@ -69,8 +70,9 @@ import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.schedulers.Schedulers;
import io.reactivex.rxjava3.subjects.PublishSubject; import io.reactivex.rxjava3.subjects.PublishSubject;
public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistStreamEntry>, Void> { public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistStreamEntry>, Void>
// Save the list 10 seconds after the last change occurred implements PlaylistControlViewHolder {
/** Save the list 10 seconds after the last change occurred. */
private static final long SAVE_DEBOUNCE_MILLIS = 10000; private static final long SAVE_DEBOUNCE_MILLIS = 10000;
private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12; private static final int MINIMUM_INITIAL_DRAG_VELOCITY = 12;
@State @State
@ -91,13 +93,20 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
private PublishSubject<Long> debouncedSaveSignal; private PublishSubject<Long> debouncedSaveSignal;
private CompositeDisposable disposables; private CompositeDisposable disposables;
/* Has the playlist been fully loaded from db */ /** Whether the playlist has been fully loaded from db. */
private AtomicBoolean isLoadingComplete; private AtomicBoolean isLoadingComplete;
/* Has the playlist been modified (e.g. items reordered or deleted) */ /** Whether the playlist has been modified (e.g. items reordered or deleted) */
private AtomicBoolean isModified; private AtomicBoolean isModified;
/* Flag to prevent simultaneous rewrites of the playlist */ /** Flag to prevent simultaneous rewrites of the playlist. */
private boolean isRewritingPlaylist = false; private boolean isRewritingPlaylist = false;
/**
* The pager adapter that the fragment is created from when it is used as frontpage, i.e.
* {@link #useAsFrontPage} is {@link true}.
*/
@Nullable
private MainFragment.SelectedTabsPagerAdapter tabsPagerAdapter = null;
public static LocalPlaylistFragment getInstance(final long playlistId, final String name) { public static LocalPlaylistFragment getInstance(final long playlistId, final String name) {
final LocalPlaylistFragment instance = new LocalPlaylistFragment(); final LocalPlaylistFragment instance = new LocalPlaylistFragment();
instance.setInitialData(playlistId, name); instance.setInitialData(playlistId, name);
@ -157,6 +166,17 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
return headerBinding; return headerBinding;
} }
/**
* <p>Commit changes immediately if the playlist has been modified.</p>
* Delete operations and other modifications will be committed to ensure that the database
* is up to date, e.g. when the user adds the just deleted stream from another fragment.
*/
public void commitChanges() {
if (isModified != null && isModified.get()) {
saveImmediate();
}
}
@Override @Override
protected void initListeners() { protected void initListeners() {
super.initListeners(); super.initListeners();
@ -265,14 +285,10 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
if (itemListAdapter != null) { if (itemListAdapter != null) {
itemListAdapter.unsetSelectedListener(); itemListAdapter.unsetSelectedListener();
} }
if (playlistControlBinding != null) {
playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(null);
playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(null);
playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(null);
headerBinding = null; headerBinding = null;
playlistControlBinding = null; playlistControlBinding = null;
}
if (databaseSubscription != null) { if (databaseSubscription != null) {
databaseSubscription.cancel(); databaseSubscription.cancel();
@ -294,6 +310,9 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
if (disposables != null) { if (disposables != null) {
disposables.dispose(); disposables.dispose();
} }
if (tabsPagerAdapter != null) {
tabsPagerAdapter.getLocalPlaylistFragments().remove(this);
}
debouncedSaveSignal = null; debouncedSaveSignal = null;
playlistManager = null; playlistManager = null;
@ -349,7 +368,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
@Override @Override
public boolean onOptionsItemSelected(final MenuItem item) { public boolean onOptionsItemSelected(final MenuItem item) {
if (item.getItemId() == R.id.menu_item_share_playlist) { if (item.getItemId() == R.id.menu_item_share_playlist) {
sharePlaylist(); createShareConfirmationDialog();
} else if (item.getItemId() == R.id.menu_item_rename_playlist) { } else if (item.getItemId() == R.id.menu_item_rename_playlist) {
createRenameDialog(); createRenameDialog();
} else if (item.getItemId() == R.id.menu_item_remove_watched) { } else if (item.getItemId() == R.id.menu_item_remove_watched) {
@ -377,16 +396,33 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
} }
/** /**
* Share the playlist as a newline-separated list of stream URLs. * Shares the playlist as a list of stream URLs if {@code shouldSharePlaylistDetails} is
* set to {@code false}. Shares the playlist name along with a list of video titles and URLs
* if {@code shouldSharePlaylistDetails} is set to {@code true}.
*
* @param shouldSharePlaylistDetails Whether the playlist details should be included in the
* shared content.
*/ */
public void sharePlaylist() { private void sharePlaylist(final boolean shouldSharePlaylistDetails) {
final Context context = requireContext();
disposables.add(playlistManager.getPlaylistStreams(playlistId) disposables.add(playlistManager.getPlaylistStreams(playlistId)
.flatMapSingle(playlist -> Single.just(playlist.stream() .flatMapSingle(playlist -> Single.just(playlist.stream()
.map(PlaylistStreamEntry::getStreamEntity) .map(PlaylistStreamEntry::getStreamEntity)
.map(StreamEntity::getUrl) .map(streamEntity -> {
if (shouldSharePlaylistDetails) {
return context.getString(R.string.video_details_list_item,
streamEntity.getTitle(), streamEntity.getUrl());
} else {
return streamEntity.getUrl();
}
})
.collect(Collectors.joining("\n")))) .collect(Collectors.joining("\n"))))
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
.subscribe(urlsText -> ShareUtils.shareText(requireContext(), name, urlsText), .subscribe(urlsText -> ShareUtils.shareText(
context, name, shouldSharePlaylistDetails
? context.getString(R.string.share_playlist_content_details,
name, urlsText) : urlsText),
throwable -> showUiErrorSnackbar(this, "Sharing playlist", throwable))); throwable -> showUiErrorSnackbar(this, "Sharing playlist", throwable)));
} }
@ -498,38 +534,11 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
} }
setVideoCount(itemListAdapter.getItemsList().size()); setVideoCount(itemListAdapter.getItemsList().size());
playlistControlBinding.playlistCtrlPlayAllButton.setOnClickListener(view -> { PlayButtonHelper.initPlaylistControlClickListener(activity, playlistControlBinding, this);
NavigationHelper.playOnMainPlayer(activity, getPlayQueue());
showHoldToAppendTipIfNeeded();
});
playlistControlBinding.playlistCtrlPlayPopupButton.setOnClickListener(view -> {
NavigationHelper.playOnPopupPlayer(activity, getPlayQueue(), false);
showHoldToAppendTipIfNeeded();
});
playlistControlBinding.playlistCtrlPlayBgButton.setOnClickListener(view -> {
NavigationHelper.playOnBackgroundPlayer(activity, getPlayQueue(), false);
showHoldToAppendTipIfNeeded();
});
playlistControlBinding.playlistCtrlPlayPopupButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.POPUP);
return true;
});
playlistControlBinding.playlistCtrlPlayBgButton.setOnLongClickListener(view -> {
NavigationHelper.enqueueOnPlayer(activity, getPlayQueue(), PlayerType.AUDIO);
return true;
});
hideLoading(); hideLoading();
} }
private void showHoldToAppendTipIfNeeded() {
if (PreferenceManager.getDefaultSharedPreferences(activity)
.getBoolean(getString(R.string.show_hold_to_append_key), true)) {
Toast.makeText(activity, R.string.hold_to_append, Toast.LENGTH_SHORT).show();
}
}
/////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////
// Fragment Error Handling // Fragment Error Handling
/////////////////////////////////////////////////////////////////////////// ///////////////////////////////////////////////////////////////////////////
@ -853,7 +862,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
} }
} }
private PlayQueue getPlayQueue() { public PlayQueue getPlayQueue() {
return getPlayQueue(0); return getPlayQueue(0);
} }
@ -871,5 +880,29 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
} }
return new SinglePlayQueue(streamInfoItems, index); return new SinglePlayQueue(streamInfoItems, index);
} }
/**
* Creates a dialog to confirm whether the user wants to share the playlist
* with the playlist details or just the list of stream URLs.
* After the user has made a choice, the playlist is shared.
*/
private void createShareConfirmationDialog() {
new AlertDialog.Builder(requireContext())
.setTitle(R.string.share_playlist)
.setMessage(R.string.share_playlist_with_titles_message)
.setCancelable(true)
.setPositiveButton(R.string.share_playlist_with_titles, (dialog, which) ->
sharePlaylist(/* shouldSharePlaylistDetails= */ true)
)
.setNegativeButton(R.string.share_playlist_with_list, (dialog, which) ->
sharePlaylist(/* shouldSharePlaylistDetails= */ false)
)
.show();
}
public void setTabsPagerAdapter(
@Nullable final MainFragment.SelectedTabsPagerAdapter tabsPagerAdapter) {
this.tabsPagerAdapter = tabsPagerAdapter;
}
} }

View file

@ -115,6 +115,11 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
feedGroupsCarouselState = feedGroupsCarousel.onSaveInstanceState() feedGroupsCarouselState = feedGroupsCarousel.onSaveInstanceState()
} }
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
disposables.dispose() disposables.dispose()
@ -336,8 +341,7 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
val actions = DialogInterface.OnClickListener { _, i -> val actions = DialogInterface.OnClickListener { _, i ->
when (i) { when (i) {
0 -> ShareUtils.shareText( 0 -> ShareUtils.shareText(
requireContext(), selectedItem.name, selectedItem.url, requireContext(), selectedItem.name, selectedItem.url, selectedItem.thumbnails
selectedItem.thumbnailUrl
) )
1 -> ShareUtils.openUrlInBrowser(requireContext(), selectedItem.url) 1 -> ShareUtils.openUrlInBrowser(requireContext(), selectedItem.url)
2 -> deleteChannel(selectedItem) 2 -> deleteChannel(selectedItem)

View file

@ -1,6 +1,7 @@
package org.schabi.newpipe.local.subscription package org.schabi.newpipe.local.subscription
import android.content.Context import android.content.Context
import android.util.Pair
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Completable import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable import io.reactivex.rxjava3.core.Flowable
@ -11,12 +12,13 @@ import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.subscription.NotificationMode import org.schabi.newpipe.database.subscription.NotificationMode
import org.schabi.newpipe.database.subscription.SubscriptionDAO import org.schabi.newpipe.database.subscription.SubscriptionDAO
import org.schabi.newpipe.database.subscription.SubscriptionEntity import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.extractor.ListInfo
import org.schabi.newpipe.extractor.channel.ChannelInfo import org.schabi.newpipe.extractor.channel.ChannelInfo
import org.schabi.newpipe.extractor.feed.FeedInfo import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.local.feed.FeedDatabaseManager import org.schabi.newpipe.local.feed.FeedDatabaseManager
import org.schabi.newpipe.local.feed.service.FeedUpdateInfo
import org.schabi.newpipe.util.ExtractorHelper import org.schabi.newpipe.util.ExtractorHelper
import org.schabi.newpipe.util.image.ImageStrategy
class SubscriptionManager(context: Context) { class SubscriptionManager(context: Context) {
private val database = NewPipeDatabase.getInstance(context) private val database = NewPipeDatabase.getInstance(context)
@ -46,28 +48,38 @@ class SubscriptionManager(context: Context) {
} }
} }
fun upsertAll(infoList: List<ChannelInfo>): List<SubscriptionEntity> { fun upsertAll(infoList: List<Pair<ChannelInfo, List<ChannelTabInfo>>>): List<SubscriptionEntity> {
val listEntities = subscriptionTable.upsertAll( val listEntities = subscriptionTable.upsertAll(
infoList.map { SubscriptionEntity.from(it) } infoList.map { SubscriptionEntity.from(it.first) }
) )
database.runInTransaction { database.runInTransaction {
infoList.forEachIndexed { index, info -> infoList.forEachIndexed { index, info ->
feedDatabaseManager.upsertAll(listEntities[index].uid, info.relatedItems) info.second.forEach {
feedDatabaseManager.upsertAll(
listEntities[index].uid,
it.relatedItems.filterIsInstance<StreamInfoItem>()
)
}
} }
} }
return listEntities return listEntities
} }
fun updateChannelInfo(info: ChannelInfo): Completable = subscriptionTable.getSubscription(info.serviceId, info.url) fun updateChannelInfo(info: ChannelInfo): Completable =
.flatMapCompletable { subscriptionTable.getSubscription(info.serviceId, info.url)
Completable.fromRunnable { .flatMapCompletable {
it.setData(info.name, info.avatarUrl, info.description, info.subscriberCount) Completable.fromRunnable {
subscriptionTable.update(it) it.setData(
feedDatabaseManager.upsertAll(it.uid, info.relatedItems) info.name,
ImageStrategy.imageListToDbUrl(info.avatars),
info.description,
info.subscriberCount
)
subscriptionTable.update(it)
}
} }
}
fun updateNotificationMode(serviceId: Int, url: String, @NotificationMode mode: Int): Completable { fun updateNotificationMode(serviceId: Int, url: String, @NotificationMode mode: Int): Completable {
return subscriptionTable().getSubscription(serviceId, url) return subscriptionTable().getSubscription(serviceId, url)
@ -84,19 +96,15 @@ class SubscriptionManager(context: Context) {
} }
} }
fun updateFromInfo(subscriptionId: Long, info: ListInfo<StreamInfoItem>) { fun updateFromInfo(info: FeedUpdateInfo) {
val subscriptionEntity = subscriptionTable.getSubscription(subscriptionId) val subscriptionEntity = subscriptionTable.getSubscription(info.uid)
if (info is FeedInfo) { subscriptionEntity.name = info.name
subscriptionEntity.name = info.name subscriptionEntity.avatarUrl = info.avatarUrl
} else if (info is ChannelInfo) {
subscriptionEntity.setData( // these two fields are null if the feed info was fetched using the fast feed method
info.name, info.description?.let { subscriptionEntity.description = it }
info.avatarUrl, info.subscriberCount?.let { subscriptionEntity.subscriberCount = it }
info.description,
info.subscriberCount
)
}
subscriptionTable.update(subscriptionEntity) subscriptionTable.update(subscriptionEntity)
} }
@ -107,11 +115,8 @@ class SubscriptionManager(context: Context) {
.observeOn(AndroidSchedulers.mainThread()) .observeOn(AndroidSchedulers.mainThread())
} }
fun insertSubscription(subscriptionEntity: SubscriptionEntity, info: ChannelInfo) { fun insertSubscription(subscriptionEntity: SubscriptionEntity) {
database.runInTransaction { subscriptionTable.insert(subscriptionEntity)
val subscriptionId = subscriptionTable.insert(subscriptionEntity)
feedDatabaseManager.upsertAll(subscriptionId, info.relatedItems)
}
} }
fun deleteSubscription(subscriptionEntity: SubscriptionEntity) { fun deleteSubscription(subscriptionEntity: SubscriptionEntity) {
@ -125,7 +130,10 @@ class SubscriptionManager(context: Context) {
*/ */
private fun rememberAllStreams(subscription: SubscriptionEntity): Completable { private fun rememberAllStreams(subscription: SubscriptionEntity): Completable {
return ExtractorHelper.getChannelInfo(subscription.serviceId, subscription.url, false) return ExtractorHelper.getChannelInfo(subscription.serviceId, subscription.url, false)
.map { channel -> channel.relatedItems.map { stream -> StreamEntity(stream) } } .flatMap { info ->
ExtractorHelper.getChannelTab(subscription.serviceId, info.tabs.first(), false)
}
.map { channel -> channel.relatedItems.filterIsInstance<StreamInfoItem>().map { stream -> StreamEntity(stream) } }
.flatMapCompletable { entities -> .flatMapCompletable { entities ->
Completable.fromAction { Completable.fromAction {
database.streamDAO().upsertAll(entities) database.streamDAO().upsertAll(entities)

View file

@ -55,10 +55,10 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
private var groupSortOrder: Long = -1 private var groupSortOrder: Long = -1
sealed class ScreenState : Serializable { sealed class ScreenState : Serializable {
object InitialScreen : ScreenState() data object InitialScreen : ScreenState()
object IconPickerScreen : ScreenState() data object IconPickerScreen : ScreenState()
object SubscriptionsPickerScreen : ScreenState() data object SubscriptionsPickerScreen : ScreenState()
object DeleteScreen : ScreenState() data object DeleteScreen : ScreenState()
} }
@State @JvmField var selectedIcon: FeedGroupIcon? = null @State @JvmField var selectedIcon: FeedGroupIcon? = null
@ -370,7 +370,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
private fun setupIconPicker() { private fun setupIconPicker() {
val groupAdapter = GroupieAdapter() val groupAdapter = GroupieAdapter()
groupAdapter.addAll(FeedGroupIcon.values().map { PickerIconItem(it) }) groupAdapter.addAll(FeedGroupIcon.entries.map { PickerIconItem(it) })
feedGroupCreateBinding.iconSelector.apply { feedGroupCreateBinding.iconSelector.apply {
layoutManager = GridLayoutManager(requireContext(), 7, RecyclerView.VERTICAL, false) layoutManager = GridLayoutManager(requireContext(), 7, RecyclerView.VERTICAL, false)

View file

@ -110,8 +110,8 @@ class FeedGroupDialogViewModel(
} }
sealed class DialogEvent { sealed class DialogEvent {
object ProcessingEvent : DialogEvent() data object ProcessingEvent : DialogEvent()
object SuccessEvent : DialogEvent() data object SuccessEvent : DialogEvent()
} }
data class Filter(val query: String, val showOnlyUngrouped: Boolean) data class Filter(val query: String, val showOnlyUngrouped: Boolean)

View file

@ -9,7 +9,7 @@ import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.channel.ChannelInfoItem import org.schabi.newpipe.extractor.channel.ChannelInfoItem
import org.schabi.newpipe.util.Localization import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.OnClickGesture import org.schabi.newpipe.util.OnClickGesture
import org.schabi.newpipe.util.PicassoHelper import org.schabi.newpipe.util.image.PicassoHelper
class ChannelItem( class ChannelItem(
private val infoItem: ChannelInfoItem, private val infoItem: ChannelInfoItem,
@ -39,7 +39,7 @@ class ChannelItem(
itemChannelDescriptionView.text = infoItem.description itemChannelDescriptionView.text = infoItem.description
} }
PicassoHelper.loadAvatar(infoItem.thumbnailUrl).into(itemThumbnailView) PicassoHelper.loadAvatar(infoItem.thumbnails).into(itemThumbnailView)
gesturesListener?.run { gesturesListener?.run {
viewHolder.root.setOnClickListener { selected(infoItem) } viewHolder.root.setOnClickListener { selected(infoItem) }

View file

@ -10,7 +10,7 @@ import org.schabi.newpipe.database.subscription.SubscriptionEntity
import org.schabi.newpipe.databinding.PickerSubscriptionItemBinding import org.schabi.newpipe.databinding.PickerSubscriptionItemBinding
import org.schabi.newpipe.ktx.AnimationType import org.schabi.newpipe.ktx.AnimationType
import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.ktx.animate
import org.schabi.newpipe.util.PicassoHelper import org.schabi.newpipe.util.image.PicassoHelper
data class PickerSubscriptionItem( data class PickerSubscriptionItem(
val subscriptionEntity: SubscriptionEntity, val subscriptionEntity: SubscriptionEntity,

View file

@ -25,6 +25,7 @@ import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.util.Log; import android.util.Log;
import androidx.core.content.IntentCompat;
import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.reactivestreams.Subscriber; import org.reactivestreams.Subscriber;
@ -65,7 +66,7 @@ public class SubscriptionsExportService extends BaseImportExportService {
return START_NOT_STICKY; return START_NOT_STICKY;
} }
final Uri path = intent.getParcelableExtra(KEY_FILE_PATH); final Uri path = IntentCompat.getParcelableExtra(intent, KEY_FILE_PATH, Uri.class);
if (path == null) { if (path == null) {
stopAndReportError(new IllegalStateException( stopAndReportError(new IllegalStateException(
"Exporting to a file, but the path is null"), "Exporting to a file, but the path is null"),

View file

@ -26,9 +26,11 @@ import android.content.Intent;
import android.net.Uri; import android.net.Uri;
import android.text.TextUtils; import android.text.TextUtils;
import android.util.Log; import android.util.Log;
import android.util.Pair;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import androidx.core.content.IntentCompat;
import androidx.localbroadcastmanager.content.LocalBroadcastManager; import androidx.localbroadcastmanager.content.LocalBroadcastManager;
import org.reactivestreams.Subscriber; import org.reactivestreams.Subscriber;
@ -38,6 +40,7 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.SubscriptionEntity; import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.extractor.NewPipe; import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.channel.ChannelInfo; import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo;
import org.schabi.newpipe.extractor.subscription.SubscriptionItem; import org.schabi.newpipe.extractor.subscription.SubscriptionItem;
import org.schabi.newpipe.ktx.ExceptionUtils; import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.streams.io.SharpInputStream; import org.schabi.newpipe.streams.io.SharpInputStream;
@ -48,6 +51,7 @@ import org.schabi.newpipe.util.ExtractorHelper;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
@ -105,7 +109,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
if (currentMode == CHANNEL_URL_MODE) { if (currentMode == CHANNEL_URL_MODE) {
channelUrl = intent.getStringExtra(KEY_VALUE); channelUrl = intent.getStringExtra(KEY_VALUE);
} else { } else {
final Uri uri = intent.getParcelableExtra(KEY_VALUE); final Uri uri = IntentCompat.getParcelableExtra(intent, KEY_VALUE, Uri.class);
if (uri == null) { if (uri == null) {
stopAndReportError(new IllegalStateException( stopAndReportError(new IllegalStateException(
"Importing from input stream, but file path is null"), "Importing from input stream, but file path is null"),
@ -199,12 +203,19 @@ public class SubscriptionsImportService extends BaseImportExportService {
.parallel(PARALLEL_EXTRACTIONS) .parallel(PARALLEL_EXTRACTIONS)
.runOn(Schedulers.io()) .runOn(Schedulers.io())
.map((Function<SubscriptionItem, Notification<ChannelInfo>>) subscriptionItem -> { .map((Function<SubscriptionItem, Notification<Pair<ChannelInfo,
List<ChannelTabInfo>>>>) subscriptionItem -> {
try { try {
return Notification.createOnNext(ExtractorHelper final ChannelInfo channelInfo = ExtractorHelper
.getChannelInfo(subscriptionItem.getServiceId(), .getChannelInfo(subscriptionItem.getServiceId(),
subscriptionItem.getUrl(), true) subscriptionItem.getUrl(), true)
.blockingGet()); .blockingGet();
return Notification.createOnNext(new Pair<>(channelInfo,
Collections.singletonList(
ExtractorHelper.getChannelTab(
subscriptionItem.getServiceId(),
channelInfo.getTabs().get(0), true).blockingGet()
)));
} catch (final Throwable e) { } catch (final Throwable e) {
return Notification.createOnError(e); return Notification.createOnError(e);
} }
@ -223,7 +234,7 @@ public class SubscriptionsImportService extends BaseImportExportService {
} }
private Subscriber<List<SubscriptionEntity>> getSubscriber() { private Subscriber<List<SubscriptionEntity>> getSubscriber() {
return new Subscriber<List<SubscriptionEntity>>() { return new Subscriber<>() {
@Override @Override
public void onSubscribe(final Subscription s) { public void onSubscribe(final Subscription s) {
subscription = s; subscription = s;
@ -254,10 +265,11 @@ public class SubscriptionsImportService extends BaseImportExportService {
}; };
} }
private Consumer<Notification<ChannelInfo>> getNotificationsConsumer() { private Consumer<Notification<Pair<ChannelInfo,
List<ChannelTabInfo>>>> getNotificationsConsumer() {
return notification -> { return notification -> {
if (notification.isOnNext()) { if (notification.isOnNext()) {
final String name = notification.getValue().getName(); final String name = notification.getValue().first.getName();
eventListener.onItemCompleted(!TextUtils.isEmpty(name) ? name : ""); eventListener.onItemCompleted(!TextUtils.isEmpty(name) ? name : "");
} else if (notification.isOnError()) { } else if (notification.isOnError()) {
final Throwable error = notification.getError(); final Throwable error = notification.getError();
@ -275,10 +287,12 @@ public class SubscriptionsImportService extends BaseImportExportService {
}; };
} }
private Function<List<Notification<ChannelInfo>>, List<SubscriptionEntity>> upsertBatch() { private Function<List<Notification<Pair<ChannelInfo, List<ChannelTabInfo>>>>,
List<SubscriptionEntity>> upsertBatch() {
return notificationList -> { return notificationList -> {
final List<ChannelInfo> infoList = new ArrayList<>(notificationList.size()); final List<Pair<ChannelInfo, List<ChannelTabInfo>>> infoList =
for (final Notification<ChannelInfo> n : notificationList) { new ArrayList<>(notificationList.size());
for (final Notification<Pair<ChannelInfo, List<ChannelTabInfo>>> n : notificationList) {
if (n.isOnNext()) { if (n.isOnNext()) {
infoList.add(n.getValue()); infoList.add(n.getValue());
} }

View file

@ -18,6 +18,7 @@ import android.view.MenuItem;
import android.view.SubMenu; import android.view.SubMenu;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.SeekBar; import android.widget.SeekBar;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
@ -542,18 +543,19 @@ public final class PlayQueueActivity extends AppCompatActivity
//////////////////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////////////////
private void onStateChanged(final int state) { private void onStateChanged(final int state) {
final ImageButton playPauseButton = queueControlBinding.controlPlayPause;
switch (state) { switch (state) {
case Player.STATE_PAUSED: case Player.STATE_PAUSED:
queueControlBinding.controlPlayPause playPauseButton.setImageResource(R.drawable.ic_play_arrow);
.setImageResource(R.drawable.ic_play_arrow); playPauseButton.setContentDescription(getString(R.string.play));
break; break;
case Player.STATE_PLAYING: case Player.STATE_PLAYING:
queueControlBinding.controlPlayPause playPauseButton.setImageResource(R.drawable.ic_pause);
.setImageResource(R.drawable.ic_pause); playPauseButton.setContentDescription(getString(R.string.pause));
break; break;
case Player.STATE_COMPLETED: case Player.STATE_COMPLETED:
queueControlBinding.controlPlayPause playPauseButton.setImageResource(R.drawable.ic_replay);
.setImageResource(R.drawable.ic_replay); playPauseButton.setContentDescription(getString(R.string.replay));
break; break;
default: default:
break; break;
@ -596,11 +598,9 @@ public final class PlayQueueActivity extends AppCompatActivity
} }
private void onPlaybackParameterChanged(@Nullable final PlaybackParameters parameters) { private void onPlaybackParameterChanged(@Nullable final PlaybackParameters parameters) {
if (parameters != null) { if (parameters != null && menu != null && player != null) {
if (menu != null && player != null) { final MenuItem item = menu.findItem(R.id.action_playback_speed);
final MenuItem item = menu.findItem(R.id.action_playback_speed); item.setTitle(formatSpeed(parameters.speed));
item.setTitle(formatSpeed(parameters.speed));
}
} }
} }
@ -630,11 +630,13 @@ public final class PlayQueueActivity extends AppCompatActivity
final MenuItem audioTrackSelector = menu.findItem(R.id.action_audio_track); final MenuItem audioTrackSelector = menu.findItem(R.id.action_audio_track);
final List<AudioStream> availableStreams = final List<AudioStream> availableStreams =
Optional.ofNullable(player.getCurrentMetadata()) Optional.ofNullable(player)
.map(Player::getCurrentMetadata)
.flatMap(MediaItemTag::getMaybeAudioTrack) .flatMap(MediaItemTag::getMaybeAudioTrack)
.map(MediaItemTag.AudioTrack::getAudioStreams) .map(MediaItemTag.AudioTrack::getAudioStreams)
.orElse(null); .orElse(null);
final Optional<AudioStream> selectedAudioStream = player.getSelectedAudioStream(); final Optional<AudioStream> selectedAudioStream = Optional.ofNullable(player)
.flatMap(Player::getSelectedAudioStream);
if (availableStreams == null || availableStreams.size() < 2 if (availableStreams == null || availableStreams.size() < 2
|| selectedAudioStream.isEmpty()) { || selectedAudioStream.isEmpty()) {

View file

@ -89,6 +89,7 @@ import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.ErrorUtil; import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.UserAction; import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.stream.AudioStream; import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.Image;
import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.extractor.stream.VideoStream;
@ -119,7 +120,7 @@ import org.schabi.newpipe.player.ui.VideoPlayerUi;
import org.schabi.newpipe.util.DependentPreferenceHelper; import org.schabi.newpipe.util.DependentPreferenceHelper;
import org.schabi.newpipe.util.ListHelper; import org.schabi.newpipe.util.ListHelper;
import org.schabi.newpipe.util.NavigationHelper; import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.SerializedCache; import org.schabi.newpipe.util.SerializedCache;
import org.schabi.newpipe.util.SponsorBlockMode; import org.schabi.newpipe.util.SponsorBlockMode;
import org.schabi.newpipe.util.StreamTypeUtil; import org.schabi.newpipe.util.StreamTypeUtil;
@ -818,10 +819,10 @@ public final class Player implements PlaybackListener, Listener {
}; };
} }
private void loadCurrentThumbnail(final String url) { private void loadCurrentThumbnail(final List<Image> thumbnails) {
if (DEBUG) { if (DEBUG) {
Log.d(TAG, "Thumbnail - loadCurrentThumbnail() called with url = [" Log.d(TAG, "Thumbnail - loadCurrentThumbnail() called with thumbnails = ["
+ (url == null ? "null" : url) + "]"); + thumbnails.size() + "]");
} }
// first cancel any previous loading // first cancel any previous loading
@ -830,12 +831,12 @@ public final class Player implements PlaybackListener, Listener {
// Unset currentThumbnail, since it is now outdated. This ensures it is not used in media // Unset currentThumbnail, since it is now outdated. This ensures it is not used in media
// session metadata while the new thumbnail is being loaded by Picasso. // session metadata while the new thumbnail is being loaded by Picasso.
onThumbnailLoaded(null); onThumbnailLoaded(null);
if (isNullOrEmpty(url)) { if (thumbnails.isEmpty()) {
return; return;
} }
// scale down the notification thumbnail for performance // scale down the notification thumbnail for performance
PicassoHelper.loadScaledDownThumbnail(context, url) PicassoHelper.loadScaledDownThumbnail(context, thumbnails)
.tag(PICASSO_PLAYER_THUMBNAIL_TAG) .tag(PICASSO_PLAYER_THUMBNAIL_TAG)
.into(currentThumbnailTarget); .into(currentThumbnailTarget);
} }
@ -1180,7 +1181,7 @@ public final class Player implements PlaybackListener, Listener {
UIs.call(PlayerUi::onPrepared); UIs.call(PlayerUi::onPrepared);
if (playWhenReady) { if (playWhenReady && !isMuted()) {
audioReactor.requestAudioFocus(); audioReactor.requestAudioFocus();
} }
} }
@ -1321,6 +1322,11 @@ public final class Player implements PlaybackListener, Listener {
public void toggleMute() { public void toggleMute() {
final boolean wasMuted = isMuted(); final boolean wasMuted = isMuted();
simpleExoPlayer.setVolume(wasMuted ? 1 : 0); simpleExoPlayer.setVolume(wasMuted ? 1 : 0);
if (wasMuted) {
audioReactor.requestAudioFocus();
} else {
audioReactor.abandonAudioFocus();
}
UIs.call(playerUi -> playerUi.onMuteUnmuteChanged(!wasMuted)); UIs.call(playerUi -> playerUi.onMuteUnmuteChanged(!wasMuted));
notifyPlaybackUpdateToListeners(); notifyPlaybackUpdateToListeners();
} }
@ -1718,7 +1724,9 @@ public final class Player implements PlaybackListener, Listener {
return; return;
} }
audioReactor.requestAudioFocus(); if (!isMuted()) {
audioReactor.requestAudioFocus();
}
if (currentState == STATE_COMPLETED) { if (currentState == STATE_COMPLETED) {
if (playQueue.getIndex() == 0) { if (playQueue.getIndex() == 0) {
@ -1883,7 +1891,7 @@ public final class Player implements PlaybackListener, Listener {
maybeAutoQueueNextStream(info); maybeAutoQueueNextStream(info);
loadCurrentThumbnail(info.getThumbnailUrl()); loadCurrentThumbnail(info.getThumbnails());
registerStreamViewed(); registerStreamViewed();
notifyMetadataUpdateToListeners(); notifyMetadataUpdateToListeners();

View file

@ -29,6 +29,7 @@ import android.os.IBinder;
import android.util.Log; import android.util.Log;
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi; import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
import org.schabi.newpipe.player.notification.NotificationPlayerUi;
import org.schabi.newpipe.util.ThemeHelper; import org.schabi.newpipe.util.ThemeHelper;
import java.lang.ref.WeakReference; import java.lang.ref.WeakReference;
@ -59,6 +60,14 @@ public final class PlayerService extends Service {
ThemeHelper.setTheme(this); ThemeHelper.setTheme(this);
player = new Player(this); player = new Player(this);
/*
Create the player notification and start immediately the service in foreground,
otherwise if nothing is played or initializing the player and its components (especially
loading stream metadata) takes a lot of time, the app would crash on Android 8+ as the
service would never be put in the foreground while we said to the system we would do so
*/
player.UIs().get(NotificationPlayerUi.class)
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
} }
@Override @Override
@ -68,16 +77,38 @@ public final class PlayerService extends Service {
+ "], flags = [" + flags + "], startId = [" + startId + "]"); + "], flags = [" + flags + "], startId = [" + startId + "]");
} }
/*
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
*/
if (player != null) {
player.UIs().get(NotificationPlayerUi.class)
.ifPresent(NotificationPlayerUi::createNotificationAndStartForeground);
}
if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction()) if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
&& player.getPlayQueue() == null) { && (player == null || player.getPlayQueue() == null)) {
// No need to process media button's actions if the player is not working, otherwise the /*
// player service would strangely start with nothing to play No need to process media button's actions if the player is not working, otherwise
the player service would strangely start with nothing to play
Stop the service in this case, which will be removed from the foreground and its
notification cancelled in its destruction
*/
stopSelf();
return START_NOT_STICKY; return START_NOT_STICKY;
} }
player.handleIntent(intent); if (player != null) {
player.UIs().get(MediaSessionPlayerUi.class) player.handleIntent(intent);
.ifPresent(ui -> ui.handleMediaButtonIntent(intent)); player.UIs().get(MediaSessionPlayerUi.class)
.ifPresent(ui -> ui.handleMediaButtonIntent(intent));
}
return START_NOT_STICKY; return START_NOT_STICKY;
} }
@ -87,7 +118,7 @@ public final class PlayerService extends Service {
Log.d(TAG, "stopForImmediateReusing() called"); Log.d(TAG, "stopForImmediateReusing() called");
} }
if (!player.exoPlayerIsNull()) { if (player != null && !player.exoPlayerIsNull()) {
// Releases wifi & cpu, disables keepScreenOn, etc. // Releases wifi & cpu, disables keepScreenOn, etc.
// We can't just pause the player here because it will make transition // We can't just pause the player here because it will make transition
// from one stream to a new stream not smooth // from one stream to a new stream not smooth
@ -98,7 +129,7 @@ public final class PlayerService extends Service {
@Override @Override
public void onTaskRemoved(final Intent rootIntent) { public void onTaskRemoved(final Intent rootIntent) {
super.onTaskRemoved(rootIntent); super.onTaskRemoved(rootIntent);
if (!player.videoPlayerSelected()) { if (player != null && !player.videoPlayerSelected()) {
return; return;
} }
onDestroy(); onDestroy();

View file

@ -7,7 +7,6 @@ import android.view.View.OnTouchListener
import android.widget.ProgressBar import android.widget.ProgressBar
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.content.res.AppCompatResources
import androidx.core.math.MathUtils
import androidx.core.view.isVisible import androidx.core.view.isVisible
import org.schabi.newpipe.MainActivity import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R import org.schabi.newpipe.R
@ -113,7 +112,7 @@ class MainPlayerGestureListener(
// Update progress bar // Update progress bar
val oldBrightness = layoutParams.screenBrightness val oldBrightness = layoutParams.screenBrightness
bar.progress = (bar.max * MathUtils.clamp(oldBrightness, 0f, 1f)).toInt() bar.progress = (bar.max * oldBrightness.coerceIn(0f, 1f)).toInt()
bar.incrementProgressBy(distanceY.toInt()) bar.incrementProgressBy(distanceY.toInt())
// Update brightness // Update brightness
@ -161,13 +160,12 @@ class MainPlayerGestureListener(
} }
override fun onScroll( override fun onScroll(
initialEvent: MotionEvent, initialEvent: MotionEvent?,
movingEvent: MotionEvent, movingEvent: MotionEvent,
distanceX: Float, distanceX: Float,
distanceY: Float distanceY: Float
): Boolean { ): Boolean {
if (initialEvent == null || !playerUi.isFullscreen) {
if (!playerUi.isFullscreen) {
return false return false
} }

View file

@ -4,7 +4,7 @@ import android.util.Log
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.ViewConfiguration import android.view.ViewConfiguration
import androidx.core.math.MathUtils import androidx.core.view.isVisible
import org.schabi.newpipe.MainActivity import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.ktx.AnimationType import org.schabi.newpipe.ktx.AnimationType
import org.schabi.newpipe.ktx.animate import org.schabi.newpipe.ktx.animate
@ -167,7 +167,7 @@ class PopupPlayerGestureListener(
} }
override fun onFling( override fun onFling(
e1: MotionEvent, e1: MotionEvent?,
e2: MotionEvent, e2: MotionEvent,
velocityX: Float, velocityX: Float,
velocityY: Float velocityY: Float
@ -218,11 +218,14 @@ class PopupPlayerGestureListener(
} }
override fun onScroll( override fun onScroll(
initialEvent: MotionEvent, initialEvent: MotionEvent?,
movingEvent: MotionEvent, movingEvent: MotionEvent,
distanceX: Float, distanceX: Float,
distanceY: Float distanceY: Float
): Boolean { ): Boolean {
if (initialEvent == null) {
return false
}
if (isResizing) { if (isResizing) {
return super.onScroll(initialEvent, movingEvent, distanceX, distanceY) return super.onScroll(initialEvent, movingEvent, distanceX, distanceY)
@ -235,14 +238,16 @@ class PopupPlayerGestureListener(
isMoving = true isMoving = true
val diffX = (movingEvent.rawX - initialEvent.rawX) val diffX = (movingEvent.rawX - initialEvent.rawX)
val posX = MathUtils.clamp( val posX = (initialPopupX + diffX).coerceIn(
initialPopupX + diffX, 0f,
0f, (playerUi.screenWidth - playerUi.popupLayoutParams.width).toFloat() (playerUi.screenWidth - playerUi.popupLayoutParams.width).toFloat()
.coerceAtLeast(0f)
) )
val diffY = (movingEvent.rawY - initialEvent.rawY) val diffY = (movingEvent.rawY - initialEvent.rawY)
val posY = MathUtils.clamp( val posY = (initialPopupY + diffY).coerceIn(
initialPopupY + diffY, 0f,
0f, (playerUi.screenHeight - playerUi.popupLayoutParams.height).toFloat() (playerUi.screenHeight - playerUi.popupLayoutParams.height).toFloat()
.coerceAtLeast(0f)
) )
playerUi.popupLayoutParams.x = posX.toInt() playerUi.popupLayoutParams.x = posX.toInt()
@ -251,8 +256,7 @@ class PopupPlayerGestureListener(
// -- Determine if the ClosingOverlayView (red X) has to be shown or hidden -- // -- Determine if the ClosingOverlayView (red X) has to be shown or hidden --
val showClosingOverlayView: Boolean = playerUi.isInsideClosingRadius(movingEvent) val showClosingOverlayView: Boolean = playerUi.isInsideClosingRadius(movingEvent)
// Check if an view is in expected state and if not animate it into the correct state // Check if an view is in expected state and if not animate it into the correct state
val expectedVisibility = if (showClosingOverlayView) View.VISIBLE else View.GONE if (binding.closingOverlay.isVisible != showClosingOverlayView) {
if (binding.closingOverlay.visibility != expectedVisibility) {
binding.closingOverlay.animate(showClosingOverlayView, 200) binding.closingOverlay.animate(showClosingOverlayView, 200)
} }

View file

@ -3,6 +3,7 @@ package org.schabi.newpipe.player.mediaitem;
import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.util.image.ImageStrategy;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -74,7 +75,7 @@ public final class ExceptionTag implements MediaItemTag {
@Override @Override
public String getThumbnailUrl() { public String getThumbnailUrl() {
return item.getThumbnailUrl(); return ImageStrategy.choosePreferredImage(item.getThumbnails());
} }
@Override @Override

View file

@ -81,8 +81,9 @@ public interface MediaItemTag {
@NonNull @NonNull
default MediaItem asMediaItem() { default MediaItem asMediaItem() {
final String thumbnailUrl = getThumbnailUrl();
final MediaMetadata mediaMetadata = new MediaMetadata.Builder() final MediaMetadata mediaMetadata = new MediaMetadata.Builder()
.setArtworkUri(Uri.parse(getThumbnailUrl())) .setArtworkUri(thumbnailUrl == null ? null : Uri.parse(thumbnailUrl))
.setArtist(getUploaderName()) .setArtist(getUploaderName())
.setDescription(getTitle()) .setDescription(getTitle())
.setDisplayTitle(getTitle()) .setDisplayTitle(getTitle())

View file

@ -6,6 +6,7 @@ import org.schabi.newpipe.extractor.stream.AudioStream;
import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.StreamType;
import org.schabi.newpipe.extractor.stream.VideoStream; import org.schabi.newpipe.extractor.stream.VideoStream;
import org.schabi.newpipe.util.image.ImageStrategy;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@ -95,7 +96,7 @@ public final class StreamInfoTag implements MediaItemTag {
@Override @Override
public String getThumbnailUrl() { public String getThumbnailUrl() {
return streamInfo.getThumbnailUrl(); return ImageStrategy.choosePreferredImage(streamInfo.getThumbnails());
} }
@Override @Override

View file

@ -20,6 +20,7 @@ import com.google.android.exoplayer2.util.Util;
import org.schabi.newpipe.player.Player; import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.playqueue.PlayQueue; import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueItem; import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.util.image.ImageStrategy;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
@ -137,9 +138,12 @@ public class PlayQueueNavigator implements MediaSessionConnector.QueueNavigator
.putLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS, player.getPlayQueue().size()); .putLong(MediaMetadataCompat.METADATA_KEY_NUM_TRACKS, player.getPlayQueue().size());
descBuilder.setExtras(additionalMetadata); descBuilder.setExtras(additionalMetadata);
final Uri thumbnailUri = Uri.parse(item.getThumbnailUrl()); try {
if (thumbnailUri != null) { descBuilder.setIconUri(Uri.parse(
descBuilder.setIconUri(thumbnailUri); ImageStrategy.choosePreferredImage(item.getThumbnails())));
} catch (final Throwable e) {
// no thumbnail available at all, or the user disabled image loading,
// or the obtained url is not a valid `Uri`
} }
return descBuilder.build(); return descBuilder.build();

View file

@ -17,7 +17,6 @@ import org.schabi.newpipe.player.helper.PlayerHelper;
import org.schabi.newpipe.player.ui.PlayerUi; import org.schabi.newpipe.player.ui.PlayerUi;
public final class NotificationPlayerUi extends PlayerUi { public final class NotificationPlayerUi extends PlayerUi {
private boolean foregroundNotificationAlreadyCreated = false;
private final NotificationUtil notificationUtil; private final NotificationUtil notificationUtil;
public NotificationPlayerUi(@NonNull final Player player) { public NotificationPlayerUi(@NonNull final Player player) {
@ -25,15 +24,6 @@ public final class NotificationPlayerUi extends PlayerUi {
notificationUtil = new NotificationUtil(player); notificationUtil = new NotificationUtil(player);
} }
@Override
public void initPlayer() {
super.initPlayer();
if (!foregroundNotificationAlreadyCreated) {
notificationUtil.createNotificationAndStartForeground();
foregroundNotificationAlreadyCreated = true;
}
}
@Override @Override
public void destroy() { public void destroy() {
super.destroy(); super.destroy();
@ -122,4 +112,8 @@ public final class NotificationPlayerUi extends PlayerUi {
super.onPlayQueueEdited(); super.onPlayQueueEdited();
notificationUtil.createNotificationIfNeededAndUpdate(false); notificationUtil.createNotificationIfNeededAndUpdate(false);
} }
public void createNotificationAndStartForeground() {
notificationUtil.createNotificationAndStartForeground();
}
} }

View file

@ -364,7 +364,7 @@ public final class NotificationUtil {
final Bitmap thumbnail = player.getThumbnail(); final Bitmap thumbnail = player.getThumbnail();
if (thumbnail == null || !showThumbnail) { if (thumbnail == null || !showThumbnail) {
// since the builder is reused, make sure the thumbnail is unset if there is not one // since the builder is reused, make sure the thumbnail is unset if there is not one
builder.setLargeIcon(null); builder.setLargeIcon((Bitmap) null);
return; return;
} }

View file

@ -4,6 +4,7 @@ import android.util.Log;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor; import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.ListInfo; import org.schabi.newpipe.extractor.ListInfo;
import org.schabi.newpipe.extractor.Page; import org.schabi.newpipe.extractor.Page;
@ -15,7 +16,7 @@ import java.util.stream.Collectors;
import io.reactivex.rxjava3.core.SingleObserver; import io.reactivex.rxjava3.core.SingleObserver;
import io.reactivex.rxjava3.disposables.Disposable; import io.reactivex.rxjava3.disposables.Disposable;
abstract class AbstractInfoPlayQueue<T extends ListInfo<StreamInfoItem>> abstract class AbstractInfoPlayQueue<T extends ListInfo<? extends InfoItem>>
extends PlayQueue { extends PlayQueue {
boolean isInitial; boolean isInitial;
private boolean isComplete; private boolean isComplete;
@ -27,7 +28,13 @@ abstract class AbstractInfoPlayQueue<T extends ListInfo<StreamInfoItem>>
private transient Disposable fetchReactor; private transient Disposable fetchReactor;
protected AbstractInfoPlayQueue(final T info) { protected AbstractInfoPlayQueue(final T info) {
this(info.getServiceId(), info.getUrl(), info.getNextPage(), info.getRelatedItems(), 0); this(info.getServiceId(), info.getUrl(), info.getNextPage(),
info.getRelatedItems()
.stream()
.filter(StreamInfoItem.class::isInstance)
.map(StreamInfoItem.class::cast)
.collect(Collectors.toList()),
0);
} }
protected AbstractInfoPlayQueue(final int serviceId, protected AbstractInfoPlayQueue(final int serviceId,
@ -72,7 +79,11 @@ abstract class AbstractInfoPlayQueue<T extends ListInfo<StreamInfoItem>>
} }
nextPage = result.getNextPage(); nextPage = result.getNextPage();
append(extractListItems(result.getRelatedItems())); append(extractListItems(result.getRelatedItems()
.stream()
.filter(StreamInfoItem.class::isInstance)
.map(StreamInfoItem.class::cast)
.collect(Collectors.toList())));
fetchReactor.dispose(); fetchReactor.dispose();
fetchReactor = null; fetchReactor = null;
@ -87,7 +98,7 @@ abstract class AbstractInfoPlayQueue<T extends ListInfo<StreamInfoItem>>
}; };
} }
SingleObserver<ListExtractor.InfoItemsPage<StreamInfoItem>> getNextPageObserver() { SingleObserver<ListExtractor.InfoItemsPage<? extends InfoItem>> getNextPageObserver() {
return new SingleObserver<>() { return new SingleObserver<>() {
@Override @Override
public void onSubscribe(@NonNull final Disposable d) { public void onSubscribe(@NonNull final Disposable d) {
@ -101,13 +112,17 @@ abstract class AbstractInfoPlayQueue<T extends ListInfo<StreamInfoItem>>
@Override @Override
public void onSuccess( public void onSuccess(
@NonNull final ListExtractor.InfoItemsPage<StreamInfoItem> result) { @NonNull final ListExtractor.InfoItemsPage<? extends InfoItem> result) {
if (!result.hasNextPage()) { if (!result.hasNextPage()) {
isComplete = true; isComplete = true;
} }
nextPage = result.getNextPage(); nextPage = result.getNextPage();
append(extractListItems(result.getItems())); append(extractListItems(result.getItems()
.stream()
.filter(StreamInfoItem.class::isInstance)
.map(StreamInfoItem.class::cast)
.collect(Collectors.toList())));
fetchReactor.dispose(); fetchReactor.dispose();
fetchReactor = null; fetchReactor = null;

View file

@ -1,47 +0,0 @@
package org.schabi.newpipe.player.playqueue;
import org.schabi.newpipe.extractor.Page;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.util.ExtractorHelper;
import java.util.List;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.schedulers.Schedulers;
public final class ChannelPlayQueue extends AbstractInfoPlayQueue<ChannelInfo> {
public ChannelPlayQueue(final ChannelInfo info) {
super(info);
}
public ChannelPlayQueue(final int serviceId,
final String url,
final Page nextPage,
final List<StreamInfoItem> streams,
final int index) {
super(serviceId, url, nextPage, streams, index);
}
@Override
protected String getTag() {
return "ChannelPlayQueue@" + Integer.toHexString(hashCode());
}
@Override
public void fetch() {
if (this.isInitial) {
ExtractorHelper.getChannelInfo(this.serviceId, this.baseUrl, false)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getHeadListObserver());
} else {
ExtractorHelper.getMoreChannelItems(this.serviceId, this.baseUrl, this.nextPage)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getNextPageObserver());
}
}
}

View file

@ -0,0 +1,53 @@
package org.schabi.newpipe.player.playqueue;
import org.schabi.newpipe.extractor.Page;
import org.schabi.newpipe.extractor.channel.tabs.ChannelTabInfo;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.util.ExtractorHelper;
import java.util.Collections;
import java.util.List;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.schedulers.Schedulers;
public final class ChannelTabPlayQueue extends AbstractInfoPlayQueue<ChannelTabInfo> {
final ListLinkHandler linkHandler;
public ChannelTabPlayQueue(final int serviceId,
final ListLinkHandler linkHandler,
final Page nextPage,
final List<StreamInfoItem> streams,
final int index) {
super(serviceId, linkHandler.getUrl(), nextPage, streams, index);
this.linkHandler = linkHandler;
}
public ChannelTabPlayQueue(final int serviceId,
final ListLinkHandler linkHandler) {
this(serviceId, linkHandler, null, Collections.emptyList(), 0);
}
@Override
protected String getTag() {
return "ChannelTabPlayQueue@" + Integer.toHexString(hashCode());
}
@Override
public void fetch() {
if (isInitial) {
ExtractorHelper.getChannelTab(this.serviceId, this.linkHandler, false)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getHeadListObserver());
} else {
ExtractorHelper.getMoreChannelTabItems(this.serviceId, this.linkHandler, this.nextPage)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(getNextPageObserver());
}
}
}

View file

@ -539,7 +539,8 @@ public abstract class PlayQueue implements Serializable {
public boolean equalStreamsAndIndex(@Nullable final PlayQueue other) { public boolean equalStreamsAndIndex(@Nullable final PlayQueue other) {
if (equalStreams(other)) { if (equalStreams(other)) {
return other.getIndex() == getIndex(); //noinspection ConstantConditions
return other.getIndex() == getIndex(); //NOSONAR: other is not null
} }
return false; return false;
} }

View file

@ -3,6 +3,7 @@ package org.schabi.newpipe.player.playqueue;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable; import androidx.annotation.Nullable;
import org.schabi.newpipe.extractor.Image;
import org.schabi.newpipe.extractor.stream.StreamInfo; import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamInfoItem; import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.extractor.stream.StreamType; import org.schabi.newpipe.extractor.stream.StreamType;
@ -10,6 +11,7 @@ import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.VideoSegment; import org.schabi.newpipe.util.VideoSegment;
import java.io.Serializable; import java.io.Serializable;
import java.util.List;
import io.reactivex.rxjava3.core.Single; import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.schedulers.Schedulers; import io.reactivex.rxjava3.schedulers.Schedulers;
@ -25,7 +27,7 @@ public class PlayQueueItem implements Serializable {
private final int serviceId; private final int serviceId;
private final long duration; private final long duration;
@NonNull @NonNull
private final String thumbnailUrl; private final List<Image> thumbnails;
@NonNull @NonNull
private final String uploader; private final String uploader;
private final String uploaderUrl; private final String uploaderUrl;
@ -41,7 +43,7 @@ public class PlayQueueItem implements Serializable {
PlayQueueItem(@NonNull final StreamInfo info) { PlayQueueItem(@NonNull final StreamInfo info) {
this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(), this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(),
info.getThumbnailUrl(), info.getUploaderName(), info.getThumbnails(), info.getUploaderName(),
info.getUploaderUrl(), info.getStreamType()); info.getUploaderUrl(), info.getStreamType());
if (info.getStartPosition() > 0) { if (info.getStartPosition() > 0) {
@ -51,20 +53,20 @@ public class PlayQueueItem implements Serializable {
PlayQueueItem(@NonNull final StreamInfoItem item) { PlayQueueItem(@NonNull final StreamInfoItem item) {
this(item.getName(), item.getUrl(), item.getServiceId(), item.getDuration(), this(item.getName(), item.getUrl(), item.getServiceId(), item.getDuration(),
item.getThumbnailUrl(), item.getUploaderName(), item.getThumbnails(), item.getUploaderName(),
item.getUploaderUrl(), item.getStreamType()); item.getUploaderUrl(), item.getStreamType());
} }
@SuppressWarnings("ParameterNumber") @SuppressWarnings("ParameterNumber")
private PlayQueueItem(@Nullable final String name, @Nullable final String url, private PlayQueueItem(@Nullable final String name, @Nullable final String url,
final int serviceId, final long duration, final int serviceId, final long duration,
@Nullable final String thumbnailUrl, @Nullable final String uploader, final List<Image> thumbnails, @Nullable final String uploader,
final String uploaderUrl, @NonNull final StreamType streamType) { final String uploaderUrl, @NonNull final StreamType streamType) {
this.title = name != null ? name : EMPTY_STRING; this.title = name != null ? name : EMPTY_STRING;
this.url = url != null ? url : EMPTY_STRING; this.url = url != null ? url : EMPTY_STRING;
this.serviceId = serviceId; this.serviceId = serviceId;
this.duration = duration; this.duration = duration;
this.thumbnailUrl = thumbnailUrl != null ? thumbnailUrl : EMPTY_STRING; this.thumbnails = thumbnails;
this.uploader = uploader != null ? uploader : EMPTY_STRING; this.uploader = uploader != null ? uploader : EMPTY_STRING;
this.uploaderUrl = uploaderUrl; this.uploaderUrl = uploaderUrl;
this.streamType = streamType; this.streamType = streamType;
@ -91,8 +93,8 @@ public class PlayQueueItem implements Serializable {
} }
@NonNull @NonNull
public String getThumbnailUrl() { public List<Image> getThumbnails() {
return thumbnailUrl; return thumbnails;
} }
@NonNull @NonNull

View file

@ -6,7 +6,7 @@ import android.view.MotionEvent;
import android.view.View; import android.view.View;
import org.schabi.newpipe.util.Localization; import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.PicassoHelper; import org.schabi.newpipe.util.image.PicassoHelper;
import org.schabi.newpipe.util.ServiceHelper; import org.schabi.newpipe.util.ServiceHelper;
public class PlayQueueItemBuilder { public class PlayQueueItemBuilder {
@ -33,7 +33,7 @@ public class PlayQueueItemBuilder {
holder.itemDurationView.setVisibility(View.GONE); holder.itemDurationView.setVisibility(View.GONE);
} }
PicassoHelper.loadThumbnail(item.getThumbnailUrl()).into(holder.itemThumbnailView); PicassoHelper.loadThumbnail(item.getThumbnails()).into(holder.itemThumbnailView);
holder.itemRoot.setOnClickListener(view -> { holder.itemRoot.setOnClickListener(view -> {
if (onItemClickListener != null) { if (onItemClickListener != null) {

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