1
0
mirror of https://github.com/TeamNewPipe/NewPipe synced 2025-01-04 22:40:32 +00:00

Merge branch 'refactor' into Playlist-Compose

# Conflicts:
#	app/build.gradle
#	app/src/main/java/org/schabi/newpipe/MainActivity.java
#	app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java
#	app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
#	app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.kt
#	app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.java
#	app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt
#	app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java
#	app/src/main/java/org/schabi/newpipe/paging/CommentsSource.kt
#	app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt
#	app/src/main/java/org/schabi/newpipe/ui/components/common/LoadingIndicator.kt
#	app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt
#	build.gradle
This commit is contained in:
Isira Seneviratne 2024-12-26 09:40:01 +05:30
commit 52a2accea9
220 changed files with 4513 additions and 2406 deletions

View File

@ -79,6 +79,6 @@ The [ktlint](https://github.com/pinterest/ktlint) plugin does the same job as ch
## Communication
* The #newpipe channel on Libera Chat (`ircs://irc.libera.chat:6697/newpipe`) has the core team and other developers in it. [Click here for webchat](https://web.libera.chat/#newpipe)!
* You can also use a Matrix account to join the NewPipe channel at [#newpipe:libera.chat](https://matrix.to/#/#newpipe:libera.chat). Some convenient clients, available both for phone and desktop, are listed at that link.
* You can post your suggestions, changes, ideas etc. on either GitHub or IRC.
* You can use a Matrix account to join the NewPipe channel at [#newpipe:matrix.newpipe-ev.de](https://matrix.to/#/#newpipe:matrix.newpipe-ev.de). Some convenient clients, available both for phone and desktop, are listed at that link.
* Alternatively, the #newpipe channel on Libera Chat (`ircs://irc.libera.chat:6697/newpipe`) can also be joined, as it is bridged to the Matrix room. [Click here for webchat](https://web.libera.chat/#newpipe)!
* You can post your suggestions, changes, ideas etc. on either GitHub or Matrix (including via IRC).

View File

@ -3,9 +3,9 @@ contact_links:
- name: ❓ Question
url: https://github.com/TeamNewPipe/NewPipe/discussions/new?category=questions
about: Ask about anything NewPipe-related
- name: 💬 Matrix
url: https://matrix.to/#/#newpipe:matrix.newpipe-ev.de
about: Chat with us via Matrix for quick Q/A
- name: 💬 IRC
url: https://web.libera.chat/#newpipe
about: Chat with us via IRC for quick Q/A
- name: 💬 Matrix
url: https://matrix.to/#/#newpipe:libera.chat
about: Chat with us via Matrix for quick Q/A

View File

@ -47,10 +47,10 @@ jobs:
BRANCH: ${{ github.head_ref }}
run: git checkout -B "$BRANCH"
- name: set up JDK 17
- name: set up JDK
uses: actions/setup-java@v4
with:
java-version: 17
java-version: 21
distribution: "temurin"
cache: 'gradle'
@ -88,10 +88,10 @@ jobs:
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: set up JDK 17
- name: set up JDK
uses: actions/setup-java@v4
with:
java-version: 17
java-version: 21
distribution: "temurin"
cache: 'gradle'
@ -121,10 +121,10 @@ jobs:
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Set up JDK 17
- name: Set up JDK
uses: actions/setup-java@v4
with:
java-version: 17
java-version: 21
distribution: "temurin"
cache: 'gradle'

1
.gitignore vendored
View File

@ -10,6 +10,7 @@ captures/
*.class
app/debug/
app/release/
.kotlin/
# vscode / eclipse files
*.classpath

21
.idea/icon.svg generated Normal file
View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
<style type="text/css">
.st0{fill:#CD201F;}
.st1{fill:#FFFFFF;}
</style>
<g id="Alapkör">
<circle id="XMLID_23_" class="st0" cx="50" cy="50" r="50"/>
</g>
<g id="Elemek">
<path id="XMLID_19_" class="st1" d="M47,28.2c-9-5.3-15.3-9-15.3-9v61.7c0,0,30.4-18,52.3-30.9C72.1,43,57.7,34.5,47,28.2z"/>
</g>
<g id="Fedő">
<path id="XMLID_5_" class="st0" d="M48.4,40.1c-4.1-2.4-7-4.1-7-4.1V64c0,0,13.9-8.2,23.8-14C59.8,46.8,53.3,42.9,48.4,40.1z"/>
<rect id="XMLID_4_" x="41.4" y="55.6" class="st0" width="6.2" height="21"/>
</g>
<g id="Vonalak">
</g>
</svg>

After

Width:  |  Height:  |  Size: 850 B

View File

@ -1,15 +1,18 @@
import com.android.tools.profgen.ArtProfileKt
import com.android.tools.profgen.ArtProfileSerializer
import com.android.tools.profgen.DexFile
import com.mikepenz.aboutlibraries.plugin.DuplicateMode
plugins {
id "com.android.application"
id "kotlin-android"
id "kotlin-kapt"
id "kotlin-parcelize"
id "checkstyle"
id "org.sonarqube" version "4.0.0.2929"
id "org.jetbrains.kotlin.plugin.compose" version "${kotlin_version}"
alias libs.plugins.android.application
alias libs.plugins.kotlin.android
alias libs.plugins.kotlin.compose
alias libs.plugins.kotlin.kapt
alias libs.plugins.kotlin.parcelize
alias libs.plugins.checkstyle
alias libs.plugins.sonarqube
alias libs.plugins.hilt
alias libs.plugins.aboutlibraries
}
android {
@ -21,8 +24,8 @@ android {
resValue "string", "app_name", "NewPipe"
minSdk 21
targetSdk 33
versionCode 997
versionName "0.27.0"
versionCode 999
versionName "0.27.2"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -94,6 +97,7 @@ android {
buildFeatures {
viewBinding true
compose true
buildConfig true
}
packagingOptions {
@ -107,23 +111,6 @@ android {
}
}
ext {
checkstyleVersion = '10.12.1'
androidxLifecycleVersion = '2.6.2'
androidxRoomVersion = '2.6.1'
androidxWorkVersion = '2.8.1'
icepickVersion = '3.2.0'
exoPlayerVersion = '2.18.7'
googleAutoServiceVersion = '1.1.1'
groupieVersion = '2.10.1'
markwonVersion = '4.6.2'
leakCanaryVersion = '2.12'
stethoVersion = '1.6.0'
}
configurations {
checkstyle
ktlint
@ -133,7 +120,7 @@ checkstyle {
getConfigDirectory().set(rootProject.file("checkstyle"))
ignoreFailures false
showViolations true
toolVersion = checkstyleVersion
toolVersion = libs.versions.checkstyle.get()
}
tasks.register('runCheckstyle', Checkstyle) {
@ -175,11 +162,13 @@ tasks.register('formatKtlint', JavaExec) {
jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED")
}
apply from: 'check-dependencies.gradle'
afterEvaluate {
if (!System.properties.containsKey('skipFormatKtlint')) {
preDebugBuild.dependsOn formatKtlint
}
preDebugBuild.dependsOn runCheckstyle, runKtlint
preDebugBuild.dependsOn runCheckstyle, runKtlint, checkDependenciesOrder
}
sonar {
@ -190,132 +179,154 @@ sonar {
}
}
kapt {
correctErrorTypes true
}
aboutLibraries {
// note: offline mode prevents the plugin from fetching licenses at build time, which would be
// harmful for reproducible builds
offlineMode = true
duplicationMode = DuplicateMode.MERGE
}
dependencies {
/** Desugaring **/
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.0.4'
coreLibraryDesugaring libs.desugar.jdk.libs.nio
/** NewPipe libraries **/
// You can use a local version by uncommenting a few lines in settings.gradle
// Or you can use a commit you pushed to GitHub by just replacing TeamNewPipe with your GitHub
// name and the commit hash with the commit hash of the (pushed) commit you want to test
// This works thanks to JitPack: https://jitpack.io/
implementation 'com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751'
implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.24.2'
implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
implementation libs.teamnewpipe.nanojson
implementation libs.teamnewpipe.newpipe.extractor
implementation libs.teamnewpipe.nononsense.filepicker
/** Checkstyle **/
checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}"
ktlint 'com.pinterest:ktlint:0.45.2'
checkstyle libs.tools.checkstyle
ktlint libs.tools.ktlint
/** Kotlin **/
implementation "org.jetbrains.kotlin:kotlin-stdlib:${kotlin_version}"
implementation libs.kotlin.stdlib
/** AndroidX **/
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.cardview:cardview:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.documentfile:documentfile:1.0.1'
implementation 'androidx.fragment:fragment-ktx:1.6.2'
implementation "androidx.lifecycle:lifecycle-livedata-ktx:${androidxLifecycleVersion}"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:${androidxLifecycleVersion}"
implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0'
implementation 'androidx.media:media:1.7.0'
implementation 'androidx.preference:preference:1.2.1'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation "androidx.room:room-runtime:${androidxRoomVersion}"
implementation "androidx.room:room-rxjava3:${androidxRoomVersion}"
kapt "androidx.room:room-compiler:${androidxRoomVersion}"
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
// Newer version specified to prevent accessibility regressions with RecyclerView, see:
// https://developer.android.com/jetpack/androidx/releases/viewpager2#1.1.0-alpha01
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta02'
implementation "androidx.work:work-runtime-ktx:${androidxWorkVersion}"
implementation "androidx.work:work-rxjava3:${androidxWorkVersion}"
implementation 'com.google.android.material:material:1.11.0'
implementation libs.androidx.appcompat
implementation libs.androidx.cardview
implementation libs.androidx.constraintlayout
implementation libs.androidx.core.ktx
implementation libs.androidx.documentfile
implementation libs.androidx.fragment.compose
implementation libs.androidx.lifecycle.livedata
implementation libs.androidx.lifecycle.viewmodel
implementation libs.androidx.localbroadcastmanager
implementation libs.androidx.media
implementation libs.androidx.preference
implementation libs.androidx.recyclerview
implementation libs.androidx.room.runtime
implementation libs.androidx.room.rxjava3
kapt libs.androidx.room.compiler
implementation libs.androidx.swiperefreshlayout
implementation libs.androidx.work.runtime
implementation libs.androidx.work.rxjava3
implementation libs.androidx.material
/** Third-party libraries **/
// Instance state boilerplate elimination
implementation "frankiesardo:icepick:${icepickVersion}"
kapt "frankiesardo:icepick-processor:${icepickVersion}"
implementation libs.livefront.bridge
implementation libs.android.state
kapt libs.android.state.processor
// HTML parser
implementation "org.jsoup:jsoup:1.17.2"
implementation libs.jsoup
// HTTP client
implementation "com.squareup.okhttp3:okhttp:4.12.0"
implementation libs.okhttp
// Media player
implementation "com.google.android.exoplayer:exoplayer-core:${exoPlayerVersion}"
implementation "com.google.android.exoplayer:exoplayer-dash:${exoPlayerVersion}"
implementation "com.google.android.exoplayer:exoplayer-database:${exoPlayerVersion}"
implementation "com.google.android.exoplayer:exoplayer-datasource:${exoPlayerVersion}"
implementation "com.google.android.exoplayer:exoplayer-hls:${exoPlayerVersion}"
implementation "com.google.android.exoplayer:exoplayer-smoothstreaming:${exoPlayerVersion}"
implementation "com.google.android.exoplayer:exoplayer-ui:${exoPlayerVersion}"
implementation "com.google.android.exoplayer:extension-mediasession:${exoPlayerVersion}"
implementation libs.exoplayer.core
implementation libs.exoplayer.dash
implementation libs.exoplayer.database
implementation libs.exoplayer.datasource
implementation libs.exoplayer.hls
implementation libs.exoplayer.smoothstreaming
implementation libs.exoplayer.ui
implementation libs.extension.mediasession
// Metadata generator for service descriptors
compileOnly "com.google.auto.service:auto-service-annotations:${googleAutoServiceVersion}"
kapt "com.google.auto.service:auto-service:${googleAutoServiceVersion}"
compileOnly libs.auto.service
kapt libs.auto.service.kapt
// Manager for complex RecyclerView layouts
implementation "com.github.lisawray.groupie:groupie:${groupieVersion}"
implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}"
implementation libs.lisawray.groupie
implementation libs.lisawray.groupie.viewbinding
// Image loading
implementation 'io.coil-kt:coil-compose:2.7.0'
implementation libs.coil.compose
implementation libs.coil.network.okhttp
// Markdown library for Android
implementation "io.noties.markwon:core:${markwonVersion}"
implementation "io.noties.markwon:linkify:${markwonVersion}"
implementation libs.markwon.core
implementation libs.markwon.linkify
// Crash reporting
implementation "ch.acra:acra-core:5.11.3"
implementation libs.acra.core
// Properly restarting
implementation 'com.jakewharton:process-phoenix:2.1.2'
implementation libs.process.phoenix
// Reactive extensions for Java VM
implementation "io.reactivex.rxjava3:rxjava:3.1.8"
implementation "io.reactivex.rxjava3:rxandroid:3.0.2"
implementation libs.rxjava3.rxjava
implementation libs.rxjava3.rxandroid
// RxJava binding APIs for Android UI widgets
implementation "com.jakewharton.rxbinding4:rxbinding:4.0.0"
implementation libs.rxbinding4.rxbinding
// Date and time formatting
implementation "org.ocpsoft.prettytime:prettytime:5.0.8.Final"
implementation libs.prettytime
// Jetpack Compose
implementation(platform('androidx.compose:compose-bom:2024.06.00'))
implementation 'androidx.compose.material3:material3:1.3.0-beta05'
implementation 'androidx.compose.material3.adaptive:adaptive:1.0.0-beta04'
implementation 'androidx.activity:activity-compose'
implementation 'androidx.compose.ui:ui-tooling-preview'
implementation 'androidx.compose.ui:ui-text:1.7.0-beta06' // Needed for parsing HTML to AnnotatedString
implementation 'androidx.lifecycle:lifecycle-viewmodel-compose'
implementation 'androidx.paging:paging-compose:3.3.1'
implementation 'com.github.nanihadesuka:LazyColumnScrollbar:2.2.0'
implementation(platform(libs.androidx.compose.bom))
implementation libs.androidx.compose.material3
implementation libs.androidx.compose.adaptive
implementation libs.androidx.activity.compose
implementation libs.androidx.compose.ui.tooling.preview
implementation libs.androidx.lifecycle.viewmodel.compose
implementation libs.androidx.compose.ui.text // Needed for parsing HTML to AnnotatedString
implementation libs.androidx.compose.material.icons.extended
// Jetpack Compose related dependencies
implementation libs.androidx.paging.compose
implementation libs.androidx.navigation.compose
// Coroutines interop
implementation libs.kotlinx.coroutines.rx3
// Library loading for About screen
implementation libs.aboutlibraries.compose.m3
// Hilt
implementation libs.hilt.android
kapt(libs.hilt.compiler)
// Scroll
implementation libs.lazycolumnscrollbar
/** Debugging **/
// Memory leak detection
debugImplementation "com.squareup.leakcanary:leakcanary-object-watcher-android:${leakCanaryVersion}"
debugImplementation "com.squareup.leakcanary:plumber-android:${leakCanaryVersion}"
debugImplementation "com.squareup.leakcanary:leakcanary-android-core:${leakCanaryVersion}"
debugImplementation libs.leakcanary.object.watcher
debugImplementation libs.leakcanary.plumber.android
debugImplementation libs.leakcanary.android.core
// Debug bridge for Android
debugImplementation "com.facebook.stetho:stetho:${stethoVersion}"
debugImplementation "com.facebook.stetho:stetho-okhttp3:${stethoVersion}"
debugImplementation libs.stetho
debugImplementation libs.stetho.okhttp3
// Jetpack Compose
debugImplementation 'androidx.compose.ui:ui-tooling'
debugImplementation libs.androidx.compose.ui.tooling
/** Testing **/
testImplementation 'junit:junit:4.13.2'
testImplementation 'org.mockito:mockito-core:5.6.0'
testImplementation libs.junit
testImplementation libs.mockito.core
androidTestImplementation "androidx.test.ext:junit:1.1.5"
androidTestImplementation "androidx.test:runner:1.5.2"
androidTestImplementation "androidx.room:room-testing:${androidxRoomVersion}"
androidTestImplementation "org.assertj:assertj-core:3.24.2"
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.runner
androidTestImplementation libs.androidx.room.testing
androidTestImplementation libs.assertj.core
}
static String getGitWorkingBranch() {

View File

@ -0,0 +1,48 @@
tasks.register('checkDependenciesOrder') {
group = 'verification'
description = 'Checks that each section in libs.versions.toml is sorted alphabetically'
def tomlFile = file('../gradle/libs.versions.toml')
doLast {
if (!tomlFile.exists()) {
throw new GradleException('TOML file not found')
}
def lines = tomlFile.readLines()
def nonSortedBlocks = []
def currentBlock = []
def prevLine = ''
def prevIndex = 0
lines.eachWithIndex { line, lineIndex ->
if (line.trim() && !line.startsWith('#')) {
if (line.startsWith('[')) {
prevLine = ''
} else {
def currIndex = lineIndex + 1
if (prevLine > line) {
if (currentBlock && currentBlock[-1] == "${prevIndex}: ${prevLine}") {
currentBlock.add("${currIndex}: ${line}")
} else {
if (!currentBlock.isEmpty()) {
nonSortedBlocks.add(currentBlock)
currentBlock = []
}
currentBlock.add("${prevIndex}: ${prevLine}")
currentBlock.add("${currIndex}: ${line}")
}
}
prevLine = line
prevIndex = lineIndex + 1
}
}
}
if (!currentBlock.isEmpty()) {
nonSortedBlocks.add(currentBlock)
throw new GradleException("The following lines were not sorted:\n" +
nonSortedBlocks.collect { it.join("\n") }.join("\n\n"))
}
}
}

View File

@ -7,20 +7,12 @@
-keep class org.schabi.newpipe.extractor.timeago.patterns.** { *; }
-keep class org.mozilla.javascript.** { *; }
-keep class org.mozilla.classfile.ClassFileWriter
-dontwarn org.mozilla.javascript.JavaToJSONConverters
-dontwarn org.mozilla.javascript.tools.**
## Rules for ExoPlayer
-keep class com.google.android.exoplayer2.** { *; }
## Rules for Icepick. Copy pasted from https://github.com/frankiesardo/icepick
-dontwarn icepick.**
-keep class icepick.** { *; }
-keep class **$$Icepick { *; }
-keepclasseswithmembernames class * {
@icepick.* <fields>;
}
-keepnames class * { @icepick.State *;}
## Rules for OkHttp. Copy pasted from https://github.com/square/okhttp
-dontwarn okhttp3.**
-dontwarn okio.**

View File

@ -77,6 +77,11 @@
android:exported="false"
android:label="@string/settings" />
<activity
android:name=".settings.SettingsV2Activity"
android:exported="true"
android:label="@string/settings" />
<activity
android:name=".about.AboutActivity"
android:exported="false"

View File

@ -1,275 +0,0 @@
package org.schabi.newpipe;
import android.app.ActivityManager;
import android.app.Application;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
import androidx.annotation.NonNull;
import androidx.core.app.NotificationChannelCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat;
import androidx.preference.PreferenceManager;
import com.jakewharton.processphoenix.ProcessPhoenix;
import org.acra.ACRA;
import org.acra.config.CoreConfigurationBuilder;
import org.schabi.newpipe.error.ReCaptchaActivity;
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.downloader.Downloader;
import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.ServiceHelper;
import org.schabi.newpipe.util.StateSaver;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PreferredImageQuality;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.net.SocketException;
import java.util.List;
import java.util.Objects;
import coil.ImageLoader;
import coil.ImageLoaderFactory;
import coil.util.DebugLogger;
import io.reactivex.rxjava3.exceptions.CompositeException;
import io.reactivex.rxjava3.exceptions.MissingBackpressureException;
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException;
import io.reactivex.rxjava3.exceptions.UndeliverableException;
import io.reactivex.rxjava3.functions.Consumer;
import io.reactivex.rxjava3.plugins.RxJavaPlugins;
/*
* Copyright (C) Hans-Christoph Steiner 2016 <hans@eds.org>
* App.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 App extends Application implements ImageLoaderFactory {
public static final String PACKAGE_NAME = BuildConfig.APPLICATION_ID;
private static final String TAG = App.class.toString();
private boolean isFirstRun = false;
private static App app;
@NonNull
public static App getApp() {
return app;
}
@Override
protected void attachBaseContext(final Context base) {
super.attachBaseContext(base);
initACRA();
}
@Override
public void onCreate() {
super.onCreate();
app = this;
if (ProcessPhoenix.isPhoenixProcess(this)) {
Log.i(TAG, "This is a phoenix process! "
+ "Aborting initialization of App[onCreate]");
return;
}
// check if the last used preference version is set
// to determine whether this is the first app run
final int lastUsedPrefVersion = PreferenceManager.getDefaultSharedPreferences(this)
.getInt(getString(R.string.last_used_preferences_version), -1);
isFirstRun = lastUsedPrefVersion == -1;
// Initialize settings first because other initializations can use its values
NewPipeSettings.initSettings(this);
NewPipe.init(getDownloader(),
Localization.getPreferredLocalization(this),
Localization.getPreferredContentCountry(this));
Localization.initPrettyTime(Localization.resolvePrettyTime(getApplicationContext()));
StateSaver.init(this);
initNotificationChannels();
ServiceHelper.initServices(this);
// Initialize image loader
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this);
ImageStrategy.setPreferredImageQuality(PreferredImageQuality.fromPreferenceKey(this,
prefs.getString(getString(R.string.image_quality_key),
getString(R.string.image_quality_default))));
configureRxJavaErrorHandler();
}
@NonNull
@Override
public ImageLoader newImageLoader() {
return new ImageLoader.Builder(this)
.allowRgb565(ContextCompat.getSystemService(this, ActivityManager.class)
.isLowRamDevice())
.logger(BuildConfig.DEBUG ? new DebugLogger() : null)
.crossfade(true)
.build();
}
protected Downloader getDownloader() {
final DownloaderImpl downloader = DownloaderImpl.init(null);
setCookiesToDownloader(downloader);
return downloader;
}
protected void setCookiesToDownloader(final DownloaderImpl downloader) {
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(
getApplicationContext());
final String key = getApplicationContext().getString(R.string.recaptcha_cookies_key);
downloader.setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, prefs.getString(key, null));
downloader.updateYoutubeRestrictedModeCookies(getApplicationContext());
}
private void configureRxJavaErrorHandler() {
// https://github.com/ReactiveX/RxJava/wiki/What's-different-in-2.0#error-handling
RxJavaPlugins.setErrorHandler(new Consumer<Throwable>() {
@Override
public void accept(@NonNull final Throwable throwable) {
Log.e(TAG, "RxJavaPlugins.ErrorHandler called with -> : "
+ "throwable = [" + throwable.getClass().getName() + "]");
final Throwable actualThrowable;
if (throwable instanceof UndeliverableException) {
// As UndeliverableException is a wrapper,
// get the cause of it to get the "real" exception
actualThrowable = Objects.requireNonNull(throwable.getCause());
} else {
actualThrowable = throwable;
}
final List<Throwable> errors;
if (actualThrowable instanceof CompositeException) {
errors = ((CompositeException) actualThrowable).getExceptions();
} else {
errors = List.of(actualThrowable);
}
for (final Throwable error : errors) {
if (isThrowableIgnored(error)) {
return;
}
if (isThrowableCritical(error)) {
reportException(error);
return;
}
}
// Out-of-lifecycle exceptions should only be reported if a debug user wishes so,
// When exception is not reported, log it
if (isDisposedRxExceptionsReported()) {
reportException(actualThrowable);
} else {
Log.e(TAG, "RxJavaPlugin: Undeliverable Exception received: ", actualThrowable);
}
}
private boolean isThrowableIgnored(@NonNull final Throwable throwable) {
// Don't crash the application over a simple network problem
return ExceptionUtils.hasAssignableCause(throwable,
// network api cancellation
IOException.class, SocketException.class,
// blocking code disposed
InterruptedException.class, InterruptedIOException.class);
}
private boolean isThrowableCritical(@NonNull final Throwable throwable) {
// Though these exceptions cannot be ignored
return ExceptionUtils.hasAssignableCause(throwable,
NullPointerException.class, IllegalArgumentException.class, // bug in app
OnErrorNotImplementedException.class, MissingBackpressureException.class,
IllegalStateException.class); // bug in operator
}
private void reportException(@NonNull final Throwable throwable) {
// Throw uncaught exception that will trigger the report system
Thread.currentThread().getUncaughtExceptionHandler()
.uncaughtException(Thread.currentThread(), throwable);
}
});
}
/**
* Called in {@link #attachBaseContext(Context)} after calling the {@code super} method.
* Should be overridden if MultiDex is enabled, since it has to be initialized before ACRA.
*/
protected void initACRA() {
if (ACRA.isACRASenderServiceProcess()) {
return;
}
final CoreConfigurationBuilder acraConfig = new CoreConfigurationBuilder()
.withBuildConfigClass(BuildConfig.class);
ACRA.init(this, acraConfig);
}
private void initNotificationChannels() {
// Keep the importance below DEFAULT to avoid making noise on every notification update for
// the main and update channels
final List<NotificationChannelCompat> notificationChannelCompats = List.of(
new NotificationChannelCompat.Builder(getString(R.string.notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.notification_channel_name))
.setDescription(getString(R.string.notification_channel_description))
.build(),
new NotificationChannelCompat
.Builder(getString(R.string.app_update_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.app_update_notification_channel_name))
.setDescription(
getString(R.string.app_update_notification_channel_description))
.build(),
new NotificationChannelCompat.Builder(getString(R.string.hash_channel_id),
NotificationManagerCompat.IMPORTANCE_HIGH)
.setName(getString(R.string.hash_channel_name))
.setDescription(getString(R.string.hash_channel_description))
.build(),
new NotificationChannelCompat.Builder(getString(R.string.error_report_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW)
.setName(getString(R.string.error_report_channel_name))
.setDescription(getString(R.string.error_report_channel_description))
.build(),
new NotificationChannelCompat
.Builder(getString(R.string.streams_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setName(getString(R.string.streams_notification_channel_name))
.setDescription(
getString(R.string.streams_notification_channel_description))
.build()
);
final NotificationManagerCompat notificationManager = NotificationManagerCompat.from(this);
notificationManager.createNotificationChannelsCompat(notificationChannelCompats);
}
protected boolean isDisposedRxExceptionsReported() {
return false;
}
public boolean isFirstRun() {
return isFirstRun;
}
}

View File

@ -0,0 +1,286 @@
package org.schabi.newpipe
import android.app.ActivityManager
import android.app.Application
import android.content.Context
import android.util.Log
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.getSystemService
import androidx.preference.PreferenceManager
import coil3.ImageLoader
import coil3.SingletonImageLoader
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.allowRgb565
import coil3.request.crossfade
import coil3.util.DebugLogger
import com.jakewharton.processphoenix.ProcessPhoenix
import dagger.hilt.android.HiltAndroidApp
import io.reactivex.rxjava3.exceptions.CompositeException
import io.reactivex.rxjava3.exceptions.MissingBackpressureException
import io.reactivex.rxjava3.exceptions.OnErrorNotImplementedException
import io.reactivex.rxjava3.exceptions.UndeliverableException
import io.reactivex.rxjava3.functions.Consumer
import io.reactivex.rxjava3.plugins.RxJavaPlugins
import org.acra.ACRA.init
import org.acra.ACRA.isACRASenderServiceProcess
import org.acra.config.CoreConfigurationBuilder
import org.schabi.newpipe.error.ReCaptchaActivity
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.downloader.Downloader
import org.schabi.newpipe.ktx.hasAssignableCause
import org.schabi.newpipe.settings.NewPipeSettings
import org.schabi.newpipe.util.BridgeStateSaverInitializer
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.ServiceHelper
import org.schabi.newpipe.util.StateSaver
import org.schabi.newpipe.util.image.ImageStrategy
import org.schabi.newpipe.util.image.PreferredImageQuality
import java.io.IOException
import java.io.InterruptedIOException
import java.net.SocketException
/*
* Copyright (C) Hans-Christoph Steiner 2016 <hans@eds.org>
* App.kt 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/>.
*/
@HiltAndroidApp
open class App :
Application(),
SingletonImageLoader.Factory {
var isFirstRun = false
private set
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
initACRA()
}
override fun onCreate() {
super.onCreate()
instance = this
if (ProcessPhoenix.isPhoenixProcess(this)) {
Log.i(TAG, "This is a phoenix process! Aborting initialization of App[onCreate]")
return
}
// check if the last used preference version is set
// to determine whether this is the first app run
val lastUsedPrefVersion =
PreferenceManager
.getDefaultSharedPreferences(this)
.getInt(getString(R.string.last_used_preferences_version), -1)
isFirstRun = lastUsedPrefVersion == -1
// Initialize settings first because other initializations can use its values
NewPipeSettings.initSettings(this)
NewPipe.init(
getDownloader(),
Localization.getPreferredLocalization(this),
Localization.getPreferredContentCountry(this),
)
Localization.initPrettyTime(Localization.resolvePrettyTime(this))
BridgeStateSaverInitializer.init(this)
StateSaver.init(this)
initNotificationChannels()
ServiceHelper.initServices(this)
// Initialize image loader
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
ImageStrategy.setPreferredImageQuality(
PreferredImageQuality.fromPreferenceKey(
this,
prefs.getString(
getString(R.string.image_quality_key),
getString(R.string.image_quality_default),
),
),
)
configureRxJavaErrorHandler()
}
override fun newImageLoader(context: Context): ImageLoader =
ImageLoader
.Builder(this)
.logger(if (BuildConfig.DEBUG) DebugLogger() else null)
.allowRgb565(getSystemService<ActivityManager>()!!.isLowRamDevice)
.crossfade(true)
.components {
add(OkHttpNetworkFetcherFactory(callFactory = DownloaderImpl.getInstance().client))
}.build()
protected open fun getDownloader(): Downloader {
val downloader = DownloaderImpl.init(null)
setCookiesToDownloader(downloader)
return downloader
}
protected fun setCookiesToDownloader(downloader: DownloaderImpl) {
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
val key = getString(R.string.recaptcha_cookies_key)
downloader.setCookie(ReCaptchaActivity.RECAPTCHA_COOKIES_KEY, prefs.getString(key, null))
downloader.updateYoutubeRestrictedModeCookies(this)
}
private fun configureRxJavaErrorHandler() {
// https://github.com/ReactiveX/RxJava/wiki/What's-different-in-2.0#error-handling
RxJavaPlugins.setErrorHandler(
object : Consumer<Throwable> {
override fun accept(throwable: Throwable) {
Log.e(TAG, "RxJavaPlugins.ErrorHandler called with -> : throwable = [${throwable.javaClass.getName()}]")
// As UndeliverableException is a wrapper,
// get the cause of it to get the "real" exception
val actualThrowable = (throwable as? UndeliverableException)?.cause ?: throwable
val errors = (actualThrowable as? CompositeException)?.exceptions ?: listOf(actualThrowable)
for (error in errors) {
if (isThrowableIgnored(error)) {
return
}
if (isThrowableCritical(error)) {
reportException(error)
return
}
}
// Out-of-lifecycle exceptions should only be reported if a debug user wishes so,
// When exception is not reported, log it
if (isDisposedRxExceptionsReported()) {
reportException(actualThrowable)
} else {
Log.e(TAG, "RxJavaPlugin: Undeliverable Exception received: ", actualThrowable)
}
}
fun isThrowableIgnored(throwable: Throwable): Boolean {
// Don't crash the application over a simple network problem
return throwable // network api cancellation
.hasAssignableCause(
IOException::class.java,
SocketException::class.java, // blocking code disposed
InterruptedException::class.java,
InterruptedIOException::class.java,
)
}
fun isThrowableCritical(throwable: Throwable): Boolean {
// Though these exceptions cannot be ignored
return throwable
.hasAssignableCause(
// bug in app
NullPointerException::class.java,
IllegalArgumentException::class.java,
OnErrorNotImplementedException::class.java,
MissingBackpressureException::class.java,
// bug in operator
IllegalStateException::class.java,
)
}
fun reportException(throwable: Throwable) {
// Throw uncaught exception that will trigger the report system
Thread
.currentThread()
.uncaughtExceptionHandler
.uncaughtException(Thread.currentThread(), throwable)
}
},
)
}
/**
* Called in [.attachBaseContext] after calling the `super` method.
* Should be overridden if MultiDex is enabled, since it has to be initialized before ACRA.
*/
protected fun initACRA() {
if (isACRASenderServiceProcess()) {
return
}
val acraConfig =
CoreConfigurationBuilder()
.withBuildConfigClass(BuildConfig::class.java)
init(this, acraConfig)
}
private fun initNotificationChannels() {
// Keep the importance below DEFAULT to avoid making noise on every notification update for
// the main and update channels
val mainChannel =
NotificationChannelCompat
.Builder(
getString(R.string.notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW,
).setName(getString(R.string.notification_channel_name))
.setDescription(getString(R.string.notification_channel_description))
.build()
val appUpdateChannel =
NotificationChannelCompat
.Builder(
getString(R.string.app_update_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW,
).setName(getString(R.string.app_update_notification_channel_name))
.setDescription(getString(R.string.app_update_notification_channel_description))
.build()
val hashChannel =
NotificationChannelCompat
.Builder(
getString(R.string.hash_channel_id),
NotificationManagerCompat.IMPORTANCE_HIGH,
).setName(getString(R.string.hash_channel_name))
.setDescription(getString(R.string.hash_channel_description))
.build()
val errorReportChannel =
NotificationChannelCompat
.Builder(
getString(R.string.error_report_channel_id),
NotificationManagerCompat.IMPORTANCE_LOW,
).setName(getString(R.string.error_report_channel_name))
.setDescription(getString(R.string.error_report_channel_description))
.build()
val newStreamChannel =
NotificationChannelCompat
.Builder(
getString(R.string.streams_notification_channel_id),
NotificationManagerCompat.IMPORTANCE_DEFAULT,
).setName(getString(R.string.streams_notification_channel_name))
.setDescription(getString(R.string.streams_notification_channel_description))
.build()
val channels = listOf(mainChannel, appUpdateChannel, hashChannel, errorReportChannel, newStreamChannel)
NotificationManagerCompat.from(this).createNotificationChannelsCompat(channels)
}
protected open fun isDisposedRxExceptionsReported(): Boolean = false
companion object {
const val PACKAGE_NAME: String = BuildConfig.APPLICATION_ID
private val TAG = App::class.java.toString()
@JvmStatic
lateinit var instance: App
private set
}
}

View File

@ -0,0 +1,22 @@
package org.schabi.newpipe
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
class AppModule {
@Provides
@Singleton
fun providesSharedPreference(@ApplicationContext context: Context): SharedPreferences {
return PreferenceManager.getDefaultSharedPreferences(context)
}
}

View File

@ -10,8 +10,9 @@ import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentManager;
import icepick.Icepick;
import icepick.State;
import com.evernote.android.state.State;
import com.livefront.bridge.Bridge;
public abstract class BaseFragment extends Fragment {
protected final String TAG = getClass().getSimpleName() + "@" + Integer.toHexString(hashCode());
@ -48,7 +49,7 @@ public abstract class BaseFragment extends Fragment {
+ "savedInstanceState = [" + savedInstanceState + "]");
}
super.onCreate(savedInstanceState);
Icepick.restoreInstanceState(this, savedInstanceState);
Bridge.restoreInstanceState(this, savedInstanceState);
if (savedInstanceState != null) {
onRestoreInstanceState(savedInstanceState);
}
@ -70,7 +71,7 @@ public abstract class BaseFragment extends Fragment {
@Override
public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState);
Icepick.saveInstanceState(this, outState);
Bridge.saveInstanceState(this, outState);
}
protected void onRestoreInstanceState(@NonNull final Bundle savedInstanceState) {

View File

@ -48,6 +48,11 @@ public final class DownloaderImpl extends Downloader {
this.mCookies = new HashMap<>();
}
@NonNull
public OkHttpClient getClient() {
return client;
}
/**
* It's recommended to call exactly once in the entire lifetime of the application.
*

View File

@ -166,7 +166,7 @@ public class MainActivity extends AppCompatActivity {
NotificationWorker.initialize(this);
}
if (!UpdateSettingsFragment.wasUserAskedForConsent(this)
&& !App.getApp().isFirstRun()
&& !App.getInstance().isFirstRun()
&& ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
UpdateSettingsFragment.askForConsentToUpdateChecks(this);
}
@ -176,7 +176,7 @@ public class MainActivity extends AppCompatActivity {
protected void onPostCreate(final Bundle savedInstanceState) {
super.onPostCreate(savedInstanceState);
final App app = App.getApp();
final App app = App.getInstance();
final SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(app);
if (prefs.getBoolean(app.getString(R.string.update_app_key), false)
@ -553,28 +553,27 @@ public class MainActivity extends AppCompatActivity {
// In case bottomSheet is not visible on the screen or collapsed we can assume that the user
// interacts with a fragment inside fragment_holder so all back presses should be
// handled by it
final var fragmentManager = getSupportFragmentManager();
if (bottomSheetHiddenOrCollapsed()) {
final var fm = getSupportFragmentManager();
final var fragment = fm.findFragmentById(R.id.fragment_holder);
final var fragment = fragmentManager.findFragmentById(R.id.fragment_holder);
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
// delegate the back press to it
if (fragment instanceof BackPressable backPressable && backPressable.onBackPressed()) {
return;
}
} else {
final var fragmentPlayer = getSupportFragmentManager()
.findFragmentById(R.id.fragment_player_holder);
final var player = fragmentManager.findFragmentById(R.id.fragment_player_holder);
// If current fragment implements BackPressable (i.e. can/wanna handle back press)
// delegate the back press to it
if (fragmentPlayer instanceof BackPressable backPressable
&& !backPressable.onBackPressed()) {
if (player instanceof BackPressable backPressable && !backPressable.onBackPressed()) {
BottomSheetBehavior.from(mainBinding.fragmentPlayerHolder)
.setState(BottomSheetBehavior.STATE_COLLAPSED);
return;
}
}
if (getSupportFragmentManager().getBackStackEntryCount() == 1) {
if (fragmentManager.getBackStackEntryCount() == 1) {
finish();
} else {
super.onBackPressed();

View File

@ -41,6 +41,9 @@ import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleOwner;
import androidx.preference.PreferenceManager;
import com.evernote.android.state.State;
import com.livefront.bridge.Bridge;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.databinding.ListRadioIconItemBinding;
import org.schabi.newpipe.databinding.SingleChoiceDialogViewBinding;
@ -98,8 +101,6 @@ import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
import icepick.Icepick;
import icepick.State;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
@ -152,7 +153,7 @@ public class RouterActivity extends AppCompatActivity {
getWindow().setAttributes(params);
super.onCreate(savedInstanceState);
Icepick.restoreInstanceState(this, savedInstanceState);
Bridge.restoreInstanceState(this, savedInstanceState);
// FragmentManager will take care to recreate (Playlist|Download)Dialog when screen rotates
// We used to .setOnDismissListener(dialog -> finish()); when creating these DialogFragments
@ -197,7 +198,7 @@ public class RouterActivity extends AppCompatActivity {
@Override
protected void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState);
Icepick.saveInstanceState(this, outState);
Bridge.saveInstanceState(this, outState);
}
@Override

View File

@ -1,199 +1,31 @@
package org.schabi.newpipe.about
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import androidx.annotation.StringRes
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.google.android.material.tabs.TabLayoutMediator
import org.schabi.newpipe.BuildConfig
import androidx.compose.ui.res.stringResource
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.ActivityAboutBinding
import org.schabi.newpipe.databinding.FragmentAboutBinding
import org.schabi.newpipe.ui.components.common.ScaffoldWithToolbar
import org.schabi.newpipe.ui.screens.AboutScreen
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.ThemeHelper
import org.schabi.newpipe.util.external_communication.ShareUtils
class AboutActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
Localization.assureCorrectAppLanguage(this)
enableEdgeToEdge()
super.onCreate(savedInstanceState)
ThemeHelper.setTheme(this)
title = getString(R.string.title_activity_about)
val aboutBinding = ActivityAboutBinding.inflate(layoutInflater)
setContentView(aboutBinding.root)
setSupportActionBar(aboutBinding.aboutToolbar)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
// Create the adapter that will return a fragment for each of the three
// primary sections of the activity.
val mAboutStateAdapter = AboutStateAdapter(this)
// Set up the ViewPager with the sections adapter.
aboutBinding.aboutViewPager2.adapter = mAboutStateAdapter
TabLayoutMediator(
aboutBinding.aboutTabLayout,
aboutBinding.aboutViewPager2
) { tab, position ->
tab.setText(mAboutStateAdapter.getPageTitle(position))
}.attach()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
if (item.itemId == android.R.id.home) {
finish()
return true
}
return super.onOptionsItemSelected(item)
}
/**
* A placeholder fragment containing a simple view.
*/
class AboutFragment : Fragment() {
private fun Button.openLink(@StringRes url: Int) {
setOnClickListener {
ShareUtils.openUrlInApp(context, requireContext().getString(url))
setContent {
AppTheme {
ScaffoldWithToolbar(
title = stringResource(R.string.title_activity_about),
onBackClick = { onBackPressedDispatcher.onBackPressed() }
) { padding ->
AboutScreen(padding)
}
}
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
FragmentAboutBinding.inflate(inflater, container, false).apply {
aboutAppVersion.text = BuildConfig.VERSION_NAME
aboutGithubLink.openLink(R.string.github_url)
aboutDonationLink.openLink(R.string.donation_url)
aboutWebsiteLink.openLink(R.string.website_url)
aboutPrivacyPolicyLink.openLink(R.string.privacy_policy_url)
faqLink.openLink(R.string.faq_url)
return root
}
}
}
/**
* A [FragmentStateAdapter] that returns a fragment corresponding to
* one of the sections/tabs/pages.
*/
private class AboutStateAdapter(fa: FragmentActivity) : FragmentStateAdapter(fa) {
private val posAbout = 0
private val posLicense = 1
private val totalCount = 2
override fun createFragment(position: Int): Fragment {
return when (position) {
posAbout -> AboutFragment()
posLicense -> LicenseFragment.newInstance(SOFTWARE_COMPONENTS)
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
}
}
override fun getItemCount(): Int {
// Show 2 total pages.
return totalCount
}
fun getPageTitle(position: Int): Int {
return when (position) {
posAbout -> R.string.tab_about
posLicense -> R.string.tab_licenses
else -> throw IllegalArgumentException("Unknown position for ViewPager2")
}
}
}
companion object {
/**
* List of all software components.
*/
private val SOFTWARE_COMPONENTS = arrayListOf(
SoftwareComponent(
"ACRA", "2013", "Kevin Gaudin",
"https://github.com/ACRA/acra", StandardLicenses.APACHE2
),
SoftwareComponent(
"AndroidX", "2005 - 2011", "The Android Open Source Project",
"https://developer.android.com/jetpack", StandardLicenses.APACHE2
),
SoftwareComponent(
"ExoPlayer", "2014 - 2020", "Google, Inc.",
"https://github.com/google/ExoPlayer", StandardLicenses.APACHE2
),
SoftwareComponent(
"GigaGet", "2014 - 2015", "Peter Cai",
"https://github.com/PaperAirplane-Dev-Team/GigaGet", StandardLicenses.GPL3
),
SoftwareComponent(
"Groupie", "2016", "Lisa Wray",
"https://github.com/lisawray/groupie", StandardLicenses.MIT
),
SoftwareComponent(
"Icepick", "2015", "Frankie Sardo",
"https://github.com/frankiesardo/icepick", StandardLicenses.EPL1
),
SoftwareComponent(
"Jsoup", "2009 - 2020", "Jonathan Hedley",
"https://github.com/jhy/jsoup", StandardLicenses.MIT
),
SoftwareComponent(
"Markwon", "2019", "Dimitry Ivanov",
"https://github.com/noties/Markwon", StandardLicenses.APACHE2
),
SoftwareComponent(
"Material Components for Android", "2016 - 2020", "Google, Inc.",
"https://github.com/material-components/material-components-android",
StandardLicenses.APACHE2
),
SoftwareComponent(
"NewPipe Extractor", "2017 - 2020", "Christian Schabesberger",
"https://github.com/TeamNewPipe/NewPipeExtractor", StandardLicenses.GPL3
),
SoftwareComponent(
"NoNonsense-FilePicker", "2016", "Jonas Kalderstam",
"https://github.com/spacecowboy/NoNonsense-FilePicker", StandardLicenses.MPL2
),
SoftwareComponent(
"OkHttp", "2019", "Square, Inc.",
"https://square.github.io/okhttp/", StandardLicenses.APACHE2
),
SoftwareComponent(
"Coil", "2023", "Coil Contributors",
"https://coil-kt.github.io/coil/", StandardLicenses.APACHE2
),
SoftwareComponent(
"PrettyTime", "2012 - 2020", "Lincoln Baxter, III",
"https://github.com/ocpsoft/prettytime", StandardLicenses.APACHE2
),
SoftwareComponent(
"ProcessPhoenix", "2015", "Jake Wharton",
"https://github.com/JakeWharton/ProcessPhoenix", StandardLicenses.APACHE2
),
SoftwareComponent(
"RxAndroid", "2015", "The RxAndroid authors",
"https://github.com/ReactiveX/RxAndroid", StandardLicenses.APACHE2
),
SoftwareComponent(
"RxBinding", "2015", "Jake Wharton",
"https://github.com/JakeWharton/RxBinding", StandardLicenses.APACHE2
),
SoftwareComponent(
"RxJava", "2016 - 2020", "RxJava Contributors",
"https://github.com/ReactiveX/RxJava", StandardLicenses.APACHE2
),
SoftwareComponent(
"SearchPreference", "2018", "ByteHamster",
"https://github.com/ByteHamster/SearchPreference", StandardLicenses.MIT
),
)
}
}

View File

@ -1,11 +0,0 @@
package org.schabi.newpipe.about
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.io.Serializable
/**
* Class for storing information about a software license.
*/
@Parcelize
class License(val name: String, val abbreviation: String, val filename: String) : Parcelable, Serializable

View File

@ -1,140 +0,0 @@
package org.schabi.newpipe.about
import android.os.Bundle
import android.util.Base64
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.webkit.WebView
import androidx.appcompat.app.AlertDialog
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Observable
import io.reactivex.rxjava3.disposables.CompositeDisposable
import io.reactivex.rxjava3.disposables.Disposable
import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.FragmentLicensesBinding
import org.schabi.newpipe.databinding.ItemSoftwareComponentBinding
import org.schabi.newpipe.ktx.parcelableArrayList
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.external_communication.ShareUtils
/**
* Fragment containing the software licenses.
*/
class LicenseFragment : Fragment() {
private lateinit var softwareComponents: List<SoftwareComponent>
private var activeSoftwareComponent: SoftwareComponent? = null
private val compositeDisposable = CompositeDisposable()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
softwareComponents = arguments?.parcelableArrayList<SoftwareComponent>(ARG_COMPONENTS)!!
.sortedBy { it.name } // Sort components by name
activeSoftwareComponent = savedInstanceState?.getSerializable(SOFTWARE_COMPONENT_KEY) as? SoftwareComponent
}
override fun onDestroy() {
compositeDisposable.dispose()
super.onDestroy()
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
val binding = FragmentLicensesBinding.inflate(inflater, container, false)
binding.licensesAppReadLicense.setOnClickListener {
compositeDisposable.add(
showLicense(NEWPIPE_SOFTWARE_COMPONENT)
)
}
for (component in softwareComponents) {
val componentBinding = ItemSoftwareComponentBinding
.inflate(inflater, container, false)
componentBinding.name.text = component.name
componentBinding.copyright.text = getString(
R.string.copyright,
component.years,
component.copyrightOwner,
component.license.abbreviation
)
val root: View = componentBinding.root
root.tag = component
root.setOnClickListener {
compositeDisposable.add(
showLicense(component)
)
}
binding.licensesSoftwareComponents.addView(root)
registerForContextMenu(root)
}
activeSoftwareComponent?.let { compositeDisposable.add(showLicense(it)) }
return binding.root
}
override fun onSaveInstanceState(savedInstanceState: Bundle) {
super.onSaveInstanceState(savedInstanceState)
activeSoftwareComponent?.let { savedInstanceState.putSerializable(SOFTWARE_COMPONENT_KEY, it) }
}
private fun showLicense(
softwareComponent: SoftwareComponent
): Disposable {
return if (context == null) {
Disposable.empty()
} else {
val context = requireContext()
activeSoftwareComponent = softwareComponent
Observable.fromCallable { getFormattedLicense(context, softwareComponent.license) }
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe { formattedLicense ->
val webViewData = Base64.encodeToString(
formattedLicense.toByteArray(), Base64.NO_PADDING
)
val webView = WebView(context)
webView.loadData(webViewData, "text/html; charset=UTF-8", "base64")
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 {
private const val ARG_COMPONENTS = "components"
private const val SOFTWARE_COMPONENT_KEY = "ACTIVE_SOFTWARE_COMPONENT"
private val NEWPIPE_SOFTWARE_COMPONENT = SoftwareComponent(
"NewPipe",
"2014-2023",
"Team NewPipe",
"https://newpipe.net/",
StandardLicenses.GPL3,
BuildConfig.VERSION_NAME
)
fun newInstance(softwareComponents: ArrayList<SoftwareComponent>): LicenseFragment {
val fragment = LicenseFragment()
fragment.arguments = bundleOf(ARG_COMPONENTS to softwareComponents)
return fragment
}
}
}

View File

@ -1,52 +0,0 @@
package org.schabi.newpipe.about
import android.content.Context
import org.schabi.newpipe.R
import org.schabi.newpipe.util.ThemeHelper
import java.io.IOException
/**
* @param context the context to use
* @param license the license
* @return String which contains a HTML formatted license page
* styled according to the context's theme
*/
fun getFormattedLicense(context: Context, license: License): String {
try {
return context.assets.open(license.filename).bufferedReader().use { it.readText() }
// split the HTML file and insert the stylesheet into the HEAD of the file
.replace("</head>", "<style>${getLicenseStylesheet(context)}</style></head>")
} catch (e: IOException) {
throw IllegalArgumentException("Could not get license file: ${license.filename}", e)
}
}
/**
* @param context the Android context
* @return String which is a CSS stylesheet according to the context's theme
*/
fun getLicenseStylesheet(context: Context): String {
val isLightTheme = ThemeHelper.isLightThemeSelected(context)
val licenseBackgroundColor = getHexRGBColor(
context, if (isLightTheme) R.color.light_license_background_color else R.color.dark_license_background_color
)
val licenseTextColor = getHexRGBColor(
context, if (isLightTheme) R.color.light_license_text_color else R.color.dark_license_text_color
)
val youtubePrimaryColor = getHexRGBColor(
context, if (isLightTheme) R.color.light_youtube_primary_color else R.color.dark_youtube_primary_color
)
return "body{padding:12px 15px;margin:0;background:#$licenseBackgroundColor;color:#$licenseTextColor}" +
"a[href]{color:#$youtubePrimaryColor}pre{white-space:pre-wrap}"
}
/**
* Cast R.color to a hexadecimal color value.
*
* @param context the context to use
* @param color the color number from R.color
* @return a six characters long String with hexadecimal RGB values
*/
fun getHexRGBColor(context: Context, color: Int): String {
return context.getString(color).substring(3)
}

View File

@ -1,17 +0,0 @@
package org.schabi.newpipe.about
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import java.io.Serializable
@Parcelize
class SoftwareComponent
@JvmOverloads
constructor(
val name: String,
val years: String,
val copyrightOwner: String,
val link: String,
val license: License,
val version: String? = null
) : Parcelable, Serializable

View File

@ -1,21 +0,0 @@
package org.schabi.newpipe.about
/**
* Class containing information about standard software licenses.
*/
object StandardLicenses {
@JvmField
val GPL3 = License("GNU General Public License, Version 3.0", "GPLv3", "gpl_3.html")
@JvmField
val APACHE2 = License("Apache License, Version 2.0", "ALv2", "apache2.html")
@JvmField
val MPL2 = License("Mozilla Public License, Version 2.0", "MPL 2.0", "mpl2.html")
@JvmField
val MIT = License("MIT License", "MIT", "mit.html")
@JvmField
val EPL1 = License("Eclipse Public License, Version 1.0", "EPL 1.0", "epl1.html")
}

View File

@ -8,6 +8,7 @@ import androidx.room.Query
import androidx.room.Transaction
import io.reactivex.rxjava3.core.Completable
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.core.Maybe
import org.schabi.newpipe.database.BasicDAO
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
@ -27,7 +28,7 @@ abstract class StreamDAO : BasicDAO<StreamEntity> {
abstract override fun listByService(serviceId: Int): Flowable<List<StreamEntity>>
@Query("SELECT * FROM streams WHERE url = :url AND service_id = :serviceId")
abstract fun getStream(serviceId: Long, url: String): Flowable<List<StreamEntity>>
abstract fun getStream(serviceId: Long, url: String): Maybe<StreamEntity>
@Query("UPDATE streams SET uploader_url = :uploaderUrl WHERE url = :url AND service_id = :serviceId")
abstract fun setUploaderUrl(serviceId: Long, url: String, uploaderUrl: String): Completable

View File

@ -1,5 +1,8 @@
package org.schabi.newpipe.database.stream.dao;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
import androidx.room.Dao;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
@ -12,9 +15,7 @@ import org.schabi.newpipe.database.stream.model.StreamStateEntity;
import java.util.List;
import io.reactivex.rxjava3.core.Flowable;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID;
import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
import io.reactivex.rxjava3.core.Maybe;
@Dao
public interface StreamStateDAO extends BasicDAO<StreamStateEntity> {
@ -32,7 +33,7 @@ public interface StreamStateDAO extends BasicDAO<StreamStateEntity> {
}
@Query("SELECT * FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
Flowable<List<StreamStateEntity>> getState(long streamId);
Maybe<StreamStateEntity> getState(long streamId);
@Query("DELETE FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
int deleteState(long streamId);

View File

@ -39,6 +39,8 @@ import androidx.documentfile.provider.DocumentFile;
import androidx.fragment.app.DialogFragment;
import androidx.preference.PreferenceManager;
import com.evernote.android.state.State;
import com.livefront.bridge.Bridge;
import com.nononsenseapps.filepicker.Utils;
import org.schabi.newpipe.MainActivity;
@ -59,6 +61,8 @@ import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard;
import org.schabi.newpipe.streams.io.StoredDirectoryHelper;
import org.schabi.newpipe.streams.io.StoredFileHelper;
import org.schabi.newpipe.util.AudioTrackAdapter;
import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper;
import org.schabi.newpipe.util.FilePickerActivityHelper;
import org.schabi.newpipe.util.FilenameUtils;
import org.schabi.newpipe.util.ListHelper;
@ -67,8 +71,6 @@ import org.schabi.newpipe.util.SecondaryStreamHelper;
import org.schabi.newpipe.util.SimpleOnSeekBarChangeListener;
import org.schabi.newpipe.util.StreamItemAdapter;
import org.schabi.newpipe.util.StreamItemAdapter.StreamInfoWrapper;
import org.schabi.newpipe.util.AudioTrackAdapter;
import org.schabi.newpipe.util.AudioTrackAdapter.AudioTracksWrapper;
import org.schabi.newpipe.util.ThemeHelper;
import java.io.File;
@ -79,8 +81,6 @@ import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import icepick.Icepick;
import icepick.State;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import us.shandian.giga.get.MissionRecoveryInfo;
import us.shandian.giga.postprocessing.Postprocessing;
@ -214,7 +214,7 @@ public class DownloadDialog extends DialogFragment
context = getContext();
setStyle(STYLE_NO_TITLE, ThemeHelper.getDialogTheme(context));
Icepick.restoreInstanceState(this, savedInstanceState);
Bridge.restoreInstanceState(this, savedInstanceState);
this.audioTrackAdapter = new AudioTrackAdapter(wrappedAudioTracks);
this.subtitleStreamsAdapter = new StreamItemAdapter<>(wrappedSubtitleStreams);
@ -372,7 +372,7 @@ public class DownloadDialog extends DialogFragment
@Override
public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState);
Icepick.saveInstanceState(this, outState);
Bridge.saveInstanceState(this, outState);
}

View File

@ -2,7 +2,6 @@ package org.schabi.newpipe.error;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.app.Activity;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
@ -13,7 +12,6 @@ import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.app.AppCompatActivity;
@ -22,7 +20,6 @@ import androidx.core.content.IntentCompat;
import com.grack.nanojson.JsonWriter;
import org.schabi.newpipe.BuildConfig;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.ActivityErrorBinding;
import org.schabi.newpipe.util.Localization;
@ -187,25 +184,6 @@ public class ErrorActivity extends AppCompatActivity {
.collect(Collectors.joining(separator + "\n", separator + "\n", separator));
}
/**
* Get the checked activity.
*
* @param returnActivity the activity to return to
* @return the casted return activity or null
*/
@Nullable
static Class<? extends Activity> getReturnActivity(final Class<?> returnActivity) {
Class<? extends Activity> checkedReturnActivity = null;
if (returnActivity != null) {
if (Activity.class.isAssignableFrom(returnActivity)) {
checkedReturnActivity = returnActivity.asSubclass(Activity.class);
} else {
checkedReturnActivity = MainActivity.class;
}
}
return checkedReturnActivity;
}
private void buildInfo(final ErrorInfo info) {
String text = "";

View File

@ -188,7 +188,7 @@ public class ReCaptchaActivity extends AppCompatActivity {
handleCookies(Utils.decodeUrlUtf8(url.substring(abuseStart + 13, abuseEnd)));
} catch (final StringIndexOutOfBoundsException e) {
if (MainActivity.DEBUG) {
Log.d(TAG, "handleCookiesFromUrl: invalid google abuse starting at "
Log.e(TAG, "handleCookiesFromUrl: invalid google abuse starting at "
+ abuseStart + " and ending at " + abuseEnd + " for url " + url, e);
}
}

View File

@ -13,6 +13,8 @@ import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.fragment.app.Fragment;
import com.evernote.android.state.State;
import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorInfo;
@ -22,8 +24,6 @@ import org.schabi.newpipe.util.InfoCache;
import java.util.concurrent.atomic.AtomicBoolean;
import icepick.State;
public abstract class BaseStateFragment<I> extends BaseFragment implements ViewContract<I> {
@State
protected AtomicBoolean wasLoading = new AtomicBoolean();
@ -134,6 +134,7 @@ public abstract class BaseStateFragment<I> extends BaseFragment implements ViewC
hideErrorPanel();
}
@Override
public void showEmptyState() {
isLoading.set(false);
if (emptyStateView != null) {

View File

@ -6,9 +6,11 @@ import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.Nullable;
import androidx.compose.ui.platform.ComposeView;
import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R;
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
public class EmptyFragment extends BaseFragment {
private static final String SHOW_MESSAGE = "SHOW_MESSAGE";
@ -26,8 +28,10 @@ public class EmptyFragment extends BaseFragment {
final Bundle savedInstanceState) {
final boolean showMessage = getArguments().getBoolean(SHOW_MESSAGE);
final View view = inflater.inflate(R.layout.fragment_empty, container, false);
view.findViewById(R.id.empty_state_view).setVisibility(
showMessage ? View.VISIBLE : View.GONE);
final ComposeView composeView = view.findViewById(R.id.empty_state_view);
EmptyStateUtil.setEmptyStateComposable(composeView);
composeView.setVisibility(showMessage ? View.VISIBLE : View.GONE);
return view;
}
}

View File

@ -11,6 +11,8 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import com.evernote.android.state.State;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.stream.Description;
@ -19,8 +21,6 @@ import org.schabi.newpipe.util.Localization;
import java.util.List;
import icepick.State;
public class DescriptionFragment extends BaseDescriptionFragment {
@State
@ -31,7 +31,7 @@ public class DescriptionFragment extends BaseDescriptionFragment {
}
public DescriptionFragment() {
// keep empty constructor for IcePick when resuming fragment from memory
// keep empty constructor for State when resuming fragment from memory
}

View File

@ -56,6 +56,7 @@ import androidx.core.content.ContextCompat;
import androidx.fragment.app.Fragment;
import androidx.preference.PreferenceManager;
import com.evernote.android.state.State;
import com.google.android.exoplayer2.PlaybackException;
import com.google.android.exoplayer2.PlaybackParameters;
import com.google.android.material.appbar.AppBarLayout;
@ -73,7 +74,6 @@ import org.schabi.newpipe.error.ReCaptchaActivity;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.Image;
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.ExtractionException;
import org.schabi.newpipe.extractor.stream.AudioStream;
@ -127,8 +127,7 @@ import java.util.Optional;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import coil.util.CoilUtils;
import icepick.State;
import coil3.util.CoilUtils;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
@ -1011,19 +1010,6 @@ public final class VideoDetailFragment
updateTabLayoutVisibility();
}
public void scrollToComment(final CommentsInfoItem comment) {
final int commentsTabPos = pageAdapter.getItemPositionByTitle(COMMENTS_TAB_TAG);
final var fragment = pageAdapter.getItem(commentsTabPos);
// TODO: Implement the scrolling with Compose.
// unexpand the app bar only if scrolling to the comment succeeded
// if (fragment instanceof CommentsFragment commentsFragment &&
// commentsFragment.scrollToComment(comment)) {
// binding.appBarLayout.setExpanded(false, false);
// binding.viewPager.setCurrentItem(commentsTabPos, false);
// }
}
/*//////////////////////////////////////////////////////////////////////////
// Play Utils
//////////////////////////////////////////////////////////////////////////*/

View File

@ -9,6 +9,8 @@ import android.view.View;
import androidx.annotation.NonNull;
import com.evernote.android.state.State;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction;
@ -24,7 +26,6 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Queue;
import icepick.State;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.Disposable;
@ -143,7 +144,7 @@ public abstract class BaseListInfoFragment<I extends InfoItem, L extends ListInf
currentWorker = loadResult(forceLoad)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe((@NonNull L result) -> {
.subscribe((@NonNull final L result) -> {
isLoading.set(false);
currentInfo = result;
currentNextPage = result.getNextPage();

View File

@ -10,6 +10,8 @@ import android.widget.LinearLayout;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.evernote.android.state.State;
import org.schabi.newpipe.R;
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
@ -20,8 +22,6 @@ import org.schabi.newpipe.util.Localization;
import java.util.List;
import icepick.State;
public class ChannelAboutFragment extends BaseDescriptionFragment {
@State
protected ChannelInfo channelInfo;
@ -31,7 +31,7 @@ public class ChannelAboutFragment extends BaseDescriptionFragment {
}
public ChannelAboutFragment() {
// keep empty constructor for IcePick when resuming fragment from memory
// keep empty constructor for State when resuming fragment from memory
}
@Override

View File

@ -10,7 +10,6 @@ import android.graphics.Color;
import android.os.Bundle;
import android.text.TextUtils;
import android.util.Log;
import android.util.TypedValue;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
@ -25,6 +24,7 @@ import androidx.core.graphics.ColorUtils;
import androidx.core.view.MenuProvider;
import androidx.preference.PreferenceManager;
import com.evernote.android.state.State;
import com.google.android.material.snackbar.Snackbar;
import com.google.android.material.tabs.TabLayout;
import com.jakewharton.rxbinding4.view.RxView;
@ -44,6 +44,8 @@ import org.schabi.newpipe.fragments.detail.TabAdapter;
import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.local.feed.notifications.NotificationHelper;
import org.schabi.newpipe.local.subscription.SubscriptionManager;
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
import org.schabi.newpipe.util.ChannelTabHelper;
import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.ExtractorHelper;
@ -59,8 +61,7 @@ import java.util.List;
import java.util.Queue;
import java.util.concurrent.TimeUnit;
import coil.util.CoilUtils;
import icepick.State;
import coil3.util.CoilUtils;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
@ -199,6 +200,11 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
EmptyStateUtil.setEmptyStateComposable(
binding.emptyStateView,
EmptyStateSpec.Companion.getContentNotSupported()
);
tabAdapter = new TabAdapter(getChildFragmentManager());
binding.viewPager.setAdapter(tabAdapter);
binding.tabLayout.setupWithViewPager(binding.viewPager);
@ -249,7 +255,7 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
//////////////////////////////////////////////////////////////////////////*/
private void monitorSubscription(final ChannelInfo info) {
final Consumer<Throwable> onError = (Throwable throwable) -> {
final Consumer<Throwable> onError = (final Throwable throwable) -> {
animate(binding.channelSubscribeButton, false, 100);
showSnackBarError(new ErrorInfo(throwable, UserAction.SUBSCRIPTION_GET,
"Get subscription status", currentInfo));
@ -284,14 +290,14 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
}
private Function<Object, Object> mapOnSubscribe(final SubscriptionEntity subscription) {
return (@NonNull Object o) -> {
return (@NonNull final Object o) -> {
subscriptionManager.insertSubscription(subscription);
return o;
};
}
private Function<Object, Object> mapOnUnsubscribe(final SubscriptionEntity subscription) {
return (@NonNull Object o) -> {
return (@NonNull final Object o) -> {
subscriptionManager.deleteSubscription(subscription);
return o;
};
@ -318,7 +324,7 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
}
private Disposable monitorSubscribeButton(final Function<Object, Object> action) {
final Consumer<Object> onNext = (@NonNull Object o) -> {
final Consumer<Object> onNext = (@NonNull final Object o) -> {
if (DEBUG) {
Log.d(TAG, "Changed subscription status to this channel!");
}
@ -338,7 +344,7 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
}
private Consumer<List<SubscriptionEntity>> getSubscribeUpdateMonitor(final ChannelInfo info) {
return (List<SubscriptionEntity> subscriptionEntities) -> {
return (final List<SubscriptionEntity> subscriptionEntities) -> {
if (DEBUG) {
Log.d(TAG, "subscriptionManager.subscriptionTable.doOnNext() called with: "
+ "subscriptionEntities = [" + subscriptionEntities + "]");
@ -645,8 +651,6 @@ public class ChannelFragment extends BaseStateFragment<ChannelInfo>
return;
}
binding.errorContentNotSupported.setVisibility(View.VISIBLE);
binding.channelKaomoji.setText("(︶︹︺)");
binding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f);
binding.emptyStateView.setVisibility(View.VISIBLE);
}
}

View File

@ -9,6 +9,8 @@ import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.evernote.android.state.State;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.PlaylistControlBinding;
import org.schabi.newpipe.error.UserAction;
@ -24,6 +26,7 @@ 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.ui.emptystate.EmptyStateUtil;
import org.schabi.newpipe.util.ChannelTabHelper;
import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.PlayButtonHelper;
@ -32,13 +35,12 @@ 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
// states must be protected and not private for State being able to access them
@State
protected ListLinkHandler tabHandler;
@State
@ -78,6 +80,12 @@ public class ChannelTabFragment extends BaseListInfoFragment<InfoItem, ChannelTa
return inflater.inflate(R.layout.fragment_channel_tab, container, false);
}
@Override
public void onViewCreated(@NonNull final View rootView, final Bundle savedInstanceState) {
super.onViewCreated(rootView, savedInstanceState);
EmptyStateUtil.setEmptyStateComposable(rootView.findViewById(R.id.empty_state_view));
}
@Override
public void onDestroyView() {
super.onDestroyView();
@ -156,6 +164,7 @@ public class ChannelTabFragment extends BaseListInfoFragment<InfoItem, ChannelTa
}
}
@Override
public PlayQueue getPlayQueue() {
final List<StreamInfoItem> streamItems = infoListAdapter.getItemsList().stream()
.filter(StreamInfoItem.class::isInstance)

View File

@ -3,28 +3,25 @@ package org.schabi.newpipe.fragments.list.comments
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.lifecycle.viewmodel.compose.viewModel
import org.schabi.newpipe.ui.components.comment.CommentSection
import androidx.fragment.compose.content
import org.schabi.newpipe.ui.components.video.comment.CommentSection
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.util.KEY_SERVICE_ID
import org.schabi.newpipe.util.KEY_URL
import org.schabi.newpipe.viewmodels.CommentsViewModel
class CommentsFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
val viewModel = viewModel<CommentsViewModel>()
AppTheme {
CommentSection(commentsFlow = viewModel.comments)
) = content {
AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
CommentSection()
}
}
}

View File

@ -11,6 +11,8 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.appcompat.app.ActionBar;
import com.evernote.android.state.State;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorInfo;
import org.schabi.newpipe.error.UserAction;
@ -29,7 +31,6 @@ import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.KioskTranslator;
import org.schabi.newpipe.util.Localization;
import icepick.State;
import io.reactivex.rxjava3.core.Single;
/**

View File

@ -3,10 +3,9 @@ package org.schabi.newpipe.fragments.list.playlist
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.compose.ui.platform.ComposeView
import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.compose.content
import org.schabi.newpipe.ui.screens.PlaylistScreen
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.util.KEY_SERVICE_ID
@ -17,12 +16,9 @@ class PlaylistFragment : Fragment() {
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
) = ComposeView(requireContext()).apply {
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent {
AppTheme {
PlaylistScreen()
}
) = content {
AppTheme {
PlaylistScreen()
}
}

View File

@ -40,6 +40,8 @@ import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import com.evernote.android.state.State;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.FragmentSearchBinding;
import org.schabi.newpipe.error.ErrorInfo;
@ -62,6 +64,8 @@ import org.schabi.newpipe.ktx.AnimationType;
import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
import org.schabi.newpipe.util.Constants;
import org.schabi.newpipe.util.DeviceUtils;
import org.schabi.newpipe.util.ExtractorHelper;
@ -77,7 +81,6 @@ import java.util.Queue;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import icepick.State;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.core.Single;
@ -343,6 +346,10 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
protected void initViews(final View rootView, final Bundle savedInstanceState) {
super.initViews(rootView, savedInstanceState);
EmptyStateUtil.setEmptyStateComposable(
searchBinding.emptyStateView,
EmptyStateSpec.Companion.getNoSearchResult());
searchBinding.suggestionsList.setAdapter(suggestionListAdapter);
// animations are just strange and useless, since the suggestions keep changing too much
searchBinding.suggestionsList.setItemAnimator(null);
@ -550,7 +557,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
}
});
searchEditText.setOnFocusChangeListener((View v, boolean hasFocus) -> {
searchEditText.setOnFocusChangeListener((final View v, final boolean hasFocus) -> {
if (DEBUG) {
Log.d(TAG, "onFocusChange() called with: "
+ "v = [" + v + "], hasFocus = [" + hasFocus + "]");
@ -611,7 +618,7 @@ public class SearchFragment extends BaseListFragment<SearchInfo, ListExtractor.I
};
searchEditText.addTextChangedListener(textWatcher);
searchEditText.setOnEditorActionListener(
(TextView v, int actionId, KeyEvent event) -> {
(final TextView v, final int actionId, final KeyEvent event) -> {
if (DEBUG) {
Log.d(TAG, "onEditorAction() called with: v = [" + v + "], "
+ "actionId = [" + actionId + "], event = [" + event + "]");

View File

@ -1,176 +0,0 @@
package org.schabi.newpipe.fragments.list.videos;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.View;
import android.view.ViewGroup;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.RelatedItemsHeaderBinding;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.extractor.InfoItem;
import org.schabi.newpipe.extractor.ListExtractor;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.fragments.list.BaseListInfoFragment;
import org.schabi.newpipe.info_list.ItemViewMode;
import org.schabi.newpipe.ktx.ViewUtils;
import java.io.Serializable;
import java.util.function.Supplier;
import io.reactivex.rxjava3.core.Single;
public class RelatedItemsFragment extends BaseListInfoFragment<InfoItem, RelatedItemsInfo>
implements SharedPreferences.OnSharedPreferenceChangeListener {
private static final String INFO_KEY = "related_info_key";
private RelatedItemsInfo relatedItemsInfo;
/*//////////////////////////////////////////////////////////////////////////
// Views
//////////////////////////////////////////////////////////////////////////*/
private RelatedItemsHeaderBinding headerBinding;
public static RelatedItemsFragment getInstance(final StreamInfo info) {
final RelatedItemsFragment instance = new RelatedItemsFragment();
instance.setInitialData(info);
return instance;
}
public RelatedItemsFragment() {
super(UserAction.REQUESTED_STREAM);
}
/*//////////////////////////////////////////////////////////////////////////
// LifeCycle
//////////////////////////////////////////////////////////////////////////*/
@Override
public View onCreateView(@NonNull final LayoutInflater inflater,
@Nullable final ViewGroup container,
@Nullable final Bundle savedInstanceState) {
return inflater.inflate(R.layout.fragment_related_items, container, false);
}
@Override
public void onDestroyView() {
headerBinding = null;
super.onDestroyView();
}
@Override
protected Supplier<View> getListHeaderSupplier() {
if (relatedItemsInfo == null || relatedItemsInfo.getRelatedItems() == null) {
return null;
}
headerBinding = RelatedItemsHeaderBinding
.inflate(activity.getLayoutInflater(), itemsList, false);
final SharedPreferences pref = PreferenceManager
.getDefaultSharedPreferences(requireContext());
final boolean autoplay = pref.getBoolean(getString(R.string.auto_queue_key), false);
headerBinding.autoplaySwitch.setChecked(autoplay);
headerBinding.autoplaySwitch.setOnCheckedChangeListener((compoundButton, b) ->
PreferenceManager.getDefaultSharedPreferences(requireContext()).edit()
.putBoolean(getString(R.string.auto_queue_key), b).apply());
return headerBinding::getRoot;
}
@Override
protected Single<ListExtractor.InfoItemsPage<InfoItem>> loadMoreItemsLogic() {
return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage);
}
/*//////////////////////////////////////////////////////////////////////////
// Contract
//////////////////////////////////////////////////////////////////////////*/
@Override
protected Single<RelatedItemsInfo> loadResult(final boolean forceLoad) {
return Single.fromCallable(() -> relatedItemsInfo);
}
@Override
public void showLoading() {
super.showLoading();
if (headerBinding != null) {
headerBinding.getRoot().setVisibility(View.INVISIBLE);
}
}
@Override
public void handleResult(@NonNull final RelatedItemsInfo result) {
super.handleResult(result);
if (headerBinding != null) {
headerBinding.getRoot().setVisibility(View.VISIBLE);
}
ViewUtils.slideUp(requireView(), 120, 96, 0.06f);
}
/*//////////////////////////////////////////////////////////////////////////
// Utils
//////////////////////////////////////////////////////////////////////////*/
@Override
public void setTitle(final String title) {
// Nothing to do - override parent
}
@Override
public void onCreateOptionsMenu(@NonNull final Menu menu,
@NonNull final MenuInflater inflater) {
// Nothing to do - override parent
}
private void setInitialData(final StreamInfo info) {
super.setInitialData(info.getServiceId(), info.getUrl(), info.getName());
if (this.relatedItemsInfo == null) {
this.relatedItemsInfo = new RelatedItemsInfo(info);
}
}
@Override
public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState);
outState.putSerializable(INFO_KEY, relatedItemsInfo);
}
@Override
protected void onRestoreInstanceState(@NonNull final Bundle savedState) {
super.onRestoreInstanceState(savedState);
final Serializable serializable = savedState.getSerializable(INFO_KEY);
if (serializable instanceof RelatedItemsInfo) {
this.relatedItemsInfo = (RelatedItemsInfo) serializable;
}
}
@Override
public void onSharedPreferenceChanged(final SharedPreferences sharedPreferences,
final String key) {
if (headerBinding != null && getString(R.string.auto_queue_key).equals(key)) {
headerBinding.autoplaySwitch.setChecked(sharedPreferences.getBoolean(key, false));
}
}
@Override
protected ItemViewMode getItemViewMode() {
ItemViewMode mode = super.getItemViewMode();
// Only list mode is supported. Either List or card will be used.
if (mode != ItemViewMode.LIST && mode != ItemViewMode.CARD) {
mode = ItemViewMode.LIST;
}
return mode;
}
}

View File

@ -0,0 +1,36 @@
package org.schabi.newpipe.fragments.list.videos
import android.os.Bundle
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import androidx.fragment.compose.content
import org.schabi.newpipe.extractor.stream.StreamInfo
import org.schabi.newpipe.ktx.serializable
import org.schabi.newpipe.ui.components.video.RelatedItems
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.util.KEY_INFO
class RelatedItemsFragment : Fragment() {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
) = content {
AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
RelatedItems(requireArguments().serializable<StreamInfo>(KEY_INFO)!!)
}
}
}
companion object {
@JvmStatic
fun getInstance(info: StreamInfo) = RelatedItemsFragment().apply {
arguments = bundleOf(KEY_INFO to info)
}
}
}

View File

@ -1,22 +0,0 @@
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

@ -4,6 +4,10 @@ package org.schabi.newpipe.info_list;
* Item view mode for streams & playlist listing screens.
*/
public enum ItemViewMode {
/**
* View mode is automatically determined based on the device configuration.
*/
AUTO,
/**
* Full width list item with thumb on the left and two line title & uploader in right.
*/

View File

@ -346,7 +346,7 @@ public final class InfoItemDialog {
public static void reportErrorDuringInitialization(final Throwable throwable,
final InfoItem item) {
ErrorUtil.showSnackbar(App.getApp().getBaseContext(), new ErrorInfo(
ErrorUtil.showSnackbar(App.getInstance().getBaseContext(), new ErrorInfo(
throwable,
UserAction.OPEN_INFO_ITEM_DIALOG,
"none",

View File

@ -41,10 +41,11 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
* </p>
*/
public enum StreamDialogDefaultEntry {
SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) ->
fetchUploaderUrlIfSparse(fragment.requireContext(), item.getServiceId(), item.getUrl(),
item.getUploaderUrl(), url -> openChannelFragment(fragment, item, url))
),
SHOW_CHANNEL_DETAILS(R.string.show_channel_details, (fragment, item) -> {
final var activity = fragment.requireActivity();
fetchUploaderUrlIfSparse(activity, item.getServiceId(), item.getUrl(),
item.getUploaderUrl(), url -> openChannelFragment(activity, item, url));
}),
/**
* Enqueues the stream automatically to the current PlayerType.

View File

@ -64,8 +64,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
StreamStateEntity state2 = null;
if (DependentPreferenceHelper
.getPositionsInListsEnabled(itemProgressView.getContext())) {
state2 = historyRecordManager.loadStreamState(infoItem)
.blockingGet()[0];
state2 = historyRecordManager.loadStreamState(infoItem).blockingGet();
}
if (state2 != null) {
itemProgressView.setVisibility(View.VISIBLE);
@ -120,7 +119,7 @@ public class StreamMiniInfoItemHolder extends InfoItemHolder {
if (DependentPreferenceHelper.getPositionsInListsEnabled(itemProgressView.getContext())) {
state = historyRecordManager
.loadStreamState(infoItem)
.blockingGet()[0];
.blockingGet();
}
if (state != null && item.getDuration() > 0
&& !StreamTypeUtil.isLiveStream(item.getStreamType())) {

View File

@ -1,25 +1,9 @@
package org.schabi.newpipe.ktx
import android.os.Build
import android.os.Bundle
import android.os.Parcelable
import androidx.core.os.BundleCompat
import java.io.Serializable
import kotlin.reflect.safeCast
inline fun <reified T : Parcelable> Bundle.parcelableArrayList(key: String?): ArrayList<T>? {
return BundleCompat.getParcelableArrayList(this, key, T::class.java)
}
inline fun <reified T : Serializable> Bundle.serializable(key: String?): T? {
return getSerializable(this, key, T::class.java)
}
fun <T : Serializable> getSerializable(bundle: Bundle, key: String?, clazz: Class<T>): T? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
bundle.getSerializable(key, clazz)
} else {
@Suppress("DEPRECATION")
clazz.kotlin.safeCast(bundle.getSerializable(key))
}
return BundleCompat.getSerializable(this, key, T::class.java)
}

View File

@ -0,0 +1,13 @@
package org.schabi.newpipe.ktx
import android.content.Context
import android.content.ContextWrapper
import androidx.fragment.app.FragmentActivity
tailrec fun Context.findFragmentActivity(): FragmentActivity {
return when (this) {
is FragmentActivity -> this
is ContextWrapper -> baseContext.findFragmentActivity()
else -> throw IllegalStateException("Unable to find FragmentActivity")
}
}

View File

@ -18,6 +18,8 @@ import androidx.appcompat.app.AlertDialog;
import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import com.evernote.android.state.State;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
import org.schabi.newpipe.NewPipeDatabase;
@ -35,6 +37,8 @@ import org.schabi.newpipe.local.holder.LocalBookmarkPlaylistItemHolder;
import org.schabi.newpipe.local.holder.RemoteBookmarkPlaylistItemHolder;
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
import org.schabi.newpipe.util.debounce.DebounceSavable;
@ -44,7 +48,6 @@ import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import icepick.State;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
@ -121,6 +124,10 @@ public final class BookmarkFragment extends BaseLocalListFragment<List<PlaylistL
super.initViews(rootView, savedInstanceState);
itemListAdapter.setUseItemHandle(true);
EmptyStateUtil.setEmptyStateComposable(
rootView.findViewById(R.id.empty_state_view),
EmptyStateSpec.Companion.getNoBookmarkedPlaylist()
);
}
@Override

View File

@ -44,11 +44,11 @@ import androidx.lifecycle.ViewModelProvider
import androidx.preference.PreferenceManager
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.evernote.android.state.State
import com.xwray.groupie.GroupieAdapter
import com.xwray.groupie.Item
import com.xwray.groupie.OnItemClickListener
import com.xwray.groupie.OnItemLongClickListener
import icepick.State
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Single
import io.reactivex.rxjava3.disposables.CompositeDisposable
@ -74,6 +74,7 @@ import org.schabi.newpipe.ktx.slideUp
import org.schabi.newpipe.local.feed.item.StreamItem
import org.schabi.newpipe.local.feed.service.FeedLoadService
import org.schabi.newpipe.local.subscription.SubscriptionManager
import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable
import org.schabi.newpipe.util.DeviceUtils
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.NavigationHelper
@ -132,6 +133,7 @@ class FeedFragment : BaseStateFragment<FeedState>() {
override fun onViewCreated(rootView: View, savedInstanceState: Bundle?) {
// super.onViewCreated() calls initListeners() which require the binding to be initialized
_feedBinding = FragmentFeedBinding.bind(rootView)
feedBinding.emptyStateView.setEmptyStateComposable()
super.onViewCreated(rootView, savedInstanceState)
val factory = FeedViewModel.getFactory(requireContext(), groupId)

View File

@ -165,7 +165,7 @@ class FeedViewModel(
fun getFactory(context: Context, groupId: Long) = viewModelFactory {
initializer {
FeedViewModel(
App.getApp(),
App.instance,
groupId,
// Read initial value from preferences
getShowPlayedItemsFromPreferences(context.applicationContext),

View File

@ -18,10 +18,13 @@ package org.schabi.newpipe.local.history;
* along with NewPipe. If not, see <http://www.gnu.org/licenses/>.
*/
import static org.schabi.newpipe.util.ExtractorHelper.getStreamInfo;
import android.content.Context;
import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import androidx.collection.LongLongPair;
import androidx.preference.PreferenceManager;
import org.schabi.newpipe.NewPipeDatabase;
@ -45,7 +48,6 @@ import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.extractor.stream.StreamInfoItem;
import org.schabi.newpipe.local.feed.FeedViewModel;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.util.ExtractorHelper;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
@ -91,47 +93,39 @@ public class HistoryRecordManager {
* @param info the item to mark as watched
* @return a Maybe containing the ID of the item if successful
*/
public Maybe<Long> markAsWatched(final StreamInfoItem info) {
public Completable markAsWatched(final StreamInfoItem info) {
if (!isStreamHistoryEnabled()) {
return Maybe.empty();
return Completable.complete();
}
final OffsetDateTime currentTime = OffsetDateTime.now(ZoneOffset.UTC);
return Maybe.fromCallable(() -> database.runInTransaction(() -> {
final long streamId;
final long duration;
// Duration will not exist if the item was loaded with fast mode, so fetch it if empty
if (info.getDuration() < 0) {
final StreamInfo completeInfo = ExtractorHelper.getStreamInfo(
info.getServiceId(),
info.getUrl(),
false
final var remoteInfo = getStreamInfo(info.getServiceId(), info.getUrl(), false)
.map(item ->
new LongLongPair(item.getDuration(), streamTable.upsert(new StreamEntity(item))));
return Single.just(info)
.filter(item -> item.getDuration() >= 0)
.map(item ->
new LongLongPair(item.getDuration(), streamTable.upsert(new StreamEntity(item)))
)
.subscribeOn(Schedulers.io())
.blockingGet();
duration = completeInfo.getDuration();
streamId = streamTable.upsert(new StreamEntity(completeInfo));
} else {
duration = info.getDuration();
streamId = streamTable.upsert(new StreamEntity(info));
}
.switchIfEmpty(remoteInfo)
.flatMapCompletable(pair -> Completable.fromRunnable(() -> {
final long duration = pair.getFirst();
final long streamId = pair.getSecond();
// Update the stream progress to the full duration of the video
final StreamStateEntity entity = new StreamStateEntity(
streamId,
duration * 1000
);
streamStateTable.upsert(entity);
// Update the stream progress to the full duration of the video
final var entity = new StreamStateEntity(streamId, duration * 1000);
streamStateTable.upsert(entity);
// Add a history entry
final StreamHistoryEntity latestEntry = streamHistoryTable.getLatestEntry(streamId);
if (latestEntry == null) {
// never actually viewed: add history entry but with 0 views
return streamHistoryTable.insert(new StreamHistoryEntity(streamId, currentTime, 0));
} else {
return 0L;
}
})).subscribeOn(Schedulers.io());
// Add a history entry
final var latestEntry = streamHistoryTable.getLatestEntry(streamId);
if (latestEntry == null) {
final var currentTime = OffsetDateTime.now(ZoneOffset.UTC);
// never actually viewed: add history entry but with 0 views
final var entry = new StreamHistoryEntity(streamId, currentTime, 0);
streamHistoryTable.insert(entry);
}
}))
.subscribeOn(Schedulers.io());
}
public Maybe<Long> onViewed(final StreamInfo info) {
@ -221,7 +215,7 @@ public class HistoryRecordManager {
public Flowable<List<String>> getRelatedSearches(final String query,
final int similarQueryLimit,
final int uniqueQueryLimit) {
return query.length() > 0
return !query.isEmpty()
? searchHistoryTable.getSimilarEntries(query, similarQueryLimit)
: searchHistoryTable.getUniqueEntries(uniqueQueryLimit);
}
@ -236,47 +230,31 @@ public class HistoryRecordManager {
public Maybe<StreamStateEntity> loadStreamState(final PlayQueueItem queueItem) {
return queueItem.getStream()
.map(info -> streamTable.upsert(new StreamEntity(info)))
.flatMapPublisher(streamStateTable::getState)
.firstElement()
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0)))
.flatMapMaybe(this::loadStreamState)
.filter(state -> state.isValid(queueItem.getDuration()))
.subscribeOn(Schedulers.io());
}
public Maybe<StreamStateEntity> loadStreamState(final StreamInfo info) {
return Single.fromCallable(() -> streamTable.upsert(new StreamEntity(info)))
.flatMapPublisher(streamStateTable::getState)
.firstElement()
.flatMap(list -> list.isEmpty() ? Maybe.empty() : Maybe.just(list.get(0)))
.filter(state -> state.isValid(info.getDuration()))
.flatMapMaybe(streamStateTable::getState)
.subscribeOn(Schedulers.io());
}
public Completable saveStreamState(@NonNull final StreamInfo info, final long progressMillis) {
return Completable.fromAction(() -> database.runInTransaction(() -> {
final long streamId = streamTable.upsert(new StreamEntity(info));
final StreamStateEntity state = new StreamStateEntity(streamId, progressMillis);
final var state = new StreamStateEntity(streamId, progressMillis);
if (state.isValid(info.getDuration())) {
streamStateTable.upsert(state);
}
})).subscribeOn(Schedulers.io());
}
public Single<StreamStateEntity[]> loadStreamState(final InfoItem info) {
return Single.fromCallable(() -> {
final List<StreamEntity> entities = streamTable
.getStream(info.getServiceId(), info.getUrl()).blockingFirst();
if (entities.isEmpty()) {
return new StreamStateEntity[]{null};
}
final List<StreamStateEntity> states = streamStateTable
.getState(entities.get(0).getUid()).blockingFirst();
if (states.isEmpty()) {
return new StreamStateEntity[]{null};
}
return new StreamStateEntity[]{states.get(0)};
}).subscribeOn(Schedulers.io());
public Maybe<StreamStateEntity> loadStreamState(final InfoItem info) {
return streamTable.getStream(info.getServiceId(), info.getUrl())
.flatMap(entity -> streamStateTable.getState(entity.getUid()))
.subscribeOn(Schedulers.io());
}
public Single<List<StreamStateEntity>> loadLocalStreamStateBatch(
@ -295,13 +273,7 @@ public class HistoryRecordManager {
result.add(null);
continue;
}
final List<StreamStateEntity> states = streamStateTable.getState(streamId)
.blockingFirst();
if (states.isEmpty()) {
result.add(null);
} else {
result.add(states.get(0));
}
result.add(streamStateTable.getState(streamId).blockingGet());
}
return result;
}).subscribeOn(Schedulers.io());

View File

@ -15,6 +15,7 @@ import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.viewbinding.ViewBinding;
import com.evernote.android.state.State;
import com.google.android.material.snackbar.Snackbar;
import org.reactivestreams.Subscriber;
@ -45,7 +46,6 @@ import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import icepick.State;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
@ -368,6 +368,7 @@ public class StatisticsPlaylistFragment
}
}
@Override
public PlayQueue getPlayQueue() {
return getPlayQueue(0);
}

View File

@ -26,6 +26,8 @@ import androidx.recyclerview.widget.ItemTouchHelper;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewbinding.ViewBinding;
import com.evernote.android.state.State;
import org.reactivestreams.Subscriber;
import org.reactivestreams.Subscription;
import org.schabi.newpipe.NewPipeDatabase;
@ -49,12 +51,12 @@ import org.schabi.newpipe.local.BaseLocalListFragment;
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.util.debounce.DebounceSavable;
import org.schabi.newpipe.util.debounce.DebounceSaver;
import org.schabi.newpipe.util.Localization;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.OnClickGesture;
import org.schabi.newpipe.util.PlayButtonHelper;
import org.schabi.newpipe.util.debounce.DebounceSavable;
import org.schabi.newpipe.util.debounce.DebounceSaver;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.ArrayList;
@ -63,7 +65,6 @@ import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.stream.Collectors;
import icepick.State;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
@ -843,6 +844,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment<List<PlaylistSt
}
}
@Override
public PlayQueue getPlayQueue() {
return getPlayQueue(0);
}

View File

@ -1,5 +1,7 @@
package org.schabi.newpipe.local.subscription;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import android.app.Dialog;
import android.content.Intent;
import android.os.Bundle;
@ -10,13 +12,11 @@ import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;
import com.evernote.android.state.State;
import com.livefront.bridge.Bridge;
import org.schabi.newpipe.R;
import icepick.Icepick;
import icepick.State;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
public class ImportConfirmationDialog extends DialogFragment {
@State
protected Intent resultServiceIntent;
@ -57,12 +57,12 @@ public class ImportConfirmationDialog extends DialogFragment {
throw new IllegalStateException("Result intent is null");
}
Icepick.restoreInstanceState(this, savedInstanceState);
Bridge.restoreInstanceState(this, savedInstanceState);
}
@Override
public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState);
Icepick.saveInstanceState(this, outState);
Bridge.saveInstanceState(this, outState);
}
}

View File

@ -20,11 +20,11 @@ import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.GridLayoutManager
import com.evernote.android.state.State
import com.xwray.groupie.Group
import com.xwray.groupie.GroupAdapter
import com.xwray.groupie.Section
import com.xwray.groupie.viewbinding.GroupieViewHolder
import icepick.State
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity.Companion.GROUP_ALL_ID
@ -56,6 +56,7 @@ import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService
import org.schabi.newpipe.local.subscription.services.SubscriptionsImportService.PREVIOUS_EXPORT_MODE
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard
import org.schabi.newpipe.streams.io.StoredFileHelper
import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.OnClickGesture
import org.schabi.newpipe.util.ServiceHelper
@ -257,6 +258,8 @@ class SubscriptionFragment : BaseStateFragment<SubscriptionState>() {
binding.itemsList.adapter = groupAdapter
binding.itemsList.itemAnimator = null
binding.emptyStateView.setEmptyStateComposable()
viewModel = ViewModelProvider(this)[SubscriptionViewModel::class.java]
viewModel.stateLiveData.observe(viewLifecycleOwner) { it?.let(this::handleResult) }
viewModel.feedGroupsLiveData.observe(viewLifecycleOwner) {

View File

@ -27,6 +27,8 @@ import androidx.annotation.StringRes;
import androidx.appcompat.app.ActionBar;
import androidx.core.text.util.LinkifyCompat;
import com.evernote.android.state.State;
import org.schabi.newpipe.BaseFragment;
import org.schabi.newpipe.R;
import org.schabi.newpipe.error.ErrorInfo;
@ -44,8 +46,6 @@ import org.schabi.newpipe.util.ServiceHelper;
import java.util.Collections;
import java.util.List;
import icepick.State;
public class SubscriptionsImportFragment extends BaseFragment {
@State
int currentServiceId = Constants.NO_SERVICE_ID;

View File

@ -18,11 +18,11 @@ import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.evernote.android.state.State
import com.livefront.bridge.Bridge
import com.xwray.groupie.GroupieAdapter
import com.xwray.groupie.OnItemClickListener
import com.xwray.groupie.Section
import icepick.Icepick
import icepick.State
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.databinding.DialogFeedGroupCreateBinding
@ -78,7 +78,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Icepick.restoreInstanceState(this, savedInstanceState)
Bridge.restoreInstanceState(this, savedInstanceState)
setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext()))
groupId = arguments?.getLong(KEY_GROUP_ID, NO_GROUP_SELECTED) ?: NO_GROUP_SELECTED
@ -115,7 +115,7 @@ class FeedGroupDialog : DialogFragment(), BackPressable {
iconsListState = feedGroupCreateBinding.iconSelector.layoutManager?.onSaveInstanceState()
subscriptionsListState = feedGroupCreateBinding.subscriptionsSelectorList.layoutManager?.onSaveInstanceState()
Icepick.saveInstanceState(this, outState)
Bridge.saveInstanceState(this, outState)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View File

@ -11,10 +11,10 @@ import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.ItemTouchHelper.SimpleCallback
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.evernote.android.state.State
import com.livefront.bridge.Bridge
import com.xwray.groupie.GroupieAdapter
import com.xwray.groupie.TouchCallback
import icepick.Icepick
import icepick.State
import org.schabi.newpipe.R
import org.schabi.newpipe.database.feed.model.FeedGroupEntity
import org.schabi.newpipe.databinding.DialogFeedGroupReorderBinding
@ -23,10 +23,6 @@ import org.schabi.newpipe.local.subscription.dialog.FeedGroupReorderDialogViewMo
import org.schabi.newpipe.local.subscription.item.FeedGroupReorderItem
import org.schabi.newpipe.util.ThemeHelper
import java.util.Collections
import kotlin.collections.ArrayList
import kotlin.collections.List
import kotlin.collections.map
import kotlin.collections.sortedBy
class FeedGroupReorderDialog : DialogFragment() {
private var _binding: DialogFeedGroupReorderBinding? = null
@ -42,7 +38,7 @@ class FeedGroupReorderDialog : DialogFragment() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Icepick.restoreInstanceState(this, savedInstanceState)
Bridge.restoreInstanceState(this, savedInstanceState)
setStyle(STYLE_NO_TITLE, ThemeHelper.getMinWidthDialogTheme(requireContext()))
}
@ -80,7 +76,7 @@ class FeedGroupReorderDialog : DialogFragment() {
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
Icepick.saveInstanceState(this, outState)
Bridge.saveInstanceState(this, outState)
}
private fun handleGroups(list: List<FeedGroupEntity>) {

View File

@ -3,14 +3,18 @@ package org.schabi.newpipe.local.subscription.item
import android.view.View
import com.xwray.groupie.viewbinding.BindableItem
import org.schabi.newpipe.R
import org.schabi.newpipe.databinding.ListEmptyViewBinding
import org.schabi.newpipe.databinding.ListEmptyViewSubscriptionsBinding
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec
import org.schabi.newpipe.ui.emptystate.setEmptyStateComposable
/**
* When there are no subscriptions, show a hint to the user about how to import subscriptions
*/
class ImportSubscriptionsHintPlaceholderItem : BindableItem<ListEmptyViewBinding>() {
class ImportSubscriptionsHintPlaceholderItem : BindableItem<ListEmptyViewSubscriptionsBinding>() {
override fun getLayout(): Int = R.layout.list_empty_view_subscriptions
override fun bind(viewBinding: ListEmptyViewBinding, position: Int) {}
override fun bind(viewBinding: ListEmptyViewSubscriptionsBinding, position: Int) {
viewBinding.root.setEmptyStateComposable(EmptyStateSpec.NoSubscriptionsHint)
}
override fun getSpanSize(spanCount: Int, position: Int): Int = spanCount
override fun initializeViewBinding(view: View) = ListEmptyViewBinding.bind(view)
override fun initializeViewBinding(view: View) = ListEmptyViewSubscriptionsBinding.bind(view)
}

View File

@ -0,0 +1,27 @@
package org.schabi.newpipe.paging
import androidx.paging.PagingSource
import androidx.paging.PagingState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.Page
import org.schabi.newpipe.extractor.comments.CommentsInfo
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
class CommentRepliesSource(
private val commentInfo: CommentsInfoItem,
) : PagingSource<Page, CommentsInfoItem>() {
private val service = NewPipe.getService(commentInfo.serviceId)
override suspend fun load(params: LoadParams<Page>): LoadResult<Page, CommentsInfoItem> {
// params.key is null the first time load() is called, and we need to return the first page
val repliesPage = params.key ?: commentInfo.replies
val info = withContext(Dispatchers.IO) {
CommentsInfo.getMoreItems(service, commentInfo.url, repliesPage)
}
return LoadResult.Page(info.items, null, info.nextPage)
}
override fun getRefreshKey(state: PagingState<Page, CommentsInfoItem>) = null
}

View File

@ -8,38 +8,23 @@ import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.Page
import org.schabi.newpipe.extractor.comments.CommentsInfo
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
import org.schabi.newpipe.util.NO_SERVICE_ID
import org.schabi.newpipe.ui.components.video.comment.CommentInfo
class CommentsSource(
serviceId: Int,
private val url: String?,
private val repliesPage: Page?
) : PagingSource<Page, CommentsInfoItem>() {
init {
require(serviceId != NO_SERVICE_ID) { "serviceId is NO_SERVICE_ID" }
}
private val service = NewPipe.getService(serviceId)
class CommentsSource(private val commentInfo: CommentInfo) : PagingSource<Page, CommentsInfoItem>() {
private val service = NewPipe.getService(commentInfo.serviceId)
override suspend fun load(params: LoadParams<Page>): LoadResult<Page, CommentsInfoItem> {
// repliesPage is non-null only when used to load the comment replies
val nextKey = params.key ?: repliesPage
return withContext(Dispatchers.IO) {
nextKey?.let {
val info = CommentsInfo.getMoreItems(service, url, it)
LoadResult.Page(info.items, null, info.nextPage)
} ?: run {
val info = CommentsInfo.getInfo(service, url)
if (info.isCommentsDisabled) {
LoadResult.Error(CommentsDisabledException())
} else {
LoadResult.Page(info.relatedItems, null, info.nextPage)
}
// params.key is null the first time the load() function is called, so we need to return the
// first batch of already-loaded comments
if (params.key == null) {
return LoadResult.Page(commentInfo.comments, null, commentInfo.nextPage)
} else {
val info = withContext(Dispatchers.IO) {
CommentsInfo.getMoreItems(service, commentInfo.url, params.key)
}
return LoadResult.Page(info.items, null, info.nextPage)
}
}
override fun getRefreshKey(state: PagingState<Page, CommentsInfoItem>) = null
}
class CommentsDisabledException : RuntimeException()

View File

@ -46,6 +46,7 @@ import static org.schabi.newpipe.util.ListHelper.getPopupResolutionIndex;
import static org.schabi.newpipe.util.ListHelper.getResolutionIndex;
import static org.schabi.newpipe.util.Localization.assureCorrectAppLanguage;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static coil3.Image_androidKt.toBitmap;
import android.content.BroadcastReceiver;
import android.content.Context;
@ -53,14 +54,12 @@ import android.content.Intent;
import android.content.IntentFilter;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.drawable.Drawable;
import android.media.AudioManager;
import android.util.Log;
import android.view.LayoutInflater;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.graphics.drawable.DrawableKt;
import androidx.core.math.MathUtils;
import androidx.preference.PreferenceManager;
@ -125,7 +124,7 @@ import java.util.List;
import java.util.Optional;
import java.util.stream.IntStream;
import coil.target.Target;
import coil3.target.Target;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
@ -193,7 +192,7 @@ public final class Player implements PlaybackListener, Listener {
@Nullable
private Bitmap currentThumbnail;
@Nullable
private coil.request.Disposable thumbnailDisposable;
private coil3.request.Disposable thumbnailDisposable;
/*//////////////////////////////////////////////////////////////////////////
// Player
@ -789,27 +788,26 @@ public final class Player implements PlaybackListener, Listener {
// scale down the notification thumbnail for performance
final var thumbnailTarget = new Target() {
@Override
public void onError(@Nullable final Drawable error) {
public void onError(@Nullable final coil3.Image error) {
Log.e(TAG, "Thumbnail - onError() called");
// there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too.
onThumbnailLoaded(null);
}
@Override
public void onStart(@Nullable final Drawable placeholder) {
public void onStart(@Nullable final coil3.Image placeholder) {
if (DEBUG) {
Log.d(TAG, "Thumbnail - onStart() called");
}
}
@Override
public void onSuccess(@NonNull final Drawable result) {
public void onSuccess(@NonNull final coil3.Image result) {
if (DEBUG) {
Log.d(TAG, "Thumbnail - onSuccess() called with: drawable = [" + result + "]");
}
// there is a new thumbnail, so e.g. the end screen thumbnail needs to change, too.
onThumbnailLoaded(DrawableKt.toBitmapOrNull(result, result.getIntrinsicWidth(),
result.getIntrinsicHeight(), null));
onThumbnailLoaded(toBitmap(result));
}
};
thumbnailDisposable = CoilHelper.INSTANCE

View File

@ -24,6 +24,9 @@ import androidx.core.math.MathUtils;
import androidx.fragment.app.DialogFragment;
import androidx.preference.PreferenceManager;
import com.evernote.android.state.State;
import com.livefront.bridge.Bridge;
import org.schabi.newpipe.R;
import org.schabi.newpipe.databinding.DialogPlaybackParameterBinding;
import org.schabi.newpipe.player.ui.VideoPlayerUi;
@ -37,9 +40,6 @@ import java.util.function.DoubleConsumer;
import java.util.function.DoubleFunction;
import java.util.function.DoubleSupplier;
import icepick.Icepick;
import icepick.State;
public class PlaybackParameterDialog extends DialogFragment {
private static final String TAG = "PlaybackParameterDialog";
@ -135,7 +135,7 @@ public class PlaybackParameterDialog extends DialogFragment {
@Override
public void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState);
Icepick.saveInstanceState(this, outState);
Bridge.saveInstanceState(this, outState);
}
/*//////////////////////////////////////////////////////////////////////////
@ -146,7 +146,7 @@ public class PlaybackParameterDialog extends DialogFragment {
@Override
public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
assureCorrectAppLanguage(getContext());
Icepick.restoreInstanceState(this, savedInstanceState);
Bridge.restoreInstanceState(this, savedInstanceState);
binding = DialogPlaybackParameterBinding.inflate(getLayoutInflater());
initUI();

View File

@ -16,8 +16,8 @@ import com.google.android.exoplayer2.PlaybackParameters;
import org.schabi.newpipe.App;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.Player;
import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.event.PlayerServiceEventListener;
import org.schabi.newpipe.player.event.PlayerServiceExtendedEventListener;
@ -116,7 +116,7 @@ public final class PlayerHolder {
// helper to handle context in common place as using the same
// context to bind/unbind a service is crucial
private Context getCommonContext() {
return App.getApp();
return App.getInstance();
}
public void startService(final boolean playAfterConnect,

View File

@ -38,7 +38,9 @@ public class MediaSessionPlayerUi extends PlayerUi
implements SharedPreferences.OnSharedPreferenceChangeListener {
private static final String TAG = "MediaSessUi";
@Nullable
private MediaSessionCompat mediaSession;
@Nullable
private MediaSessionConnector sessionConnector;
private final String ignoreHardwareMediaButtonsKey;
@ -198,6 +200,11 @@ public class MediaSessionPlayerUi extends PlayerUi
return;
}
if (sessionConnector == null) {
// sessionConnector will be null after destroyPlayer is called
return;
}
// only use the fourth and fifth actions (the settings page also shows only the last 2 on
// Android 13+)
final List<NotificationActionData> newNotificationActions = IntStream.of(3, 4)

View File

@ -179,7 +179,7 @@ public class SeekbarPreviewThumbnailHolder {
// Gets the bitmap within the timeout of 15 seconds imposed by default by OkHttpClient
// Ensure that you are not running on the main thread, otherwise this will hang
final var bitmap = CoilHelper.INSTANCE.loadBitmapBlocking(App.getApp(), url);
final var bitmap = CoilHelper.INSTANCE.loadBitmapBlocking(App.getInstance(), url);
if (sw != null) {
Log.d(TAG, "Download of bitmap for seekbarPreview from '" + url + "' took "

View File

@ -15,7 +15,7 @@ import org.schabi.newpipe.extractor.localization.Localization;
import org.schabi.newpipe.util.image.ImageStrategy;
import org.schabi.newpipe.util.image.PreferredImageQuality;
import coil.Coil;
import coil3.SingletonImageLoader;
public class ContentSettingsFragment extends BasePreferenceFragment {
private String youtubeRestrictedModeEnabledKey;
@ -41,7 +41,7 @@ public class ContentSettingsFragment extends BasePreferenceFragment {
(preference, newValue) -> {
ImageStrategy.setPreferredImageQuality(PreferredImageQuality
.fromPreferenceKey(requireContext(), (String) newValue));
final var loader = Coil.imageLoader(preference.getContext());
final var loader = SingletonImageLoader.get(preference.getContext());
loader.getMemoryCache().clear();
loader.getDiskCache().clear();
Toast.makeText(preference.getContext(),

View File

@ -0,0 +1,27 @@
package org.schabi.newpipe.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import org.schabi.newpipe.R
import org.schabi.newpipe.settings.viewmodel.SettingsViewModel
import org.schabi.newpipe.ui.SwitchPreference
import org.schabi.newpipe.ui.theme.SizeTokens
@Composable
fun DebugScreen(viewModel: SettingsViewModel, modifier: Modifier = Modifier) {
val settingsLayoutRedesign by viewModel.settingsLayoutRedesign.collectAsState()
Column(modifier = modifier) {
SwitchPreference(
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
R.string.settings_layout_redesign,
settingsLayoutRedesign,
viewModel::toggleSettingsLayoutRedesign
)
}
}

View File

@ -156,7 +156,7 @@ public final class NewPipeSettings {
prefs.getInt(disabledTunnelingAutomaticallyKey, -1) == 0
&& !prefs.getBoolean(disabledTunnelingKey, false);
if (App.getApp().isFirstRun()
if (App.getInstance().isFirstRun()
|| (wasDeviceBlacklistUpdated && !wasMediaTunnelingEnabledByUser)) {
setMediaTunneling(context);
}

View File

@ -11,6 +11,7 @@ import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.compose.ui.platform.ComposeView;
import androidx.fragment.app.DialogFragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
@ -19,6 +20,8 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.database.subscription.SubscriptionEntity;
import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.local.subscription.SubscriptionManager;
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
import org.schabi.newpipe.util.ThemeHelper;
import org.schabi.newpipe.util.image.CoilHelper;
@ -57,7 +60,7 @@ public class SelectChannelFragment extends DialogFragment {
private OnCancelListener onCancelListener = null;
private ProgressBar progressBar;
private TextView emptyView;
private ComposeView emptyView;
private RecyclerView recyclerView;
private List<SubscriptionEntity> subscriptions = new Vector<>();
@ -91,6 +94,9 @@ public class SelectChannelFragment extends DialogFragment {
progressBar = v.findViewById(R.id.progressBar);
emptyView = v.findViewById(R.id.empty_state_view);
EmptyStateUtil.setEmptyStateComposable(emptyView,
EmptyStateSpec.Companion.getNoSubscriptions());
progressBar.setVisibility(View.VISIBLE);
recyclerView.setVisibility(View.GONE);
emptyView.setVisibility(View.GONE);

View File

@ -11,6 +11,7 @@ import android.widget.ProgressBar;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.compose.ui.platform.ComposeView;
import androidx.fragment.app.DialogFragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
@ -27,6 +28,8 @@ import org.schabi.newpipe.error.ErrorUtil;
import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.local.playlist.LocalPlaylistManager;
import org.schabi.newpipe.local.playlist.RemotePlaylistManager;
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
import org.schabi.newpipe.util.image.CoilHelper;
import java.util.List;
@ -40,7 +43,7 @@ public class SelectPlaylistFragment extends DialogFragment {
private OnSelectedListener onSelectedListener = null;
private ProgressBar progressBar;
private TextView emptyView;
private ComposeView emptyView;
private RecyclerView recyclerView;
private Disposable disposable = null;
@ -62,6 +65,8 @@ public class SelectPlaylistFragment extends DialogFragment {
recyclerView = v.findViewById(R.id.items_list);
emptyView = v.findViewById(R.id.empty_state_view);
EmptyStateUtil.setEmptyStateComposable(emptyView,
EmptyStateSpec.Companion.getNoBookmarkedPlaylist());
recyclerView.setLayoutManager(new LinearLayoutManager(getContext()));
final SelectPlaylistAdapter playlistAdapter = new SelectPlaylistAdapter();
recyclerView.setAdapter(playlistAdapter);

View File

@ -1,5 +1,7 @@
package org.schabi.newpipe.settings;
import static org.schabi.newpipe.MainActivity.DEBUG;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Log;
@ -18,8 +20,6 @@ import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
import static org.schabi.newpipe.MainActivity.DEBUG;
/**
* In order to add a migration, follow these steps, given P is the previous version:<br>
* - in the class body add a new {@code MIGRATION_P_P+1 = new Migration(P, P+1) { ... }} and put in
@ -171,7 +171,7 @@ public final class SettingMigrations {
final int lastPrefVersion = sp.getInt(lastPrefVersionKey, 0);
// no migration to run, already up to date
if (App.getApp().isFirstRun()) {
if (App.getInstance().isFirstRun()) {
sp.edit().putInt(lastPrefVersionKey, VERSION).apply();
return;
} else if (lastPrefVersion == VERSION) {

View File

@ -21,7 +21,9 @@ import androidx.fragment.app.FragmentManager;
import androidx.preference.Preference;
import androidx.preference.PreferenceFragmentCompat;
import com.evernote.android.state.State;
import com.jakewharton.rxbinding4.widget.RxTextView;
import com.livefront.bridge.Bridge;
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R;
@ -41,9 +43,6 @@ import org.schabi.newpipe.views.FocusOverlayView;
import java.util.concurrent.TimeUnit;
import icepick.Icepick;
import icepick.State;
/*
* Created by Christian Schabesberger on 31.08.15.
*
@ -93,7 +92,7 @@ public class SettingsActivity extends AppCompatActivity implements
assureCorrectAppLanguage(this);
super.onCreate(savedInstanceBundle);
Icepick.restoreInstanceState(this, savedInstanceBundle);
Bridge.restoreInstanceState(this, savedInstanceBundle);
final boolean restored = savedInstanceBundle != null;
final SettingsLayoutBinding settingsLayoutBinding =
@ -125,7 +124,7 @@ public class SettingsActivity extends AppCompatActivity implements
@Override
protected void onSaveInstanceState(@NonNull final Bundle outState) {
super.onSaveInstanceState(outState);
Icepick.saveInstanceState(this, outState);
Bridge.saveInstanceState(this, outState);
}
@Override

View File

@ -0,0 +1,23 @@
package org.schabi.newpipe.settings
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import org.schabi.newpipe.R
import org.schabi.newpipe.ui.TextPreference
@Composable
fun SettingsScreen(
onSelectSettingOption: (SettingsScreenKey) -> Unit,
modifier: Modifier = Modifier
) {
Column(modifier = modifier) {
TextPreference(
title = R.string.settings_category_debug_title,
onClick = { onSelectSettingOption(SettingsScreenKey.DEBUG) }
)
HorizontalDivider(color = Color.Black)
}
}

View File

@ -0,0 +1,85 @@
package org.schabi.newpipe.settings
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import dagger.hilt.android.AndroidEntryPoint
import org.schabi.newpipe.R
import org.schabi.newpipe.settings.viewmodel.SettingsViewModel
import org.schabi.newpipe.ui.Toolbar
import org.schabi.newpipe.ui.theme.AppTheme
const val SCREEN_TITLE_KEY = "SCREEN_TITLE_KEY"
@AndroidEntryPoint
class SettingsV2Activity : ComponentActivity() {
private val settingsViewModel: SettingsViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
var screenTitle by remember { mutableIntStateOf(SettingsScreenKey.ROOT.screenTitle) }
navController.addOnDestinationChangedListener { _, _, arguments ->
screenTitle =
arguments?.getInt(SCREEN_TITLE_KEY) ?: SettingsScreenKey.ROOT.screenTitle
}
AppTheme {
Scaffold(topBar = {
Toolbar(
title = stringResource(id = screenTitle),
hasSearch = true,
onSearchQueryChange = null // TODO: Add suggestions logic
)
}) { padding ->
NavHost(
navController = navController,
startDestination = SettingsScreenKey.ROOT.name,
modifier = Modifier.padding(padding)
) {
composable(
SettingsScreenKey.ROOT.name,
listOf(createScreenTitleArg(SettingsScreenKey.ROOT.screenTitle))
) {
SettingsScreen(onSelectSettingOption = { screen ->
navController.navigate(screen.name)
})
}
composable(
SettingsScreenKey.DEBUG.name,
listOf(createScreenTitleArg(SettingsScreenKey.DEBUG.screenTitle))
) {
DebugScreen(settingsViewModel)
}
}
}
}
}
}
}
fun createScreenTitleArg(@StringRes screenTitle: Int) = navArgument(SCREEN_TITLE_KEY) {
defaultValue = screenTitle
}
enum class SettingsScreenKey(@StringRes val screenTitle: Int) {
ROOT(R.string.settings),
DEBUG(R.string.settings_category_debug_title)
}

View File

@ -11,6 +11,8 @@ import androidx.fragment.app.Fragment;
import androidx.recyclerview.widget.LinearLayoutManager;
import org.schabi.newpipe.databinding.SettingsPreferencesearchFragmentBinding;
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec;
import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
import java.util.List;
@ -39,6 +41,9 @@ public class PreferenceSearchFragment extends Fragment {
binding = SettingsPreferencesearchFragmentBinding.inflate(inflater, container, false);
binding.searchResults.setLayoutManager(new LinearLayoutManager(getContext()));
EmptyStateUtil.setEmptyStateComposable(
binding.emptyStateView,
EmptyStateSpec.Companion.getNoSearchMaxSizeResult());
adapter = new PreferenceSearchAdapter();
adapter.setOnItemClickListener(this::onItemClicked);

View File

@ -0,0 +1,39 @@
package org.schabi.newpipe.settings.viewmodel
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import org.schabi.newpipe.R
import javax.inject.Inject
@HiltViewModel
class SettingsViewModel @Inject constructor(
@ApplicationContext context: Context,
private val preferenceManager: SharedPreferences
) : AndroidViewModel(context.applicationContext as Application) {
private var _settingsLayoutRedesignPref: Boolean
get() = preferenceManager.getBoolean(
ContextCompat.getString(getApplication(), R.string.settings_layout_redesign_key), false
)
set(value) {
preferenceManager.edit().putBoolean(
ContextCompat.getString(getApplication(), R.string.settings_layout_redesign_key),
value
).apply()
}
private val _settingsLayoutRedesign: MutableStateFlow<Boolean> =
MutableStateFlow(_settingsLayoutRedesignPref)
val settingsLayoutRedesign = _settingsLayoutRedesign.asStateFlow()
fun toggleSettingsLayoutRedesign(newState: Boolean) {
_settingsLayoutRedesign.value = newState
_settingsLayoutRedesignPref = newState
}
}

View File

@ -0,0 +1,53 @@
package org.schabi.newpipe.ui
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import org.schabi.newpipe.ui.theme.SizeTokens
@Composable
fun SwitchPreference(
modifier: Modifier = Modifier,
@StringRes title: Int,
isChecked: Boolean,
onCheckedChange: (Boolean) -> Unit,
@StringRes summary: Int? = null
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier.fillMaxWidth()
) {
Column {
Text(
text = stringResource(id = title),
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Start,
)
summary?.let {
Text(
text = stringResource(id = summary),
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Start,
)
}
}
Spacer(modifier = Modifier.width(SizeTokens.SpacingSmall))
Switch(checked = isChecked, onCheckedChange = onCheckedChange)
}
}

View File

@ -0,0 +1,66 @@
package org.schabi.newpipe.ui
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import org.schabi.newpipe.ui.theme.SizeTokens
@Composable
fun TextPreference(
modifier: Modifier = Modifier,
@StringRes title: Int,
@DrawableRes icon: Int? = null,
@StringRes summary: Int? = null,
onClick: () -> Unit,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier
.fillMaxWidth()
.padding(SizeTokens.SpacingSmall)
.defaultMinSize(minHeight = SizeTokens.SpaceMinSize)
.clickable { onClick() }
) {
icon?.let {
Icon(
painter = painterResource(id = icon),
contentDescription = "icon for $title preference"
)
Spacer(modifier = Modifier.width(SizeTokens.SpacingSmall))
}
Column {
Text(
text = stringResource(id = title),
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Start,
)
summary?.let {
Text(
text = stringResource(id = summary),
modifier = Modifier.padding(SizeTokens.SpacingExtraSmall),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Start,
)
}
}
}
}

View File

@ -0,0 +1,147 @@
package org.schabi.newpipe.ui.components.about
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat.getDrawable
import coil3.compose.AsyncImage
import my.nanihadesuka.compose.ColumnScrollbar
import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.R
import org.schabi.newpipe.ui.components.common.defaultThemedScrollbarSettings
import org.schabi.newpipe.util.external_communication.ShareUtils
private val ABOUT_ITEMS = listOf(
AboutData(R.string.faq_title, R.string.faq_description, R.string.faq, R.string.faq_url),
AboutData(
R.string.contribution_title, R.string.contribution_encouragement,
R.string.view_on_github, R.string.github_url
),
AboutData(
R.string.donation_title, R.string.donation_encouragement, R.string.give_back,
R.string.donation_url
),
AboutData(
R.string.website_title, R.string.website_encouragement, R.string.open_in_browser,
R.string.website_url
),
AboutData(
R.string.privacy_policy_title, R.string.privacy_policy_encouragement,
R.string.read_privacy_policy, R.string.privacy_policy_url
)
)
private class AboutData(
@StringRes val title: Int,
@StringRes val description: Int,
@StringRes val buttonText: Int,
@StringRes val url: Int
)
private class AboutDataProvider : CollectionPreviewParameterProvider<AboutData>(ABOUT_ITEMS)
@Preview(backgroundColor = 0xFFFFFFFF, showBackground = true)
@Composable
@NonRestartableComposable
fun AboutTab() {
val scrollState = rememberScrollState()
ColumnScrollbar(state = scrollState, settings = defaultThemedScrollbarSettings()) {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(scrollState),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Column(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth()
.wrapContentSize(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
// note: the preview
val context = LocalContext.current
val launcherDrawable = remember { getDrawable(context, R.mipmap.ic_launcher) }
AsyncImage(
model = launcherDrawable,
contentDescription = stringResource(R.string.app_name),
)
Spacer(Modifier.height(4.dp))
Text(
text = stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
textAlign = TextAlign.Center,
)
Text(
text = BuildConfig.VERSION_NAME,
style = MaterialTheme.typography.titleMedium,
textAlign = TextAlign.Center,
)
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.app_description),
textAlign = TextAlign.Center,
)
}
for (item in ABOUT_ITEMS) {
AboutItem(item, Modifier.padding(horizontal = 16.dp))
}
Spacer(Modifier.height(8.dp))
}
}
}
@Preview(backgroundColor = 0xFFFFFFFF, showBackground = true)
@Composable
@NonRestartableComposable
private fun AboutItem(
@PreviewParameter(AboutDataProvider::class) aboutData: AboutData,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
Text(
text = stringResource(aboutData.title),
style = MaterialTheme.typography.titleMedium
)
Text(
text = stringResource(aboutData.description),
style = MaterialTheme.typography.bodyMedium
)
val context = LocalContext.current
TextButton(
modifier = Modifier
.fillMaxWidth()
.wrapContentWidth(Alignment.End),
onClick = { ShareUtils.openUrlInApp(context, context.getString(aboutData.url)) }
) {
Text(text = stringResource(aboutData.buttonText))
}
}
}

View File

@ -0,0 +1,186 @@
@file:OptIn(ExperimentalLayoutApi::class)
package org.schabi.newpipe.ui.components.about
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Badge
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider
import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
import androidx.compose.ui.unit.dp
import com.mikepenz.aboutlibraries.entity.Developer
import com.mikepenz.aboutlibraries.entity.Library
import com.mikepenz.aboutlibraries.entity.License
import com.mikepenz.aboutlibraries.entity.Organization
import com.mikepenz.aboutlibraries.entity.Scm
import com.mikepenz.aboutlibraries.ui.compose.m3.util.author
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.util.external_communication.ShareUtils
@Composable
fun Library(
@PreviewParameter(LibraryProvider::class) library: Library,
showLicenseDialog: (licenseFilename: String) -> Unit,
descriptionMaxLines: Int,
) {
val spdxLicense = library.licenses.firstOrNull()?.spdxId?.takeIf { it.isNotBlank() }
val licenseAssetPath = spdxLicense?.let { SPDX_ID_TO_ASSET_PATH[it] }
val context = LocalContext.current
Column(
modifier = (
if (licenseAssetPath != null) {
Modifier.clickable {
showLicenseDialog(licenseAssetPath)
}
} else if (spdxLicense != null) {
Modifier.clickable {
ShareUtils.openUrlInBrowser(context, "https://spdx.org/licenses/$spdxLicense.html")
}
} else {
Modifier
}
)
.padding(vertical = 8.dp, horizontal = 16.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = library.name,
modifier = Modifier.weight(0.75f),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val version = library.artifactVersion
if (!version.isNullOrBlank()) {
Text(
version,
modifier = if (version.length > 12) {
// limit the version size if it's too many characters (can happen e.g. if
// the version is a commit hash)
Modifier.weight(0.25f)
} else {
Modifier
}.padding(start = 8.dp),
style = MaterialTheme.typography.labelMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
val author = library.author
if (author.isNotBlank()) {
Text(
text = author,
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
val description = library.description
if (!description.isNullOrBlank() && description != library.name) {
Spacer(Modifier.height(3.dp))
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
maxLines = descriptionMaxLines,
overflow = TextOverflow.Ellipsis,
)
}
if (library.licenses.isNotEmpty()) {
FlowRow(
modifier = Modifier.padding(top = 6.dp, bottom = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
library.licenses.forEach {
Badge {
Text(text = it.spdxId?.takeIf { it.isNotBlank() } ?: it.name)
}
}
}
}
}
}
@Preview(showBackground = true, backgroundColor = 0xFFFFFFFF)
@Composable
private fun LibraryPreview(@PreviewParameter(LibraryProvider::class) library: Library) {
AppTheme {
Library(library, {}, 2)
}
}
private class LibraryProvider : CollectionPreviewParameterProvider<Library>(
listOf(
Library(
uniqueId = "org.schabi.newpipe.extractor",
artifactVersion = "v0.24.3",
name = "NewPipeExtractor",
description = "NewPipe Extractor is a library for extracting things from streaming sites. It is a core component of NewPipe, but could be used independently.",
website = "https://newpipe.net",
developers = listOf(Developer("TeamNewPipe", "https://newpipe.net")).toImmutableList(),
organization = Organization("TeamNewPipe", "https://newpipe.net"),
scm = Scm(null, null, "https://github.com/TeamNewPipe/NewPipeExtractor"),
licenses = setOf(
License(
name = "GNU General Public License v3.0",
url = "https://api.github.com/licenses/gpl-3.0",
year = null,
spdxId = "GPL-3.0-only",
licenseContent = LoremIpsum().values.first(),
hash = "1234"
),
License(
name = "GNU General Public License v3.0",
url = "https://api.github.com/licenses/gpl-3.0",
year = null,
spdxId = "GPL-3.0-only",
licenseContent = LoremIpsum().values.first(),
hash = "4321"
)
).toImmutableSet()
),
Library(
uniqueId = "org.schabi.newpipe.extractor",
artifactVersion = "v0.24.3",
name = "NewPipeExtractor",
description = "NewPipe Extractor is a library for extracting things from streaming sites. It is a core component of NewPipe, but could be used independently.",
website = null,
developers = listOf<Developer>().toImmutableList(),
organization = null,
scm = null,
licenses = setOf(
License(
name = "GNU General Public License v3.0",
url = "https://api.github.com/licenses/gpl-3.0",
year = null,
spdxId = "GPL-3.0-only",
licenseContent = LoremIpsum().values.first(),
hash = "1234"
)
).toImmutableSet()
)
)
)

View File

@ -0,0 +1,138 @@
/**
* The library definitions for most libraries are autogenerated by the AboutLibraries plugin.
* This file is only for TeamNewPipe-related libraries.
*/
package org.schabi.newpipe.ui.components.about
import android.content.Context
import com.mikepenz.aboutlibraries.entity.Developer
import com.mikepenz.aboutlibraries.entity.Library
import com.mikepenz.aboutlibraries.entity.License
import com.mikepenz.aboutlibraries.entity.Scm
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toImmutableSet
import org.schabi.newpipe.BuildConfig
import org.schabi.newpipe.R
val SPDX_ID_TO_ASSET_PATH = mapOf(
"Apache-2.0" to "apache2.html",
"EPL-1.0" to "epl1.html",
"GPL-3.0-only" to "gpl_3.html",
"GPL-3.0-or-later" to "gpl_3.html",
"MIT" to "mit.html",
"MPL-2.0" to "mpl2.html",
)
fun getFirstPartyLibraries(
context: Context,
teamNewPipeLibraries: List<Library>,
): List<Library> {
val gpl3 = setOf(
License(
name = "GNU General Public License v3.0",
url = "https://www.gnu.org/licenses/gpl-3.0.txt",
year = null,
spdxId = "GPL-3.0-or-later",
licenseContent = null,
hash = "GPL-3.0-or-later",
)
).toImmutableSet()
val npeId = "com.github.TeamNewPipe:NewPipeExtractor"
val npe = teamNewPipeLibraries.firstOrNull { it.uniqueId == npeId }
return listOf(
Library(
uniqueId = BuildConfig.APPLICATION_ID,
artifactVersion = BuildConfig.VERSION_NAME,
name = context.getString(R.string.app_name),
description = context.getString(R.string.app_description),
website = context.getString(R.string.website_url),
developers = listOf(
Developer(
name = context.getString(R.string.team_newpipe),
organisationUrl = context.getString(R.string.website_url)
)
).toImmutableList(),
organization = null,
scm = Scm(null, null, context.getString(R.string.github_url)),
licenses = gpl3,
),
Library(
uniqueId = npeId,
artifactVersion = npe?.artifactVersion,
name = context.getString(R.string.newpipe_extractor),
description = context.getString(R.string.newpipe_extractor_description),
website = context.getString(R.string.newpipe_extractor_github_url),
developers = listOf(
Developer(
name = context.getString(R.string.team_newpipe),
organisationUrl = context.getString(R.string.website_url)
)
).toImmutableList(),
organization = null,
scm = Scm(null, null, context.getString(R.string.newpipe_extractor_github_url)),
licenses = gpl3,
),
)
}
fun getAdditionalThirdPartyLibraries(
context: Context,
teamNewPipeLibraries: List<Library>,
licenses: ImmutableSet<License>,
): List<Library> {
val apache2 = licenses.firstOrNull { it.spdxId == "Apache-2.0" }
val mit = licenses.firstOrNull { it.spdxId == "MIT" }
val mpl2 = licenses.firstOrNull { it.spdxId == "MPL-2.0" }
val nanojsonId = "com.github.TeamNewPipe:nanojson"
val nanojson = teamNewPipeLibraries.firstOrNull { it.uniqueId == nanojsonId }
val nnfpId = "com.github.TeamNewPipe:NoNonsense-FilePicker"
val nnfp = teamNewPipeLibraries.firstOrNull { it.uniqueId == nnfpId }
return listOf(
Library(
uniqueId = nnfpId,
artifactVersion = nnfp?.artifactVersion,
name = "NoNonsense-FilePicker",
description = "A file/directory-picker for Android.",
website = "https://github.com/TeamNewPipe/NoNonsense-FilePicker",
developers = listOf(
Developer(
name = "Jonas Kalderstam",
organisationUrl = "https://github.com/spacecowboy/NoNonsense-FilePicker",
),
Developer(
name = context.getString(R.string.team_newpipe),
organisationUrl = context.getString(R.string.website_url)
)
).toImmutableList(),
organization = null,
scm = Scm(null, null, "https://github.com/TeamNewPipe/NoNonsense-FilePicker"),
licenses = listOfNotNull(mpl2).toImmutableSet(),
),
Library(
uniqueId = nanojsonId,
artifactVersion = nanojson?.artifactVersion,
name = "nanojson",
description = "nanojson is a tiny, fast, and compliant JSON parser and writer for Java.",
website = "https://github.com/TeamNewPipe/nanojson",
developers = listOf(
Developer(
name = "mmastrac",
organisationUrl = "https://github.com/mmastrac/nanojson",
),
Developer(
name = context.getString(R.string.team_newpipe),
organisationUrl = context.getString(R.string.website_url)
),
).toImmutableList(),
organization = null,
scm = Scm(null, null, "https://github.com/TeamNewPipe/nanojson"),
licenses = listOfNotNull(mit, apache2).toImmutableSet()
),
)
}

View File

@ -0,0 +1,51 @@
@file:OptIn(ExperimentalMaterial3Api::class)
package org.schabi.newpipe.ui.components.about
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.material3.contentColorFor
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.unit.dp
import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar
import org.schabi.newpipe.ui.components.common.LoadingIndicator
@Composable
fun LicenseDialog(licenseHtml: AnnotatedString, onDismissRequest: () -> Unit) {
val lazyListState = rememberLazyListState()
ModalBottomSheet(onDismissRequest) {
CompositionLocalProvider(
// contentColorFor(MaterialTheme.colorScheme.containerColor), i.e. ModalBottomSheet's
// default background color, does not resolve correctly, so need to manually set the
// content color for MaterialTheme.colorScheme.background instead
LocalContentColor provides contentColorFor(MaterialTheme.colorScheme.background)
) {
LazyColumnThemedScrollbar(state = lazyListState) {
LazyColumn(
state = lazyListState
) {
item {
if (licenseHtml.isEmpty()) {
LoadingIndicator(modifier = Modifier.padding(32.dp))
} else {
Text(
text = licenseHtml,
modifier = Modifier.padding(horizontal = 12.dp),
)
}
}
}
}
}
}
}

View File

@ -0,0 +1,105 @@
package org.schabi.newpipe.ui.components.about
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import org.schabi.newpipe.R
import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar
import org.schabi.newpipe.ui.components.common.LoadingIndicator
@Composable
@NonRestartableComposable
fun LicenseTab(viewModel: LicenseTabViewModel = viewModel()) {
val lazyListState = rememberLazyListState()
val stateFlow = viewModel.state.collectAsState()
val state = stateFlow.value
if (state.licenseDialogHtml != null) {
LicenseDialog(
licenseHtml = state.licenseDialogHtml,
onDismissRequest = { viewModel.closeLicenseDialog() }
)
}
LazyColumnThemedScrollbar(state = lazyListState) {
LazyColumn(
state = lazyListState,
) {
item {
Text(
text = stringResource(R.string.app_license_title),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(
start = 16.dp,
top = 16.dp,
end = 16.dp,
bottom = 8.dp
),
)
}
item {
Text(
text = stringResource(R.string.app_license),
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.padding(
start = 16.dp,
end = 16.dp,
bottom = 8.dp
),
)
}
if (state.firstPartyLibraries == null) {
item {
LoadingIndicator(modifier = Modifier.padding(32.dp))
}
} else {
for (library in state.firstPartyLibraries) {
item {
Library(
library = library,
showLicenseDialog = viewModel::showLicenseDialog,
descriptionMaxLines = Int.MAX_VALUE,
)
}
}
}
item {
Text(
text = stringResource(R.string.title_licenses),
style = MaterialTheme.typography.titleLarge,
modifier = Modifier.padding(
start = 16.dp,
top = 16.dp,
end = 16.dp,
bottom = 8.dp
),
)
}
if (state.thirdPartyLibraries == null) {
item {
LoadingIndicator(modifier = Modifier.padding(32.dp))
}
} else {
for (library in state.thirdPartyLibraries) {
item {
Library(
library = library,
showLicenseDialog = viewModel::showLicenseDialog,
descriptionMaxLines = 2,
)
}
}
}
}
}
}

View File

@ -0,0 +1,82 @@
package org.schabi.newpipe.ui.components.about
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.fromHtml
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.mikepenz.aboutlibraries.Libs
import com.mikepenz.aboutlibraries.entity.Library
import com.mikepenz.aboutlibraries.util.withContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.schabi.newpipe.App
class LicenseTabViewModel : ViewModel() {
private val _state = MutableStateFlow(LicenseTabState(null, null, null))
val state: StateFlow<LicenseTabState> = _state
private var licenseLoadJob: Job? = null
init {
viewModelScope.launch {
withContext(Dispatchers.IO) {
loadLibraries()
}
}
}
private fun loadLibraries() {
val context = App.instance
val libs = Libs.Builder().withContext(context).build()
val (teamNewPipeLibraries, thirdParty) = libs.libraries
.toMutableList()
.partition { it.uniqueId.startsWith("com.github.TeamNewPipe") }
val firstParty = getFirstPartyLibraries(context, teamNewPipeLibraries)
val allThirdParty =
getAdditionalThirdPartyLibraries(context, teamNewPipeLibraries, libs.licenses) +
thirdParty
_state.update {
it.copy(
firstPartyLibraries = firstParty,
thirdPartyLibraries = allThirdParty,
)
}
}
fun showLicenseDialog(filename: String) {
licenseLoadJob?.cancel()
_state.update { it.copy(licenseDialogHtml = AnnotatedString("")) }
licenseLoadJob = viewModelScope.launch {
withContext(Dispatchers.IO) {
val text = App.instance.assets.open(filename).bufferedReader().use { it.readText() }
val parsedHtml = AnnotatedString.fromHtml(text)
_state.update {
if (it.licenseDialogHtml != null && isActive) {
it.copy(licenseDialogHtml = parsedHtml)
} else {
it
}
}
}
}
}
fun closeLicenseDialog() {
licenseLoadJob?.cancel()
_state.update { it.copy(licenseDialogHtml = null) }
}
data class LicenseTabState(
val firstPartyLibraries: List<Library>?,
val thirdPartyLibraries: List<Library>?,
// null if dialog closed, empty if loading, otherwise license HTML content
val licenseDialogHtml: AnnotatedString?,
)
}

View File

@ -40,12 +40,12 @@ import androidx.fragment.app.FragmentActivity
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
import coil.compose.AsyncImage
import coil3.compose.AsyncImage
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.Page
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
import org.schabi.newpipe.extractor.stream.Description
import org.schabi.newpipe.paging.CommentsSource
import org.schabi.newpipe.paging.CommentRepliesSource
import org.schabi.newpipe.ui.components.common.DescriptionText
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.util.Localization
@ -147,7 +147,7 @@ fun Comment(comment: CommentsInfoItem) {
val coroutineScope = rememberCoroutineScope()
val flow = remember {
Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) {
CommentsSource(comment.serviceId, comment.url, comment.replies)
CommentRepliesSource(comment)
}.flow
.cachedIn(coroutineScope)
}

View File

@ -23,7 +23,7 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity
import coil.compose.AsyncImage
import coil3.compose.AsyncImage
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
import org.schabi.newpipe.extractor.stream.Description

View File

@ -1,28 +1,21 @@
package org.schabi.newpipe.ui.components.comment
import android.content.res.Configuration
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.paging.LoadState
import androidx.paging.LoadStates
import androidx.paging.PagingData
@ -30,11 +23,11 @@ import androidx.paging.compose.collectAsLazyPagingItems
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import my.nanihadesuka.compose.LazyColumnScrollbar
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.comments.CommentsInfoItem
import org.schabi.newpipe.extractor.stream.Description
import org.schabi.newpipe.paging.CommentsDisabledException
import org.schabi.newpipe.ui.components.common.LoadingIndicator
import org.schabi.newpipe.ui.emptystate.EmptyStateComposable
import org.schabi.newpipe.ui.emptystate.EmptyStateSpec
import org.schabi.newpipe.ui.theme.AppTheme
@Composable
@ -62,7 +55,7 @@ fun CommentSection(
if (refresh is LoadState.Loading) {
LoadingIndicator(modifier = Modifier.padding(top = 8.dp))
} else {
NoCommentsMessage((refresh as? LoadState.Error)?.error)
EmptyStateComposable(EmptyStateSpec.NoComments)
}
}
} else {
@ -75,25 +68,6 @@ fun CommentSection(
}
}
@Composable
private fun NoCommentsMessage(error: Throwable?) {
val message = if (error is CommentsDisabledException) {
R.string.comments_are_disabled
} else {
R.string.no_comments
}
Column(
modifier = Modifier
.fillMaxWidth()
.wrapContentSize(Alignment.Center),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = "(╯°-°)╯", fontSize = 35.sp)
Text(text = stringResource(id = message), fontSize = 24.sp)
}
}
private class CommentDataProvider : PreviewParameterProvider<PagingData<CommentsInfoItem>> {
private val notLoading = LoadState.NotLoading(true)
@ -107,11 +81,6 @@ private class CommentDataProvider : PreviewParameterProvider<PagingData<Comments
)
}
),
// Comments disabled
PagingData.from(
listOf<CommentsInfoItem>(),
LoadStates(LoadState.Error(CommentsDisabledException()), notLoading, notLoading)
),
// No comments
PagingData.from(
listOf<CommentsInfoItem>(),

View File

@ -6,7 +6,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.ParagraphStyle
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextLinkStyles
@ -23,24 +22,27 @@ fun DescriptionText(
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
onTextLayout: (TextLayoutResult) -> Unit = {},
style: TextStyle = LocalTextStyle.current
style: TextStyle = LocalTextStyle.current,
) {
Text(
modifier = modifier,
text = rememberParsedDescription(description),
maxLines = maxLines,
onTextLayout = onTextLayout,
style = style,
overflow = overflow
)
}
@Composable
fun rememberParsedDescription(description: Description): AnnotatedString {
// TODO: Handle links and hashtags, Markdown.
val parsedDescription = remember(description) {
return remember(description) {
if (description.type == Description.HTML) {
val styles = TextLinkStyles(SpanStyle(textDecoration = TextDecoration.Underline))
AnnotatedString.fromHtml(description.content, styles)
} else {
AnnotatedString(description.content, ParagraphStyle())
AnnotatedString(description.content)
}
}
Text(
modifier = modifier,
text = parsedDescription,
maxLines = maxLines,
style = style,
overflow = overflow,
onTextLayout = onTextLayout
)
}

View File

@ -11,9 +11,7 @@ import androidx.compose.ui.Modifier
@Composable
fun LoadingIndicator(modifier: Modifier = Modifier) {
CircularProgressIndicator(
modifier = modifier
.fillMaxSize()
.wrapContentSize(Alignment.Center),
modifier = modifier.fillMaxSize().wrapContentSize(Alignment.Center),
color = MaterialTheme.colorScheme.primary,
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)

View File

@ -0,0 +1,63 @@
package org.schabi.newpipe.ui.components.common
import android.content.res.Configuration
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.RowScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ScaffoldWithToolbar(
title: String,
onBackClick: () -> Unit,
actions: @Composable RowScope.() -> Unit = {},
content: @Composable (PaddingValues) -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text(text = title) },
// TODO decide whether to use default colors instead
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
scrolledContainerColor = MaterialTheme.colorScheme.primaryContainer,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer,
),
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = null
)
}
},
actions = actions
)
},
content = content
)
}
@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ScaffoldWithToolbarPreview() {
ScaffoldWithToolbar(
title = "Example",
onBackClick = {},
content = {}
)
}

View File

@ -0,0 +1,30 @@
package org.schabi.newpipe.ui.components.common
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import my.nanihadesuka.compose.ScrollbarSettings
@Composable
fun defaultThemedScrollbarSettings(): ScrollbarSettings = ScrollbarSettings.Default.copy(
thumbUnselectedColor = MaterialTheme.colorScheme.primary,
thumbSelectedColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f),
)
@Composable
fun LazyColumnThemedScrollbar(
state: LazyListState,
modifier: Modifier = Modifier,
settings: ScrollbarSettings = defaultThemedScrollbarSettings(),
indicatorContent: (@Composable (index: Int, isThumbSelected: Boolean) -> Unit)? = null,
content: @Composable () -> Unit
) {
my.nanihadesuka.compose.LazyColumnScrollbar(
state = state,
modifier = modifier,
settings = settings,
indicatorContent = indicatorContent,
content = content,
)
}

View File

@ -0,0 +1,148 @@
package org.schabi.newpipe.ui.components.items
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.paging.compose.LazyPagingItems
import androidx.preference.PreferenceManager
import androidx.window.core.layout.WindowWidthSizeClass
import my.nanihadesuka.compose.LazyVerticalGridScrollbar
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.InfoItem
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.info_list.ItemViewMode
import org.schabi.newpipe.ktx.findFragmentActivity
import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar
import org.schabi.newpipe.ui.components.common.defaultThemedScrollbarSettings
import org.schabi.newpipe.ui.components.items.playlist.PlaylistListItem
import org.schabi.newpipe.ui.components.items.stream.StreamCardItem
import org.schabi.newpipe.ui.components.items.stream.StreamGridItem
import org.schabi.newpipe.ui.components.items.stream.StreamListItem
import org.schabi.newpipe.util.DependentPreferenceHelper
import org.schabi.newpipe.util.NavigationHelper
@Composable
fun ItemList(
items: LazyPagingItems<out InfoItem>,
mode: ItemViewMode = determineItemViewMode(),
gridHeader: LazyGridScope.() -> Unit = {},
listHeader: LazyListScope.() -> Unit = {}
) {
val context = LocalContext.current
val onClick = remember {
{ item: InfoItem ->
val fragmentManager = context.findFragmentActivity().supportFragmentManager
if (item is StreamInfoItem) {
NavigationHelper.openVideoDetailFragment(
context, fragmentManager, item.serviceId, item.url, item.name, null, false
)
} else if (item is PlaylistInfoItem) {
NavigationHelper.openPlaylistFragment(fragmentManager, item.serviceId, item.url)
}
}
}
// Handle long clicks for stream items
// TODO: Adjust the menu display depending on where it was triggered
var selectedStream by remember { mutableStateOf<StreamInfoItem?>(null) }
val onLongClick = remember {
{ stream: StreamInfoItem ->
selectedStream = stream
}
}
val onDismissPopup = remember {
{
selectedStream = null
}
}
val showProgress = DependentPreferenceHelper.getPositionsInListsEnabled(context)
val nestedScrollModifier = Modifier.nestedScroll(rememberNestedScrollInteropConnection())
if (mode == ItemViewMode.GRID) {
val gridState = rememberLazyGridState()
LazyVerticalGridScrollbar(state = gridState, settings = defaultThemedScrollbarSettings()) {
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
val isCompact = windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.COMPACT
val minSize = if (isCompact) 150.dp else 250.dp
LazyVerticalGrid(state = gridState, columns = GridCells.Adaptive(minSize)) {
gridHeader()
items(items.itemCount) {
val item = items[it]!!
// TODO: Handle channel and playlist items.
if (item is StreamInfoItem) {
val isSelected = selectedStream == item
StreamGridItem(item, showProgress, isSelected, isCompact, onClick, onLongClick, onDismissPopup)
}
}
}
}
} else {
val state = rememberLazyListState()
LazyColumnThemedScrollbar(state = state) {
LazyColumn(modifier = nestedScrollModifier, state = state) {
listHeader()
items(items.itemCount) {
val item = items[it]!!
// TODO: Handle channel items.
if (item is StreamInfoItem) {
val isSelected = selectedStream == item
if (mode == ItemViewMode.CARD) {
StreamCardItem(item, showProgress, isSelected, onClick, onLongClick, onDismissPopup)
} else {
StreamListItem(item, showProgress, isSelected, onClick, onLongClick, onDismissPopup)
}
} else if (item is PlaylistInfoItem) {
PlaylistListItem(item, onClick)
}
}
}
}
}
}
@Composable
private fun determineItemViewMode(): ItemViewMode {
val prefValue = PreferenceManager.getDefaultSharedPreferences(LocalContext.current)
.getString(stringResource(R.string.list_view_mode_key), null)
val viewMode = prefValue?.let { ItemViewMode.valueOf(it.uppercase()) } ?: ItemViewMode.AUTO
return when (viewMode) {
ItemViewMode.AUTO -> {
// Evaluate whether to use Grid based on screen real estate.
val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
if (windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED) {
ItemViewMode.GRID
} else {
ItemViewMode.LIST
}
}
else -> viewMode
}
}

View File

@ -0,0 +1,71 @@
package org.schabi.newpipe.ui.components.items.playlist
import android.content.res.Configuration
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import org.schabi.newpipe.extractor.InfoItem
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.util.NO_SERVICE_ID
@Composable
fun PlaylistListItem(
playlist: PlaylistInfoItem,
onClick: (InfoItem) -> Unit = {},
) {
Row(
modifier = Modifier
.clickable { onClick(playlist) }
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
PlaylistThumbnail(
playlist = playlist,
modifier = Modifier.size(width = 140.dp, height = 78.dp)
)
Column {
Text(
text = playlist.name,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
maxLines = 2
)
Text(
text = playlist.uploaderName.orEmpty(),
style = MaterialTheme.typography.bodySmall
)
}
}
}
@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun PlaylistListItemPreview() {
val playlist = PlaylistInfoItem(NO_SERVICE_ID, "", "Playlist")
playlist.uploaderName = "Uploader"
AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
PlaylistListItem(playlist)
}
}
}

View File

@ -0,0 +1,66 @@
package org.schabi.newpipe.ui.components.items.playlist
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.playlist.PlaylistInfoItem
import org.schabi.newpipe.util.Localization
import org.schabi.newpipe.util.image.ImageStrategy
@Composable
fun PlaylistThumbnail(
playlist: PlaylistInfoItem,
modifier: Modifier = Modifier,
contentScale: ContentScale = ContentScale.Fit
) {
Box(contentAlignment = Alignment.BottomEnd) {
AsyncImage(
model = ImageStrategy.choosePreferredImage(playlist.thumbnails),
contentDescription = null,
placeholder = painterResource(R.drawable.placeholder_thumbnail_playlist),
error = painterResource(R.drawable.placeholder_thumbnail_playlist),
contentScale = contentScale,
modifier = modifier
)
Row(
modifier = Modifier
.padding(2.dp)
.background(Color.Black.copy(alpha = 0.5f))
.padding(2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.AutoMirrored.Default.PlaylistPlay,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(18.dp)
)
val context = LocalContext.current
Text(
text = Localization.localizeStreamCountMini(context, playlist.streamCount),
color = Color.White,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(start = 4.dp)
)
}
}
}

View File

@ -1,4 +1,4 @@
package org.schabi.newpipe.ui.components.stream
package org.schabi.newpipe.ui.components.items.stream
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
@ -26,7 +26,8 @@ import org.schabi.newpipe.ui.theme.AppTheme
@Composable
fun StreamCardItem(
stream: StreamInfoItem,
isSelected: Boolean = false,
showProgress: Boolean,
isSelected: Boolean,
onClick: (StreamInfoItem) -> Unit = {},
onLongClick: (StreamInfoItem) -> Unit = {},
onDismissPopup: () -> Unit = {}
@ -42,6 +43,7 @@ fun StreamCardItem(
) {
StreamThumbnail(
stream = stream,
showProgress = showProgress,
modifier = Modifier.fillMaxWidth(),
contentScale = ContentScale.FillWidth
)
@ -68,9 +70,7 @@ fun StreamCardItem(
}
}
if (isSelected) {
StreamMenu(stream, onDismissPopup)
}
StreamMenu(stream, isSelected, onDismissPopup)
}
}
@ -82,7 +82,7 @@ private fun StreamCardItemPreview(
) {
AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
StreamCardItem(stream)
StreamCardItem(stream, showProgress = false, isSelected = false)
}
}
}

View File

@ -1,4 +1,4 @@
package org.schabi.newpipe.ui.components.stream
package org.schabi.newpipe.ui.components.items.stream
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
@ -24,6 +24,7 @@ import org.schabi.newpipe.ui.theme.AppTheme
@Composable
fun StreamGridItem(
stream: StreamInfoItem,
showProgress: Boolean,
isSelected: Boolean = false,
isMini: Boolean = false,
onClick: (StreamInfoItem) -> Unit = {},
@ -41,7 +42,11 @@ fun StreamGridItem(
) {
val size = if (isMini) DpSize(150.dp, 85.dp) else DpSize(246.dp, 138.dp)
StreamThumbnail(stream = stream, modifier = Modifier.size(size))
StreamThumbnail(
stream = stream,
showProgress = showProgress,
modifier = Modifier.size(size)
)
Text(
text = stream.name,
@ -58,9 +63,7 @@ fun StreamGridItem(
)
}
if (isSelected) {
StreamMenu(stream, onDismissPopup)
}
StreamMenu(stream, isSelected, onDismissPopup)
}
}
@ -72,7 +75,7 @@ private fun StreamGridItemPreview(
) {
AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
StreamGridItem(stream)
StreamGridItem(stream, showProgress = false)
}
}
}
@ -85,7 +88,7 @@ private fun StreamMiniGridItemPreview(
) {
AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
StreamGridItem(stream, isMini = true)
StreamGridItem(stream, showProgress = false, isMini = true)
}
}
}

View File

@ -1,4 +1,4 @@
package org.schabi.newpipe.ui.components.stream
package org.schabi.newpipe.ui.components.items.stream
import android.content.res.Configuration
import androidx.compose.foundation.ExperimentalFoundationApi
@ -27,26 +27,27 @@ import org.schabi.newpipe.ui.theme.AppTheme
@Composable
fun StreamListItem(
stream: StreamInfoItem,
isSelected: Boolean = false,
showProgress: Boolean,
isSelected: Boolean,
onClick: (StreamInfoItem) -> Unit = {},
onLongClick: (StreamInfoItem) -> Unit = {},
onDismissPopup: () -> Unit = {}
) {
Box {
// Box serves as an anchor for the dropdown menu
Box(
modifier = Modifier
.combinedClickable(onLongClick = { onLongClick(stream) }, onClick = { onClick(stream) })
.fillMaxWidth()
.padding(12.dp)
) {
Row(
modifier = Modifier
.combinedClickable(
onLongClick = { onLongClick(stream) },
onClick = { onClick(stream) }
)
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
StreamThumbnail(
stream = stream,
modifier = Modifier.size(width = 98.dp, height = 55.dp)
showProgress = showProgress,
modifier = Modifier.size(width = 140.dp, height = 78.dp)
)
Column {
@ -54,7 +55,7 @@ fun StreamListItem(
text = stream.name,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.titleSmall,
maxLines = 1
maxLines = 2
)
Text(text = stream.uploaderName.orEmpty(), style = MaterialTheme.typography.bodySmall)
@ -66,9 +67,7 @@ fun StreamListItem(
}
}
if (isSelected) {
StreamMenu(stream, onDismissPopup)
}
StreamMenu(stream, isSelected, onDismissPopup)
}
}
@ -80,7 +79,7 @@ private fun StreamListItemPreview(
) {
AppTheme {
Surface(color = MaterialTheme.colorScheme.background) {
StreamListItem(stream)
StreamListItem(stream, showProgress = false, isSelected = false)
}
}
}

View File

@ -0,0 +1,153 @@
package org.schabi.newpipe.ui.components.items.stream
import androidx.annotation.StringRes
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.viewmodel.compose.viewModel
import org.schabi.newpipe.R
import org.schabi.newpipe.database.stream.model.StreamEntity
import org.schabi.newpipe.download.DownloadDialog
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.ktx.findFragmentActivity
import org.schabi.newpipe.local.dialog.PlaylistAppendDialog
import org.schabi.newpipe.local.dialog.PlaylistDialog
import org.schabi.newpipe.player.helper.PlayerHolder
import org.schabi.newpipe.util.NavigationHelper
import org.schabi.newpipe.util.SparseItemUtil
import org.schabi.newpipe.util.external_communication.ShareUtils
import org.schabi.newpipe.viewmodels.StreamViewModel
@Composable
fun StreamMenu(
stream: StreamInfoItem,
expanded: Boolean,
onDismissRequest: () -> Unit
) {
val context = LocalContext.current
val streamViewModel = viewModel<StreamViewModel>()
val playerHolder = PlayerHolder.getInstance()
DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) {
if (playerHolder.isPlayQueueReady) {
StreamMenuItem(
text = R.string.enqueue_stream,
onClick = {
onDismissRequest()
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
NavigationHelper.enqueueOnPlayer(context, it)
}
}
)
if (playerHolder.queuePosition < playerHolder.queueSize - 1) {
StreamMenuItem(
text = R.string.enqueue_next_stream,
onClick = {
onDismissRequest()
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
NavigationHelper.enqueueNextOnPlayer(context, it)
}
}
)
}
}
StreamMenuItem(
text = R.string.start_here_on_background,
onClick = {
onDismissRequest()
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
NavigationHelper.playOnBackgroundPlayer(context, it, true)
}
}
)
StreamMenuItem(
text = R.string.start_here_on_popup,
onClick = {
onDismissRequest()
SparseItemUtil.fetchItemInfoIfSparse(context, stream) {
NavigationHelper.playOnPopupPlayer(context, it, true)
}
}
)
StreamMenuItem(
text = R.string.download,
onClick = {
onDismissRequest()
SparseItemUtil.fetchStreamInfoAndSaveToDatabase(
context, stream.serviceId, stream.url
) { info ->
// TODO: Use an AlertDialog composable instead.
val downloadDialog = DownloadDialog(context, info)
val fragmentManager = context.findFragmentActivity().supportFragmentManager
downloadDialog.show(fragmentManager, "downloadDialog")
}
}
)
StreamMenuItem(
text = R.string.add_to_playlist,
onClick = {
onDismissRequest()
val list = listOf(StreamEntity(stream))
PlaylistDialog.createCorrespondingDialog(context, list) { dialog ->
val tag = if (dialog is PlaylistAppendDialog) "append" else "create"
dialog.show(
context.findFragmentActivity().supportFragmentManager,
"StreamDialogEntry@${tag}_playlist"
)
}
}
)
StreamMenuItem(
text = R.string.share,
onClick = {
onDismissRequest()
ShareUtils.shareText(context, stream.name, stream.url, stream.thumbnails)
}
)
StreamMenuItem(
text = R.string.open_in_browser,
onClick = {
onDismissRequest()
ShareUtils.openUrlInBrowser(context, stream.url)
}
)
StreamMenuItem(
text = R.string.mark_as_watched,
onClick = {
onDismissRequest()
streamViewModel.markAsWatched(stream)
}
)
StreamMenuItem(
text = R.string.show_channel_details,
onClick = {
onDismissRequest()
SparseItemUtil.fetchUploaderUrlIfSparse(
context, stream.serviceId, stream.url, stream.uploaderUrl
) { url ->
val activity = context.findFragmentActivity()
NavigationHelper.openChannelFragment(activity, stream, url)
}
}
)
}
}
@Composable
private fun StreamMenuItem(
@StringRes text: Int,
onClick: () -> Unit
) {
DropdownMenuItem(
text = {
Text(text = stringResource(text), color = MaterialTheme.colorScheme.onBackground)
},
onClick = onClick
)
}

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