diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 70c81c7b1..647cfbabb 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -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).
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
index 4721637bf..49ab78c7d 100644
--- a/.github/ISSUE_TEMPLATE/config.yml
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -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
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9ae3a77c2..54415858e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -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'
diff --git a/.gitignore b/.gitignore
index 1352b6917..7bccc3132 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@ captures/
*.class
app/debug/
app/release/
+.kotlin/
# vscode / eclipse files
*.classpath
diff --git a/.idea/icon.svg b/.idea/icon.svg
new file mode 100644
index 000000000..51fdf95de
--- /dev/null
+++ b/.idea/icon.svg
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/build.gradle b/app/build.gradle
index 3804a7217..77cbdc5a4 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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() {
diff --git a/app/check-dependencies.gradle b/app/check-dependencies.gradle
new file mode 100644
index 000000000..7646bc584
--- /dev/null
+++ b/app/check-dependencies.gradle
@@ -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"))
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro
index d21f33e1f..215df0da5 100644
--- a/app/proguard-rules.pro
+++ b/app/proguard-rules.pro
@@ -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.* ;
-}
--keepnames class * { @icepick.State *;}
-
## Rules for OkHttp. Copy pasted from https://github.com/square/okhttp
-dontwarn okhttp3.**
-dontwarn okio.**
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index d11de9f47..c44f8bf2c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -77,6 +77,11 @@
android:exported="false"
android:label="@string/settings" />
+
+
- * 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 .
- */
-
-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() {
- @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 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 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;
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/App.kt b/app/src/main/java/org/schabi/newpipe/App.kt
new file mode 100644
index 000000000..8501cee09
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/App.kt
@@ -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
+ * 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 .
+ */
+@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()!!.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 {
+ 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
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/AppModule.kt b/app/src/main/java/org/schabi/newpipe/AppModule.kt
new file mode 100644
index 000000000..0aaf2f72b
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/AppModule.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/BaseFragment.java b/app/src/main/java/org/schabi/newpipe/BaseFragment.java
index 7a06771dd..a55a341e6 100644
--- a/app/src/main/java/org/schabi/newpipe/BaseFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/BaseFragment.java
@@ -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) {
diff --git a/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java b/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java
index 9ddbe96df..ee5450a62 100644
--- a/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java
+++ b/app/src/main/java/org/schabi/newpipe/DownloaderImpl.java
@@ -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.
*
diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java
index d05acb057..c5f0d1889 100644
--- a/app/src/main/java/org/schabi/newpipe/MainActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java
@@ -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();
diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java
index c59dc7532..197c965ba 100644
--- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java
@@ -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
diff --git a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt
index 0d0d0d48d..b437c6acb 100644
--- a/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt
+++ b/app/src/main/java/org/schabi/newpipe/about/AboutActivity.kt
@@ -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
- ),
- )
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/about/License.kt b/app/src/main/java/org/schabi/newpipe/about/License.kt
deleted file mode 100644
index 117ff9bf5..000000000
--- a/app/src/main/java/org/schabi/newpipe/about/License.kt
+++ /dev/null
@@ -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
diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt b/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt
deleted file mode 100644
index 9f5ad2a7a..000000000
--- a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt
+++ /dev/null
@@ -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
- private var activeSoftwareComponent: SoftwareComponent? = null
- private val compositeDisposable = CompositeDisposable()
-
- override fun onCreate(savedInstanceState: Bundle?) {
- super.onCreate(savedInstanceState)
- softwareComponents = arguments?.parcelableArrayList(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): LicenseFragment {
- val fragment = LicenseFragment()
- fragment.arguments = bundleOf(ARG_COMPONENTS to softwareComponents)
- return fragment
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt b/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt
deleted file mode 100644
index 56e21c88a..000000000
--- a/app/src/main/java/org/schabi/newpipe/about/LicenseFragmentHelper.kt
+++ /dev/null
@@ -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("", "")
- } 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)
-}
diff --git a/app/src/main/java/org/schabi/newpipe/about/SoftwareComponent.kt b/app/src/main/java/org/schabi/newpipe/about/SoftwareComponent.kt
deleted file mode 100644
index 262641caa..000000000
--- a/app/src/main/java/org/schabi/newpipe/about/SoftwareComponent.kt
+++ /dev/null
@@ -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
diff --git a/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.kt b/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.kt
deleted file mode 100644
index c5b9618fe..000000000
--- a/app/src/main/java/org/schabi/newpipe/about/StandardLicenses.kt
+++ /dev/null
@@ -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")
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt
index d8c19c1e9..0015c8e0a 100644
--- a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt
+++ b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamDAO.kt
@@ -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 {
abstract override fun listByService(serviceId: Int): Flowable>
@Query("SELECT * FROM streams WHERE url = :url AND service_id = :serviceId")
- abstract fun getStream(serviceId: Long, url: String): Flowable>
+ abstract fun getStream(serviceId: Long, url: String): Maybe
@Query("UPDATE streams SET uploader_url = :uploaderUrl WHERE url = :url AND service_id = :serviceId")
abstract fun setUploaderUrl(serviceId: Long, url: String, uploaderUrl: String): Completable
diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java
index 06371248d..6f1ecf173 100644
--- a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java
+++ b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java
@@ -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 {
@@ -32,7 +33,7 @@ public interface StreamStateDAO extends BasicDAO {
}
@Query("SELECT * FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
- Flowable> getState(long streamId);
+ Maybe getState(long streamId);
@Query("DELETE FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
int deleteState(long streamId);
diff --git a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
index db2066b27..34a4ba022 100644
--- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
@@ -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);
}
diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java
index 831a8cc4b..2f607b487 100644
--- a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java
@@ -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 = "";
diff --git a/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java b/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java
index 960f98cef..51a0ff1e6 100644
--- a/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/error/ReCaptchaActivity.java
@@ -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);
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java
index a3d3d8b60..8361953b9 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/BaseStateFragment.java
@@ -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 extends BaseFragment implements ViewContract {
@State
protected AtomicBoolean wasLoading = new AtomicBoolean();
@@ -134,6 +134,7 @@ public abstract class BaseStateFragment extends BaseFragment implements ViewC
hideErrorPanel();
}
+ @Override
public void showEmptyState() {
isLoading.set(false);
if (emptyStateView != null) {
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java
index d4e73bcac..8c939a3e8 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/EmptyFragment.java
@@ -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;
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java
index 581e54156..52fb3f29e 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/DescriptionFragment.java
@@ -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
}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
index 8fcf1e663..63077e92d 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/detail/VideoDetailFragment.java
@@ -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
//////////////////////////////////////////////////////////////////////////*/
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java
index dd5eb6c8a..61a361f23 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/list/BaseListInfoFragment.java
@@ -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 {
+ .subscribe((@NonNull final L result) -> {
isLoading.set(false);
currentInfo = result;
currentNextPage = result.getNextPage();
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java
index 0dc2fb65a..b7f4a9d3d 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelAboutFragment.java
@@ -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
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java
index 3890e4865..2d5873e3f 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelFragment.java
@@ -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
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
//////////////////////////////////////////////////////////////////////////*/
private void monitorSubscription(final ChannelInfo info) {
- final Consumer onError = (Throwable throwable) -> {
+ final Consumer 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
}
private Function mapOnSubscribe(final SubscriptionEntity subscription) {
- return (@NonNull Object o) -> {
+ return (@NonNull final Object o) -> {
subscriptionManager.insertSubscription(subscription);
return o;
};
}
private Function 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
}
private Disposable monitorSubscribeButton(final Function action) {
- final Consumer onNext = (@NonNull Object o) -> {
+ final Consumer 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
}
private Consumer> getSubscribeUpdateMonitor(final ChannelInfo info) {
- return (List subscriptionEntities) -> {
+ return (final List subscriptionEntities) -> {
if (DEBUG) {
Log.d(TAG, "subscriptionManager.subscriptionTable.doOnNext() called with: "
+ "subscriptionEntities = [" + subscriptionEntities + "]");
@@ -645,8 +651,6 @@ public class ChannelFragment extends BaseStateFragment
return;
}
- binding.errorContentNotSupported.setVisibility(View.VISIBLE);
- binding.channelKaomoji.setText("(︶︹︺)");
- binding.channelKaomoji.setTextSize(TypedValue.COMPLEX_UNIT_SP, 45f);
+ binding.emptyStateView.setVisibility(View.VISIBLE);
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java
index 95ac42eed..feb23b6ac 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/list/channel/ChannelTabFragment.java
@@ -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
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 streamItems = infoListAdapter.getItemsList().stream()
.filter(StreamInfoItem.class::isInstance)
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.kt
index 10eea4e78..6e20e1425 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.kt
+++ b/app/src/main/java/org/schabi/newpipe/fragments/list/comments/CommentsFragment.kt
@@ -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()
- AppTheme {
- CommentSection(commentsFlow = viewModel.comments)
+ ) = content {
+ AppTheme {
+ Surface(color = MaterialTheme.colorScheme.background) {
+ CommentSection()
}
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java
index b90dccb17..6823e13d3 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/list/kiosk/KioskFragment.java
@@ -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;
/**
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.kt
index f85460610..68c569a4b 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.kt
+++ b/app/src/main/java/org/schabi/newpipe/fragments/list/playlist/PlaylistFragment.kt
@@ -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()
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java
index eef3455ae..06293ccee 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SearchFragment.java
@@ -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 {
+ 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 {
+ (final TextView v, final int actionId, final KeyEvent event) -> {
if (DEBUG) {
Log.d(TAG, "onEditorAction() called with: v = [" + v + "], "
+ "actionId = [" + actionId + "], event = [" + event + "]");
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java
deleted file mode 100644
index e46937ede..000000000
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.java
+++ /dev/null
@@ -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
- 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 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> loadMoreItemsLogic() {
- return Single.fromCallable(ListExtractor.InfoItemsPage::emptyPage);
- }
-
- /*//////////////////////////////////////////////////////////////////////////
- // Contract
- //////////////////////////////////////////////////////////////////////////*/
-
- @Override
- protected Single 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;
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.kt b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.kt
new file mode 100644
index 000000000..88ebe28f0
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsFragment.kt
@@ -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(KEY_INFO)!!)
+ }
+ }
+ }
+
+ companion object {
+ @JvmStatic
+ fun getInstance(info: StreamInfo) = RelatedItemsFragment().apply {
+ arguments = bundleOf(KEY_INFO to info)
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsInfo.java b/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsInfo.java
deleted file mode 100644
index bbc7e1ed0..000000000
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/videos/RelatedItemsInfo.java
+++ /dev/null
@@ -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 {
- /**
- * 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()));
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.java b/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.java
index 5d5650b92..c203ef781 100644
--- a/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.java
+++ b/app/src/main/java/org/schabi/newpipe/info_list/ItemViewMode.java
@@ -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.
*/
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java
index 0c69557bf..dcf01e190 100644
--- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/InfoItemDialog.java
@@ -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",
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java
index 948a8274c..7ed5a2037 100644
--- a/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java
+++ b/app/src/main/java/org/schabi/newpipe/info_list/dialog/StreamDialogDefaultEntry.java
@@ -41,10 +41,11 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
*
*/
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.
diff --git a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java
index 32fa8bf60..642738630 100644
--- a/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/info_list/holder/StreamMiniInfoItemHolder.java
@@ -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())) {
diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt
index e01cf620e..c65b286cf 100644
--- a/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt
+++ b/app/src/main/java/org/schabi/newpipe/ktx/Bundle.kt
@@ -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 Bundle.parcelableArrayList(key: String?): ArrayList? {
- return BundleCompat.getParcelableArrayList(this, key, T::class.java)
-}
inline fun Bundle.serializable(key: String?): T? {
- return getSerializable(this, key, T::class.java)
-}
-
-fun getSerializable(bundle: Bundle, key: String?, clazz: Class): 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)
}
diff --git a/app/src/main/java/org/schabi/newpipe/ktx/Context.kt b/app/src/main/java/org/schabi/newpipe/ktx/Context.kt
new file mode 100644
index 000000000..f2f4e9613
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ktx/Context.kt
@@ -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")
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java b/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java
index a8fe19dd4..3a65f8b07 100644
--- a/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/local/bookmark/BookmarkFragment.java
@@ -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() {
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)
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt
index 728570b17..462e8ef21 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedViewModel.kt
@@ -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),
diff --git a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java
index ed3cf548f..f2fdf9eba 100644
--- a/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java
+++ b/app/src/main/java/org/schabi/newpipe/local/history/HistoryRecordManager.java
@@ -18,10 +18,13 @@ package org.schabi.newpipe.local.history;
* along with NewPipe. If not, see .
*/
+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 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 onViewed(final StreamInfo info) {
@@ -221,7 +215,7 @@ public class HistoryRecordManager {
public Flowable> 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 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 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 loadStreamState(final InfoItem info) {
- return Single.fromCallable(() -> {
- final List entities = streamTable
- .getStream(info.getServiceId(), info.getUrl()).blockingFirst();
- if (entities.isEmpty()) {
- return new StreamStateEntity[]{null};
- }
- final List 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 loadStreamState(final InfoItem info) {
+ return streamTable.getStream(info.getServiceId(), info.getUrl())
+ .flatMap(entity -> streamStateTable.getState(entity.getUid()))
+ .subscribeOn(Schedulers.io());
}
public Single> loadLocalStreamStateBatch(
@@ -295,13 +273,7 @@ public class HistoryRecordManager {
result.add(null);
continue;
}
- final List 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());
diff --git a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
index 1fea7e155..fac358075 100644
--- a/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/local/history/StatisticsPlaylistFragment.java
@@ -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);
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
index d5ae431fa..c87d9cccc 100644
--- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistFragment.java
@@ -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() {
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) {
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java
index 56972b60d..77a70afa9 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionsImportFragment.java
@@ -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;
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt
index 954b872a6..0d71beefd 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupDialog.kt
@@ -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?) {
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt
index 1f3ab71eb..c087da464 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/dialog/FeedGroupReorderDialog.kt
@@ -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) {
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/item/ImportSubscriptionsHintPlaceholderItem.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/item/ImportSubscriptionsHintPlaceholderItem.kt
index 93b551895..cf0b8c3ff 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/item/ImportSubscriptionsHintPlaceholderItem.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/item/ImportSubscriptionsHintPlaceholderItem.kt
@@ -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() {
+class ImportSubscriptionsHintPlaceholderItem : BindableItem() {
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)
}
diff --git a/app/src/main/java/org/schabi/newpipe/paging/CommentRepliesSource.kt b/app/src/main/java/org/schabi/newpipe/paging/CommentRepliesSource.kt
new file mode 100644
index 000000000..efa189db9
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/paging/CommentRepliesSource.kt
@@ -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() {
+ private val service = NewPipe.getService(commentInfo.serviceId)
+
+ override suspend fun load(params: LoadParams): LoadResult {
+ // 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) = null
+}
diff --git a/app/src/main/java/org/schabi/newpipe/paging/CommentsSource.kt b/app/src/main/java/org/schabi/newpipe/paging/CommentsSource.kt
index aec24a344..669485e66 100644
--- a/app/src/main/java/org/schabi/newpipe/paging/CommentsSource.kt
+++ b/app/src/main/java/org/schabi/newpipe/paging/CommentsSource.kt
@@ -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() {
- init {
- require(serviceId != NO_SERVICE_ID) { "serviceId is NO_SERVICE_ID" }
- }
- private val service = NewPipe.getService(serviceId)
+class CommentsSource(private val commentInfo: CommentInfo) : PagingSource() {
+ private val service = NewPipe.getService(commentInfo.serviceId)
override suspend fun load(params: LoadParams): LoadResult {
- // 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) = null
}
-
-class CommentsDisabledException : RuntimeException()
diff --git a/app/src/main/java/org/schabi/newpipe/player/Player.java b/app/src/main/java/org/schabi/newpipe/player/Player.java
index 74d35cf31..ab5274996 100644
--- a/app/src/main/java/org/schabi/newpipe/player/Player.java
+++ b/app/src/main/java/org/schabi/newpipe/player/Player.java
@@ -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
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java
index dfb49a25b..7e74c3848 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlaybackParameterDialog.java
@@ -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();
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
index b55a6547a..24939c1d8 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHolder.java
@@ -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,
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java
index 737ebc5dd..c673e688c 100644
--- a/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java
+++ b/app/src/main/java/org/schabi/newpipe/player/mediasession/MediaSessionPlayerUi.java
@@ -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 newNotificationActions = IntStream.of(3, 4)
diff --git a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java
index d09664aeb..863e2fb8a 100644
--- a/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/player/seekbarpreview/SeekbarPreviewThumbnailHolder.java
@@ -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 "
diff --git a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
index c47abb930..fca8c7162 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/ContentSettingsFragment.java
@@ -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(),
diff --git a/app/src/main/java/org/schabi/newpipe/settings/DebugScreen.kt b/app/src/main/java/org/schabi/newpipe/settings/DebugScreen.kt
new file mode 100644
index 000000000..ac08dd36b
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/DebugScreen.kt
@@ -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
+ )
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
index 421440ea7..9fe5240cc 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
@@ -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);
}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java
index c566313e3..cbd6b0656 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java
@@ -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 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);
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java
index c340dca22..6227d95a9 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java
@@ -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);
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java b/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java
index d731f2f5e..a77e1c514 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SettingMigrations.java
@@ -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:
* - 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) {
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java
index 529e53442..0d57ce174 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsActivity.java
@@ -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
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsScreen.kt b/app/src/main/java/org/schabi/newpipe/settings/SettingsScreen.kt
new file mode 100644
index 000000000..5bd8f2b08
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsScreen.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SettingsV2Activity.kt b/app/src/main/java/org/schabi/newpipe/settings/SettingsV2Activity.kt
new file mode 100644
index 000000000..821ff0187
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/SettingsV2Activity.kt
@@ -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)
+}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java
index 9d169d660..f667bb900 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchFragment.java
@@ -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);
diff --git a/app/src/main/java/org/schabi/newpipe/settings/viewmodel/SettingsViewModel.kt b/app/src/main/java/org/schabi/newpipe/settings/viewmodel/SettingsViewModel.kt
new file mode 100644
index 000000000..ae3520c94
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/settings/viewmodel/SettingsViewModel.kt
@@ -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 =
+ MutableStateFlow(_settingsLayoutRedesignPref)
+ val settingsLayoutRedesign = _settingsLayoutRedesign.asStateFlow()
+
+ fun toggleSettingsLayoutRedesign(newState: Boolean) {
+ _settingsLayoutRedesign.value = newState
+ _settingsLayoutRedesignPref = newState
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/SwitchPreference.kt b/app/src/main/java/org/schabi/newpipe/ui/SwitchPreference.kt
new file mode 100644
index 000000000..d479343f5
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/SwitchPreference.kt
@@ -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)
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/TextPreference.kt b/app/src/main/java/org/schabi/newpipe/ui/TextPreference.kt
new file mode 100644
index 000000000..f58f2f305
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/TextPreference.kt
@@ -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,
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/about/AboutTab.kt b/app/src/main/java/org/schabi/newpipe/ui/components/about/AboutTab.kt
new file mode 100644
index 000000000..3bba5dba9
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/about/AboutTab.kt
@@ -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(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))
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/about/Library.kt b/app/src/main/java/org/schabi/newpipe/ui/components/about/Library.kt
new file mode 100644
index 000000000..97a2be949
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/about/Library.kt
@@ -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(
+ 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().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()
+ )
+ )
+)
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/about/LibraryDefinitions.kt b/app/src/main/java/org/schabi/newpipe/ui/components/about/LibraryDefinitions.kt
new file mode 100644
index 000000000..6ab103c99
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/about/LibraryDefinitions.kt
@@ -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,
+): List {
+ 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,
+ licenses: ImmutableSet,
+): List {
+ 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()
+ ),
+ )
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/about/LicenseDialog.kt b/app/src/main/java/org/schabi/newpipe/ui/components/about/LicenseDialog.kt
new file mode 100644
index 000000000..24421a93a
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/about/LicenseDialog.kt
@@ -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),
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/about/LicenseTab.kt b/app/src/main/java/org/schabi/newpipe/ui/components/about/LicenseTab.kt
new file mode 100644
index 000000000..46e71ba56
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/about/LicenseTab.kt
@@ -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,
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/about/LicenseTabViewModel.kt b/app/src/main/java/org/schabi/newpipe/ui/components/about/LicenseTabViewModel.kt
new file mode 100644
index 000000000..eeb87816c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/about/LicenseTabViewModel.kt
@@ -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 = _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?,
+ val thirdPartyLibraries: List?,
+ // null if dialog closed, empty if loading, otherwise license HTML content
+ val licenseDialogHtml: AnnotatedString?,
+ )
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/comment/Comment.kt b/app/src/main/java/org/schabi/newpipe/ui/components/comment/Comment.kt
index 8ecc87fdd..d0ec91f94 100644
--- a/app/src/main/java/org/schabi/newpipe/ui/components/comment/Comment.kt
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/comment/Comment.kt
@@ -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)
}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/comment/CommentRepliesHeader.kt b/app/src/main/java/org/schabi/newpipe/ui/components/comment/CommentRepliesHeader.kt
index a8e33a49d..f64e3e7f8 100644
--- a/app/src/main/java/org/schabi/newpipe/ui/components/comment/CommentRepliesHeader.kt
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/comment/CommentRepliesHeader.kt
@@ -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
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/comment/CommentSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/comment/CommentSection.kt
index 3f2a5a1ac..bef69a7f7 100644
--- a/app/src/main/java/org/schabi/newpipe/ui/components/comment/CommentSection.kt
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/comment/CommentSection.kt
@@ -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> {
private val notLoading = LoadState.NotLoading(true)
@@ -107,11 +81,6 @@ private class CommentDataProvider : PreviewParameterProvider(),
- LoadStates(LoadState.Error(CommentsDisabledException()), notLoading, notLoading)
- ),
// No comments
PagingData.from(
listOf(),
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt
index 3127794f5..1b7347e93 100644
--- a/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/DescriptionText.kt
@@ -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
- )
}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/LoadingIndicator.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/LoadingIndicator.kt
index 4a6a88190..3bfe1dee4 100644
--- a/app/src/main/java/org/schabi/newpipe/ui/components/common/LoadingIndicator.kt
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/LoadingIndicator.kt
@@ -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,
)
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/ScaffoldWithToolbar.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/ScaffoldWithToolbar.kt
new file mode 100644
index 000000000..18139c7a6
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/ScaffoldWithToolbar.kt
@@ -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 = {}
+ )
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/common/Scrollbar.kt b/app/src/main/java/org/schabi/newpipe/ui/components/common/Scrollbar.kt
new file mode 100644
index 000000000..eb1595467
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/common/Scrollbar.kt
@@ -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,
+ )
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt
new file mode 100644
index 000000000..fe973d24b
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/ItemList.kt
@@ -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,
+ 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(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
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/playlist/PlaylistListItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/playlist/PlaylistListItem.kt
new file mode 100644
index 000000000..f282f9030
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/playlist/PlaylistListItem.kt
@@ -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)
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/playlist/PlaylistThumbnail.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/playlist/PlaylistThumbnail.kt
new file mode 100644
index 000000000..36711105b
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/playlist/PlaylistThumbnail.kt
@@ -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)
+ )
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamCardItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamCardItem.kt
similarity index 91%
rename from app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamCardItem.kt
rename to app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamCardItem.kt
index 74216582d..12d6bfbe7 100644
--- a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamCardItem.kt
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamCardItem.kt
@@ -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)
}
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamGridItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamGridItem.kt
similarity index 86%
rename from app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamGridItem.kt
rename to app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamGridItem.kt
index 31cc971b8..44df1eb6b 100644
--- a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamGridItem.kt
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamGridItem.kt
@@ -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)
}
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamListItem.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamListItem.kt
similarity index 78%
rename from app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamListItem.kt
rename to app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamListItem.kt
index 57f9afd03..ee6bde28d 100644
--- a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamListItem.kt
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamListItem.kt
@@ -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)
}
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt
new file mode 100644
index 000000000..2902aa660
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamMenu.kt
@@ -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()
+ 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
+ )
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamThumbnail.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamThumbnail.kt
new file mode 100644
index 000000000..f5515a24a
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamThumbnail.kt
@@ -0,0 +1,89 @@
+package org.schabi.newpipe.ui.components.items.stream
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.requiredHeight
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableLongStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+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.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.viewmodel.compose.viewModel
+import coil3.compose.AsyncImage
+import org.schabi.newpipe.R
+import org.schabi.newpipe.extractor.stream.StreamInfoItem
+import org.schabi.newpipe.util.Localization
+import org.schabi.newpipe.util.StreamTypeUtil
+import org.schabi.newpipe.util.image.ImageStrategy
+import org.schabi.newpipe.viewmodels.StreamViewModel
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+@Composable
+fun StreamThumbnail(
+ stream: StreamInfoItem,
+ showProgress: Boolean,
+ modifier: Modifier = Modifier,
+ contentScale: ContentScale = ContentScale.Fit
+) {
+ Column(modifier = modifier) {
+ Box(contentAlignment = Alignment.BottomEnd) {
+ AsyncImage(
+ model = ImageStrategy.choosePreferredImage(stream.thumbnails),
+ contentDescription = null,
+ placeholder = painterResource(R.drawable.placeholder_thumbnail_video),
+ error = painterResource(R.drawable.placeholder_thumbnail_video),
+ contentScale = contentScale,
+ modifier = modifier
+ )
+
+ val isLive = StreamTypeUtil.isLiveStream(stream.streamType)
+ Text(
+ modifier = Modifier
+ .padding(2.dp)
+ .background(if (isLive) Color.Red else Color.Black.copy(alpha = 0.5f))
+ .padding(2.dp),
+ text = if (isLive) {
+ stringResource(R.string.duration_live)
+ } else {
+ Localization.getDurationString(stream.duration)
+ },
+ color = Color.White,
+ style = MaterialTheme.typography.bodySmall
+ )
+ }
+
+ if (showProgress) {
+ val streamViewModel = viewModel()
+ var progress by rememberSaveable { mutableLongStateOf(0L) }
+
+ LaunchedEffect(stream) {
+ progress = streamViewModel.getStreamState(stream)?.progressMillis ?: 0L
+ }
+
+ if (progress != 0L) {
+ LinearProgressIndicator(
+ modifier = Modifier.requiredHeight(2.dp),
+ progress = {
+ (progress.milliseconds / stream.duration.seconds).toFloat()
+ },
+ gapSize = 0.dp,
+ drawStopIndicator = {} // Hide stop indicator
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamUtils.kt b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamUtils.kt
similarity index 97%
rename from app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamUtils.kt
rename to app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamUtils.kt
index c1c462068..cdfe613ed 100644
--- a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamUtils.kt
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/items/stream/StreamUtils.kt
@@ -1,4 +1,4 @@
-package org.schabi.newpipe.ui.components.stream
+package org.schabi.newpipe.ui.components.items.stream
import androidx.compose.runtime.Composable
import androidx.compose.runtime.saveable.rememberSaveable
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/playlist/PlaylistHeader.kt b/app/src/main/java/org/schabi/newpipe/ui/components/playlist/PlaylistHeader.kt
index b46abc606..ed4383fe3 100644
--- a/app/src/main/java/org/schabi/newpipe/ui/components/playlist/PlaylistHeader.kt
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/playlist/PlaylistHeader.kt
@@ -33,7 +33,7 @@ import androidx.compose.ui.text.style.TextOverflow
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.error.ErrorUtil
import org.schabi.newpipe.extractor.ServiceList
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamList.kt b/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamList.kt
deleted file mode 100644
index 6d4569de1..000000000
--- a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamList.kt
+++ /dev/null
@@ -1,126 +0,0 @@
-package org.schabi.newpipe.ui.components.stream
-
-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.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import androidx.fragment.app.FragmentActivity
-import androidx.paging.compose.LazyPagingItems
-import androidx.preference.PreferenceManager
-import androidx.window.core.layout.WindowWidthSizeClass
-import my.nanihadesuka.compose.LazyColumnScrollbar
-import my.nanihadesuka.compose.LazyVerticalGridScrollbar
-import org.schabi.newpipe.R
-import org.schabi.newpipe.extractor.stream.StreamInfoItem
-import org.schabi.newpipe.info_list.ItemViewMode
-import org.schabi.newpipe.util.NavigationHelper
-
-@Composable
-fun StreamList(
- streams: LazyPagingItems,
- itemViewMode: ItemViewMode = determineItemViewMode(),
- gridHeader: LazyGridScope.() -> Unit = {},
- listHeader: LazyListScope.() -> Unit = {}
-) {
- val context = LocalContext.current
- val onClick = remember {
- { stream: StreamInfoItem ->
- NavigationHelper.openVideoDetailFragment(
- context, (context as FragmentActivity).supportFragmentManager,
- stream.serviceId, stream.url, stream.name, null, false
- )
- }
- }
-
- // Handle long clicks
- // TODO: Adjust the menu display depending on where it was triggered
- var selectedStream by remember { mutableStateOf(null) }
- val onLongClick = remember {
- { stream: StreamInfoItem ->
- selectedStream = stream
- }
- }
- val onDismissPopup = remember {
- {
- selectedStream = null
- }
- }
-
- if (itemViewMode == ItemViewMode.GRID) {
- val gridState = rememberLazyGridState()
-
- LazyVerticalGridScrollbar(state = gridState) {
- 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(streams.itemCount) {
- val stream = streams[it]!!
- StreamGridItem(
- stream, selectedStream == stream, isCompact, onClick, onLongClick,
- onDismissPopup
- )
- }
- }
- }
- } else {
- // Card or list views
- val listState = rememberLazyListState()
-
- LazyColumnScrollbar(state = listState) {
- LazyColumn(state = listState) {
- listHeader()
-
- items(streams.itemCount) {
- val stream = streams[it]!!
- val isSelected = selectedStream == stream
-
- if (itemViewMode == ItemViewMode.CARD) {
- StreamCardItem(stream, isSelected, onClick, onLongClick, onDismissPopup)
- } else {
- StreamListItem(stream, isSelected, onClick, onLongClick, onDismissPopup)
- }
- }
- }
- }
- }
-}
-
-@Composable
-private fun determineItemViewMode(): ItemViewMode {
- val listMode = PreferenceManager.getDefaultSharedPreferences(LocalContext.current)
- .getString(
- stringResource(R.string.list_view_mode_key),
- stringResource(R.string.list_view_mode_value)
- )
-
- return when (listMode) {
- stringResource(R.string.list_view_mode_list_key) -> ItemViewMode.LIST
- stringResource(R.string.list_view_mode_grid_key) -> ItemViewMode.GRID
- stringResource(R.string.list_view_mode_card_key) -> ItemViewMode.CARD
- else -> {
- // Auto mode - evaluate whether to use Grid based on screen real estate.
- val windowSizeClass = currentWindowAdaptiveInfo().windowSizeClass
- if (windowSizeClass.windowWidthSizeClass == WindowWidthSizeClass.EXPANDED) {
- ItemViewMode.GRID
- } else {
- ItemViewMode.LIST
- }
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamMenu.kt b/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamMenu.kt
deleted file mode 100644
index cf832d67b..000000000
--- a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamMenu.kt
+++ /dev/null
@@ -1,74 +0,0 @@
-package org.schabi.newpipe.ui.components.stream
-
-import androidx.compose.material3.DropdownMenu
-import androidx.compose.material3.DropdownMenuItem
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.res.stringResource
-import androidx.fragment.app.FragmentActivity
-import org.schabi.newpipe.R
-import org.schabi.newpipe.download.DownloadDialog
-import org.schabi.newpipe.extractor.stream.StreamInfo
-import org.schabi.newpipe.extractor.stream.StreamInfoItem
-import org.schabi.newpipe.util.SparseItemUtil
-import org.schabi.newpipe.util.external_communication.ShareUtils
-
-@Composable
-fun StreamMenu(
- stream: StreamInfoItem,
- onDismissRequest: () -> Unit
-) {
- val context = LocalContext.current
-
- // TODO: Implement remaining click actions
- DropdownMenu(expanded = true, onDismissRequest = onDismissRequest) {
- DropdownMenuItem(
- text = { Text(text = stringResource(R.string.start_here_on_background)) },
- onClick = onDismissRequest
- )
- DropdownMenuItem(
- text = { Text(text = stringResource(R.string.start_here_on_popup)) },
- onClick = onDismissRequest
- )
- DropdownMenuItem(
- text = { Text(text = stringResource(R.string.download)) },
- onClick = {
- onDismissRequest()
- SparseItemUtil.fetchStreamInfoAndSaveToDatabase(
- context, stream.serviceId, stream.url
- ) { info: StreamInfo ->
- val downloadDialog = DownloadDialog(context, info)
- val fragmentManager = (context as FragmentActivity).supportFragmentManager
- downloadDialog.show(fragmentManager, "downloadDialog")
- }
- }
- )
- DropdownMenuItem(
- text = { Text(text = stringResource(R.string.add_to_playlist)) },
- onClick = onDismissRequest
- )
- DropdownMenuItem(
- text = { Text(text = stringResource(R.string.share)) },
- onClick = {
- onDismissRequest()
- ShareUtils.shareText(context, stream.name, stream.url, stream.thumbnails)
- }
- )
- DropdownMenuItem(
- text = { Text(text = stringResource(R.string.open_in_browser)) },
- onClick = {
- onDismissRequest()
- ShareUtils.openUrlInBrowser(context, stream.url)
- }
- )
- DropdownMenuItem(
- text = { Text(text = stringResource(R.string.mark_as_watched)) },
- onClick = onDismissRequest
- )
- DropdownMenuItem(
- text = { Text(text = stringResource(R.string.show_channel_details)) },
- onClick = onDismissRequest
- )
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamThumbnail.kt b/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamThumbnail.kt
deleted file mode 100644
index 43d776e58..000000000
--- a/app/src/main/java/org/schabi/newpipe/ui/components/stream/StreamThumbnail.kt
+++ /dev/null
@@ -1,55 +0,0 @@
-package org.schabi.newpipe.ui.components.stream
-
-import androidx.compose.foundation.background
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.padding
-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.res.painterResource
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.unit.dp
-import coil.compose.AsyncImage
-import org.schabi.newpipe.R
-import org.schabi.newpipe.extractor.stream.StreamInfoItem
-import org.schabi.newpipe.util.Localization
-import org.schabi.newpipe.util.StreamTypeUtil
-import org.schabi.newpipe.util.image.ImageStrategy
-
-@Composable
-fun StreamThumbnail(
- stream: StreamInfoItem,
- modifier: Modifier = Modifier,
- contentScale: ContentScale = ContentScale.Fit
-) {
- Box(modifier = modifier, contentAlignment = Alignment.BottomEnd) {
- AsyncImage(
- model = ImageStrategy.choosePreferredImage(stream.thumbnails),
- contentDescription = null,
- placeholder = painterResource(R.drawable.placeholder_thumbnail_video),
- error = painterResource(R.drawable.placeholder_thumbnail_video),
- contentScale = contentScale,
- modifier = modifier
- )
-
- val isLive = StreamTypeUtil.isLiveStream(stream.streamType)
- val background = if (isLive) Color.Red else Color.Black
- Text(
- text = if (isLive) {
- stringResource(R.string.duration_live)
- } else {
- Localization.getDurationString(stream.duration)
- },
- color = Color.White,
- style = MaterialTheme.typography.bodySmall,
- modifier = Modifier
- .padding(2.dp)
- .background(background.copy(alpha = 0.5f))
- .padding(2.dp)
- )
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/RelatedItems.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/RelatedItems.kt
new file mode 100644
index 000000000..7f2cf9346
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/RelatedItems.kt
@@ -0,0 +1,104 @@
+package org.schabi.newpipe.ui.components.video
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Switch
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+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.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.core.content.edit
+import androidx.paging.PagingData
+import androidx.paging.compose.collectAsLazyPagingItems
+import androidx.preference.PreferenceManager
+import kotlinx.coroutines.flow.flowOf
+import org.schabi.newpipe.R
+import org.schabi.newpipe.extractor.stream.StreamInfo
+import org.schabi.newpipe.extractor.stream.StreamType
+import org.schabi.newpipe.info_list.ItemViewMode
+import org.schabi.newpipe.ui.components.items.ItemList
+import org.schabi.newpipe.ui.components.items.stream.StreamInfoItem
+import org.schabi.newpipe.ui.emptystate.EmptyStateComposable
+import org.schabi.newpipe.ui.emptystate.EmptyStateSpec
+import org.schabi.newpipe.ui.theme.AppTheme
+import org.schabi.newpipe.util.NO_SERVICE_ID
+
+@Composable
+fun RelatedItems(info: StreamInfo) {
+ val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(LocalContext.current)
+ val key = stringResource(R.string.auto_queue_key)
+ // TODO: AndroidX DataStore might be a better option.
+ var isAutoQueueEnabled by rememberSaveable {
+ mutableStateOf(sharedPreferences.getBoolean(key, false))
+ }
+
+ ItemList(
+ items = flowOf(PagingData.from(info.relatedItems)).collectAsLazyPagingItems(),
+ mode = ItemViewMode.LIST,
+ listHeader = {
+ item {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(start = 12.dp, end = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(text = stringResource(R.string.auto_queue_description))
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(text = stringResource(R.string.auto_queue_toggle))
+ Switch(
+ checked = isAutoQueueEnabled,
+ onCheckedChange = {
+ isAutoQueueEnabled = it
+ sharedPreferences.edit {
+ putBoolean(key, it)
+ }
+ }
+ )
+ }
+ }
+ }
+ if (info.relatedItems.isEmpty()) {
+ item {
+ EmptyStateComposable(EmptyStateSpec.NoVideos)
+ }
+ }
+ }
+ )
+}
+
+@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
+@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun RelatedItemsPreview() {
+ val info = StreamInfo(NO_SERVICE_ID, "", "", StreamType.VIDEO_STREAM, "", "", 0)
+ info.relatedItems = listOf(
+ StreamInfoItem(streamType = StreamType.NONE),
+ StreamInfoItem(streamType = StreamType.LIVE_STREAM),
+ StreamInfoItem(streamType = StreamType.AUDIO_LIVE_STREAM),
+ )
+
+ AppTheme {
+ Surface(color = MaterialTheme.colorScheme.background) {
+ RelatedItems(info)
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt
new file mode 100644
index 000000000..efa87b581
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/Comment.kt
@@ -0,0 +1,278 @@
+package org.schabi.newpipe.ui.components.video.comment
+
+import android.content.res.Configuration
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
+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.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.PushPin
+import androidx.compose.material.icons.filled.ThumbUp
+import androidx.compose.material3.Icon
+import androidx.compose.material3.LocalMinimumInteractiveComponentSize
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+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.unit.dp
+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.ui.components.common.rememberParsedDescription
+import org.schabi.newpipe.ui.theme.AppTheme
+import org.schabi.newpipe.util.Localization
+import org.schabi.newpipe.util.NavigationHelper
+import org.schabi.newpipe.util.external_communication.copyToClipboardCallback
+import org.schabi.newpipe.util.image.ImageStrategy
+
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+fun Comment(comment: CommentsInfoItem, onCommentAuthorOpened: () -> Unit) {
+ val context = LocalContext.current
+ var isExpanded by rememberSaveable { mutableStateOf(false) }
+ var showReplies by rememberSaveable { mutableStateOf(false) }
+ val parsedDescription = rememberParsedDescription(comment.commentText)
+
+ Row(
+ modifier = Modifier
+ .animateContentSize()
+ .combinedClickable(
+ onLongClick = copyToClipboardCallback { parsedDescription },
+ onClick = { isExpanded = !isExpanded },
+ )
+ .padding(start = 8.dp, top = 10.dp, end = 8.dp, bottom = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ AsyncImage(
+ model = ImageStrategy.choosePreferredImage(comment.uploaderAvatars),
+ contentDescription = null,
+ placeholder = painterResource(R.drawable.placeholder_person),
+ error = painterResource(R.drawable.placeholder_person),
+ modifier = Modifier
+ .padding(vertical = 4.dp)
+ .size(42.dp)
+ .clip(CircleShape)
+ .clickable {
+ NavigationHelper.openCommentAuthorIfPresent(context, comment)
+ onCommentAuthorOpened()
+ }
+ )
+
+ Column {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (comment.isPinned) {
+ Icon(
+ imageVector = Icons.Default.PushPin,
+ contentDescription = stringResource(R.string.detail_pinned_comment_view_description),
+ modifier = Modifier
+ .padding(end = 3.dp)
+ .size(20.dp)
+ )
+ }
+
+ val nameAndDate = remember(comment) {
+ val date = Localization.relativeTimeOrTextual(
+ context, comment.uploadDate, comment.textualUploadDate
+ )
+ Localization.concatenateStrings(comment.uploaderName, date)
+ }
+ Text(
+ text = nameAndDate,
+ style = MaterialTheme.typography.titleSmall,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+
+ Text(
+ text = parsedDescription,
+ // If the comment is expanded, we display all its content
+ // otherwise we only display the first two lines
+ maxLines = if (isExpanded) Int.MAX_VALUE else 2,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(top = 6.dp)
+ )
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(start = 1.dp, top = 6.dp, end = 4.dp, bottom = 6.dp)
+ ) {
+ // do not show anything if the like count is unknown
+ if (comment.likeCount >= 0) {
+ Icon(
+ imageVector = Icons.Default.ThumbUp,
+ contentDescription = stringResource(R.string.detail_likes_img_view_description),
+ modifier = Modifier
+ .padding(end = 4.dp)
+ .size(20.dp),
+ )
+ Text(
+ text = Localization.likeCount(context, comment.likeCount),
+ maxLines = 1,
+ style = MaterialTheme.typography.labelMedium,
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ }
+
+ if (comment.isHeartedByUploader) {
+ Icon(
+ imageVector = Icons.Default.Favorite,
+ contentDescription = stringResource(R.string.detail_heart_img_view_description),
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(20.dp),
+ )
+ }
+ }
+
+ if (comment.replies != null) {
+ // reduce LocalMinimumInteractiveComponentSize from 48dp to 44dp to slightly
+ // reduce the button margin (which is still clickable but not visible)
+ CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 44.dp) {
+ TextButton(
+ onClick = { showReplies = true },
+ modifier = Modifier.padding(end = 2.dp)
+ ) {
+ val text = pluralStringResource(
+ R.plurals.replies, comment.replyCount, comment.replyCount.toString()
+ )
+ Text(text = text)
+ }
+ }
+ }
+ }
+ }
+ }
+
+ if (showReplies) {
+ CommentRepliesDialog(
+ parentComment = comment,
+ onDismissRequest = { showReplies = false },
+ onCommentAuthorOpened = onCommentAuthorOpened,
+ )
+ }
+}
+
+fun CommentsInfoItem(
+ serviceId: Int = 1,
+ url: String = "",
+ name: String = "",
+ commentText: Description,
+ uploaderName: String,
+ textualUploadDate: String = "5 months ago",
+ likeCount: Int = 0,
+ isHeartedByUploader: Boolean = false,
+ isPinned: Boolean = false,
+ replies: Page? = null,
+ replyCount: Int = 0,
+) = CommentsInfoItem(serviceId, url, name).apply {
+ this.commentText = commentText
+ this.uploaderName = uploaderName
+ this.textualUploadDate = textualUploadDate
+ this.likeCount = likeCount
+ this.isHeartedByUploader = isHeartedByUploader
+ this.isPinned = isPinned
+ this.replies = replies
+ this.replyCount = replyCount
+}
+
+private class CommentPreviewProvider : CollectionPreviewParameterProvider(
+ listOf(
+ CommentsInfoItem(
+ commentText = Description("Hello world!\n\nThis line should be hidden by default.", Description.PLAIN_TEXT),
+ uploaderName = "Test",
+ likeCount = 100,
+ isPinned = false,
+ isHeartedByUploader = true,
+ replies = null,
+ replyCount = 0
+ ),
+ CommentsInfoItem(
+ commentText = Description("Hello world, long long long text lorem ipsum dolor sit amet! This line should be hidden by default.", Description.HTML),
+ uploaderName = "Test",
+ likeCount = 92847,
+ isPinned = true,
+ isHeartedByUploader = false,
+ replies = Page(""),
+ replyCount = 10
+ ),
+ CommentsInfoItem(
+ commentText = Description("Hello world, long long long text lorem ipsum dolor sit amet! This line should be hidden by default.", Description.HTML),
+ uploaderName = "Test really long long long long lorem ipsum dolor sit amet consectetur",
+ likeCount = 92847,
+ isPinned = true,
+ isHeartedByUploader = true,
+ replies = null,
+ replyCount = 0
+ ),
+ CommentsInfoItem(
+ commentText = Description("Short comment", Description.HTML),
+ uploaderName = "Test really long long long long lorem ipsum dolor sit amet consectetur",
+ likeCount = 92847,
+ isPinned = false,
+ isHeartedByUploader = false,
+ replies = Page(""),
+ replyCount = 4283
+ ),
+ )
+)
+
+@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
+@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun CommentPreview(
+ @PreviewParameter(CommentPreviewProvider::class) commentsInfoItem: CommentsInfoItem
+) {
+ AppTheme {
+ Surface(color = MaterialTheme.colorScheme.background) {
+ Comment(commentsInfoItem) {}
+ }
+ }
+}
+
+@Preview
+@Composable
+private fun CommentListPreview() {
+ AppTheme {
+ Surface(color = MaterialTheme.colorScheme.background) {
+ Column {
+ for (comment in CommentPreviewProvider().values) {
+ Comment(comment) {}
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt
new file mode 100644
index 000000000..2c62739e2
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentInfo.kt
@@ -0,0 +1,21 @@
+package org.schabi.newpipe.ui.components.video.comment
+
+import androidx.compose.runtime.Immutable
+import org.schabi.newpipe.extractor.Page
+import org.schabi.newpipe.extractor.comments.CommentsInfo
+import org.schabi.newpipe.extractor.comments.CommentsInfoItem
+
+@Immutable
+class CommentInfo(
+ val serviceId: Int,
+ val url: String,
+ val comments: List,
+ val nextPage: Page?,
+ val commentCount: Int,
+ val isCommentsDisabled: Boolean
+) {
+ constructor(commentsInfo: CommentsInfo) : this(
+ commentsInfo.serviceId, commentsInfo.url, commentsInfo.relatedItems, commentsInfo.nextPage,
+ commentsInfo.commentsCount, commentsInfo.isCommentsDisabled
+ )
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesDialog.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesDialog.kt
new file mode 100644
index 000000000..d6d00b28c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesDialog.kt
@@ -0,0 +1,187 @@
+package org.schabi.newpipe.ui.components.video.comment
+
+import android.content.res.Configuration
+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.HorizontalDivider
+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.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
+import androidx.compose.ui.unit.dp
+import androidx.paging.LoadState
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.PagingData
+import androidx.paging.cachedIn
+import androidx.paging.compose.collectAsLazyPagingItems
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
+import org.schabi.newpipe.R
+import org.schabi.newpipe.extractor.comments.CommentsInfoItem
+import org.schabi.newpipe.extractor.stream.Description
+import org.schabi.newpipe.paging.CommentRepliesSource
+import org.schabi.newpipe.ui.components.common.LazyColumnThemedScrollbar
+import org.schabi.newpipe.ui.components.common.LoadingIndicator
+import org.schabi.newpipe.ui.emptystate.EmptyStateComposable
+import org.schabi.newpipe.ui.emptystate.EmptyStateSpec
+import org.schabi.newpipe.ui.theme.AppTheme
+
+@Composable
+fun CommentRepliesDialog(
+ parentComment: CommentsInfoItem,
+ onDismissRequest: () -> Unit,
+ onCommentAuthorOpened: () -> Unit,
+) {
+ val coroutineScope = rememberCoroutineScope()
+ val commentsFlow = remember {
+ Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) {
+ CommentRepliesSource(parentComment)
+ }
+ .flow
+ .cachedIn(coroutineScope)
+ }
+
+ CommentRepliesDialog(parentComment, commentsFlow, onDismissRequest, onCommentAuthorOpened)
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun CommentRepliesDialog(
+ parentComment: CommentsInfoItem,
+ commentsFlow: Flow>,
+ onDismissRequest: () -> Unit,
+ onCommentAuthorOpened: () -> Unit,
+) {
+ val comments = commentsFlow.collectAsLazyPagingItems()
+ val nestedScrollInterop = rememberNestedScrollInteropConnection()
+ val listState = rememberLazyListState()
+
+ val coroutineScope = rememberCoroutineScope()
+ val sheetState = rememberModalBottomSheetState()
+ val nestedOnCommentAuthorOpened: () -> Unit = {
+ // also partialExpand any parent dialog
+ onCommentAuthorOpened()
+ coroutineScope.launch {
+ sheetState.partialExpand()
+ }
+ }
+
+ ModalBottomSheet(
+ sheetState = sheetState,
+ onDismissRequest = 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 = listState) {
+ LazyColumn(
+ modifier = Modifier.nestedScroll(nestedScrollInterop),
+ state = listState
+ ) {
+ item {
+ CommentRepliesHeader(
+ comment = parentComment,
+ onCommentAuthorOpened = nestedOnCommentAuthorOpened,
+ )
+ HorizontalDivider(
+ thickness = 1.dp,
+ modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp)
+ )
+ }
+
+ if (parentComment.replyCount >= 0) {
+ item {
+ Text(
+ modifier = Modifier.padding(
+ horizontal = 12.dp,
+ vertical = 4.dp
+ ),
+ text = pluralStringResource(
+ R.plurals.replies,
+ parentComment.replyCount,
+ parentComment.replyCount,
+ ),
+ maxLines = 1,
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+ }
+
+ if (comments.itemCount == 0) {
+ item {
+ val refresh = comments.loadState.refresh
+ if (refresh is LoadState.Loading) {
+ LoadingIndicator(modifier = Modifier.padding(top = 8.dp))
+ } else if (refresh is LoadState.Error) {
+ // TODO use error panel instead
+ EmptyStateComposable(
+ EmptyStateSpec.DisabledComments.copy(
+ descriptionText = {
+ stringResource(R.string.error_unable_to_load_comments)
+ }
+ )
+ )
+ } else {
+ EmptyStateComposable(EmptyStateSpec.NoComments)
+ }
+ }
+ } else {
+ items(comments.itemCount) {
+ Comment(
+ comment = comments[it]!!,
+ onCommentAuthorOpened = nestedOnCommentAuthorOpened,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
+@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun CommentRepliesDialogPreview() {
+ val comment = CommentsInfoItem(
+ commentText = Description("Hello world!", Description.PLAIN_TEXT),
+ uploaderName = "Test",
+ likeCount = 100,
+ isPinned = true,
+ isHeartedByUploader = true
+ )
+ val replies = (1..10).map { i ->
+ CommentsInfoItem(
+ commentText = Description(
+ "Reply $i: ${LoremIpsum(i * i).values.first()}",
+ Description.PLAIN_TEXT,
+ ),
+ uploaderName = LoremIpsum(11 - i).values.first()
+ )
+ }
+ val flow = flowOf(PagingData.from(replies))
+
+ AppTheme {
+ CommentRepliesDialog(comment, flow, onDismissRequest = {}, onCommentAuthorOpened = {})
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesHeader.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesHeader.kt
new file mode 100644
index 000000000..e6627f7f0
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentRepliesHeader.kt
@@ -0,0 +1,150 @@
+package org.schabi.newpipe.ui.components.video.comment
+
+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.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material.icons.filled.PushPin
+import androidx.compose.material.icons.filled.ThumbUp
+import androidx.compose.material3.Icon
+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.draw.clip
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.datasource.LoremIpsum
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import org.schabi.newpipe.R
+import org.schabi.newpipe.extractor.comments.CommentsInfoItem
+import org.schabi.newpipe.extractor.stream.Description
+import org.schabi.newpipe.ui.components.common.DescriptionText
+import org.schabi.newpipe.ui.theme.AppTheme
+import org.schabi.newpipe.util.Localization
+import org.schabi.newpipe.util.NavigationHelper
+import org.schabi.newpipe.util.image.ImageStrategy
+
+@Composable
+fun CommentRepliesHeader(comment: CommentsInfoItem, onCommentAuthorOpened: () -> Unit) {
+ val context = LocalContext.current
+
+ Column(
+ modifier = Modifier.padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ modifier = Modifier
+ .padding(end = 12.dp)
+ .clip(CircleShape)
+ .clickable {
+ NavigationHelper.openCommentAuthorIfPresent(context, comment)
+ onCommentAuthorOpened()
+ }
+ .weight(1.0f, true),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AsyncImage(
+ model = ImageStrategy.choosePreferredImage(comment.uploaderAvatars),
+ contentDescription = null,
+ placeholder = painterResource(R.drawable.placeholder_person),
+ error = painterResource(R.drawable.placeholder_person),
+ modifier = Modifier
+ .size(42.dp)
+ .clip(CircleShape)
+ )
+
+ Column {
+ Text(
+ text = comment.uploaderName,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ style = MaterialTheme.typography.titleSmall,
+ )
+
+ Text(
+ text = Localization.relativeTimeOrTextual(
+ context, comment.uploadDate, comment.textualUploadDate
+ ),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // do not show anything if the like count is unknown
+ if (comment.likeCount >= 0) {
+ Icon(
+ imageVector = Icons.Default.ThumbUp,
+ contentDescription = stringResource(R.string.detail_likes_img_view_description),
+ )
+ Text(
+ text = Localization.likeCount(context, comment.likeCount),
+ maxLines = 1,
+ )
+ }
+
+ if (comment.isHeartedByUploader) {
+ Icon(
+ imageVector = Icons.Default.Favorite,
+ contentDescription = stringResource(R.string.detail_heart_img_view_description),
+ tint = MaterialTheme.colorScheme.primary,
+ )
+ }
+
+ if (comment.isPinned) {
+ Icon(
+ imageVector = Icons.Default.PushPin,
+ contentDescription = stringResource(R.string.detail_pinned_comment_view_description),
+ )
+ }
+ }
+ }
+
+ DescriptionText(
+ description = comment.commentText,
+ style = MaterialTheme.typography.bodyMedium,
+ )
+ }
+}
+
+@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
+@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+fun CommentRepliesHeaderPreview() {
+ val comment = CommentsInfoItem(
+ commentText = Description(LoremIpsum(50).values.first(), Description.PLAIN_TEXT),
+ uploaderName = "Test really long lorem ipsum dolor sit",
+ likeCount = 1000,
+ isPinned = true,
+ isHeartedByUploader = true
+ )
+
+ AppTheme {
+ Surface(color = MaterialTheme.colorScheme.background) {
+ CommentRepliesHeader(comment) {}
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt
new file mode 100644
index 000000000..d603c4a6f
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/components/video/comment/CommentSection.kt
@@ -0,0 +1,193 @@
+package org.schabi.newpipe.ui.components.video.comment
+
+import android.content.res.Configuration
+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.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.rememberNestedScrollInteropConnection
+import androidx.compose.ui.res.pluralStringResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import androidx.paging.LoadState
+import androidx.paging.PagingData
+import androidx.paging.compose.collectAsLazyPagingItems
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOf
+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.ui.components.common.LazyColumnThemedScrollbar
+import org.schabi.newpipe.ui.components.common.LoadingIndicator
+import org.schabi.newpipe.ui.emptystate.EmptyStateComposable
+import org.schabi.newpipe.ui.emptystate.EmptyStateSpec
+import org.schabi.newpipe.ui.theme.AppTheme
+import org.schabi.newpipe.viewmodels.CommentsViewModel
+import org.schabi.newpipe.viewmodels.util.Resource
+
+@Composable
+fun CommentSection(commentsViewModel: CommentsViewModel = viewModel()) {
+ val state by commentsViewModel.uiState.collectAsStateWithLifecycle()
+ CommentSection(state, commentsViewModel.comments)
+}
+
+@Composable
+private fun CommentSection(
+ uiState: Resource,
+ commentsFlow: Flow>
+) {
+ val comments = commentsFlow.collectAsLazyPagingItems()
+ val nestedScrollInterop = rememberNestedScrollInteropConnection()
+ val state = rememberLazyListState()
+
+ LazyColumnThemedScrollbar(state = state) {
+ LazyColumn(
+ modifier = Modifier.nestedScroll(nestedScrollInterop),
+ state = state
+ ) {
+ when (uiState) {
+ is Resource.Loading -> {
+ item {
+ LoadingIndicator(modifier = Modifier.padding(top = 8.dp))
+ }
+ }
+
+ is Resource.Success -> {
+ val commentInfo = uiState.data
+ val count = commentInfo.commentCount
+
+ if (commentInfo.isCommentsDisabled) {
+ item {
+ EmptyStateComposable(EmptyStateSpec.DisabledComments)
+ }
+ } else if (count == 0) {
+ item {
+ EmptyStateComposable(EmptyStateSpec.NoComments)
+ }
+ } else {
+ // do not show anything if the comment count is unknown
+ if (count >= 0) {
+ item {
+ Text(
+ modifier = Modifier
+ .padding(start = 12.dp, end = 12.dp, bottom = 4.dp),
+ text = pluralStringResource(R.plurals.comments, count, count),
+ maxLines = 1,
+ style = MaterialTheme.typography.titleMedium
+ )
+ }
+ }
+
+ when (comments.loadState.refresh) {
+ is LoadState.Loading -> {
+ item {
+ LoadingIndicator(modifier = Modifier.padding(top = 8.dp))
+ }
+ }
+
+ is LoadState.Error -> {
+ item {
+ // TODO use error panel instead
+ EmptyStateComposable(
+ EmptyStateSpec.DisabledComments.copy(
+ descriptionText = {
+ stringResource(R.string.error_unable_to_load_comments)
+ }
+ )
+ )
+ }
+ }
+
+ else -> {
+ items(comments.itemCount) {
+ Comment(comment = comments[it]!!) {}
+ }
+ }
+ }
+ }
+ }
+
+ is Resource.Error -> {
+ item {
+ // TODO use error panel instead
+ EmptyStateComposable(
+ EmptyStateSpec.DisabledComments.copy(
+ descriptionText = {
+ stringResource(R.string.error_unable_to_load_comments)
+ }
+ )
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
+@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun CommentSectionLoadingPreview() {
+ AppTheme {
+ Surface(color = MaterialTheme.colorScheme.background) {
+ CommentSection(uiState = Resource.Loading, commentsFlow = flowOf())
+ }
+ }
+}
+
+@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
+@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun CommentSectionSuccessPreview() {
+ val comments = listOf(
+ CommentsInfoItem(
+ commentText = Description(
+ "Comment 1\n\nThis line should be hidden by default.",
+ Description.PLAIN_TEXT
+ ),
+ uploaderName = "Test",
+ replies = Page(""),
+ replyCount = 10
+ )
+ ) + (2..10).map {
+ CommentsInfoItem(
+ commentText = Description("Comment $it", Description.PLAIN_TEXT),
+ uploaderName = "Test"
+ )
+ }
+
+ AppTheme {
+ Surface(color = MaterialTheme.colorScheme.background) {
+ CommentSection(
+ uiState = Resource.Success(
+ CommentInfo(
+ serviceId = 1, url = "", comments = comments, nextPage = null,
+ commentCount = 10, isCommentsDisabled = false
+ )
+ ),
+ commentsFlow = flowOf(PagingData.from(comments))
+ )
+ }
+ }
+}
+
+@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
+@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun CommentSectionErrorPreview() {
+ AppTheme {
+ Surface(color = MaterialTheme.colorScheme.background) {
+ CommentSection(uiState = Resource.Error(RuntimeException()), commentsFlow = flowOf())
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateComposable.kt b/app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateComposable.kt
new file mode 100644
index 000000000..ab9bf6336
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateComposable.kt
@@ -0,0 +1,159 @@
+package org.schabi.newpipe.ui.emptystate
+
+import android.graphics.Color
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.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.stringResource
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.schabi.newpipe.R
+import org.schabi.newpipe.ui.theme.AppTheme
+
+@Composable
+fun EmptyStateComposable(
+ spec: EmptyStateSpec,
+ modifier: Modifier = Modifier,
+) = EmptyStateComposable(
+ modifier = spec.modifier(modifier),
+ emojiText = spec.emojiText(),
+ descriptionText = spec.descriptionText(),
+)
+
+@Composable
+private fun EmptyStateComposable(
+ emojiText: String,
+ descriptionText: String,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Center
+ ) {
+ Text(
+ text = emojiText,
+ style = MaterialTheme.typography.titleLarge,
+ textAlign = TextAlign.Center,
+ )
+
+ Text(
+ modifier = Modifier
+ .padding(top = 6.dp)
+ .padding(horizontal = 16.dp),
+ text = descriptionText,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ )
+ }
+}
+
+@Preview(showBackground = true, backgroundColor = Color.WHITE.toLong())
+@Composable
+fun EmptyStateComposableGenericErrorPreview() {
+ AppTheme {
+ EmptyStateComposable(EmptyStateSpec.GenericError)
+ }
+}
+
+@Preview(showBackground = true, backgroundColor = Color.WHITE.toLong())
+@Composable
+fun EmptyStateComposableNoCommentPreview() {
+ AppTheme {
+ EmptyStateComposable(EmptyStateSpec.NoComments)
+ }
+}
+
+data class EmptyStateSpec(
+ val modifier: (Modifier) -> Modifier,
+ val emojiText: @Composable () -> String,
+ val descriptionText: @Composable () -> String,
+) {
+ companion object {
+
+ val GenericError =
+ EmptyStateSpec(
+ modifier = {
+ it
+ .fillMaxWidth()
+ .heightIn(min = 128.dp)
+ },
+ emojiText = { "¯\\_(ツ)_/¯" },
+ descriptionText = { stringResource(id = R.string.empty_list_subtitle) },
+ )
+
+ val NoVideos =
+ EmptyStateSpec(
+ modifier = {
+ it
+ .fillMaxWidth()
+ .heightIn(min = 128.dp)
+ },
+ emojiText = { "(╯°-°)╯" },
+ descriptionText = { stringResource(id = R.string.no_videos) },
+ )
+
+ val NoComments =
+ EmptyStateSpec(
+ modifier = {
+ it
+ .fillMaxWidth()
+ .heightIn(min = 128.dp)
+ },
+ emojiText = { "¯\\_(╹x╹)_/¯" },
+ descriptionText = { stringResource(id = R.string.no_comments) },
+ )
+
+ val DisabledComments =
+ NoComments.copy(
+ descriptionText = { stringResource(id = R.string.comments_are_disabled) },
+ )
+
+ val NoSearchResult =
+ NoComments.copy(
+ modifier = { it },
+ emojiText = { "╰(°●°╰)" },
+ descriptionText = { stringResource(id = R.string.search_no_results) }
+ )
+
+ val NoSearchMaxSizeResult =
+ NoSearchResult.copy(
+ modifier = { it.fillMaxSize() },
+ )
+
+ val ContentNotSupported =
+ NoComments.copy(
+ modifier = { it.padding(top = 90.dp) },
+ emojiText = { "(︶︹︺)" },
+ descriptionText = { stringResource(id = R.string.content_not_supported) },
+ )
+
+ val NoBookmarkedPlaylist =
+ EmptyStateSpec(
+ modifier = { it },
+ emojiText = { "(╥﹏╥)" },
+ descriptionText = { stringResource(id = R.string.no_playlist_bookmarked_yet) },
+ )
+
+ val NoSubscriptionsHint =
+ EmptyStateSpec(
+ modifier = { it },
+ emojiText = { "(꩜ᯅ꩜)" },
+ descriptionText = { stringResource(id = R.string.import_subscriptions_hint) },
+ )
+
+ val NoSubscriptions =
+ NoSubscriptionsHint.copy(
+ descriptionText = { stringResource(id = R.string.no_channel_subscribed_yet) },
+ )
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateUtil.kt b/app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateUtil.kt
new file mode 100644
index 000000000..2fced431f
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/emptystate/EmptyStateUtil.kt
@@ -0,0 +1,30 @@
+@file:JvmName("EmptyStateUtil")
+
+package org.schabi.newpipe.ui.emptystate
+
+import androidx.compose.material3.LocalContentColor
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.contentColorFor
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
+import org.schabi.newpipe.ui.theme.AppTheme
+
+@JvmOverloads
+fun ComposeView.setEmptyStateComposable(
+ spec: EmptyStateSpec = EmptyStateSpec.GenericError,
+ strategy: ViewCompositionStrategy = ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed,
+) = apply {
+ setViewCompositionStrategy(strategy)
+ setContent {
+ AppTheme {
+ CompositionLocalProvider(
+ LocalContentColor provides contentColorFor(MaterialTheme.colorScheme.background)
+ ) {
+ EmptyStateComposable(
+ spec = spec
+ )
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/screens/AboutScreen.kt b/app/src/main/java/org/schabi/newpipe/ui/screens/AboutScreen.kt
new file mode 100644
index 000000000..673a22892
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/ui/screens/AboutScreen.kt
@@ -0,0 +1,84 @@
+package org.schabi.newpipe.ui.screens
+
+import android.content.res.Configuration
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Tab
+import androidx.compose.material3.TabRow
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.NonRestartableComposable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import org.schabi.newpipe.R
+import org.schabi.newpipe.ui.components.about.AboutTab
+import org.schabi.newpipe.ui.components.about.LicenseTab
+import org.schabi.newpipe.ui.theme.AppTheme
+
+private val TITLES = intArrayOf(R.string.tab_about, R.string.tab_licenses)
+
+@Composable
+@NonRestartableComposable
+fun AboutScreen(padding: PaddingValues) {
+ Column(modifier = Modifier.padding(padding)) {
+ var tabIndex by rememberSaveable { mutableIntStateOf(0) }
+ val pagerState = rememberPagerState { TITLES.size }
+
+ LaunchedEffect(tabIndex) {
+ pagerState.animateScrollToPage(tabIndex)
+ }
+ LaunchedEffect(pagerState.currentPage) {
+ tabIndex = pagerState.currentPage
+ }
+
+ TabRow(
+ selectedTabIndex = tabIndex,
+ containerColor = MaterialTheme.colorScheme.primaryContainer
+ ) {
+ TITLES.forEachIndexed { index, titleId ->
+ Tab(
+ text = { Text(text = stringResource(titleId)) },
+ selected = tabIndex == index,
+ onClick = { tabIndex = index }
+ )
+ }
+ }
+
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier
+ .fillMaxWidth()
+ .weight(1f)
+ ) { page ->
+ if (page == 0) {
+ AboutTab()
+ } else {
+ LicenseTab()
+ }
+ }
+ }
+}
+
+@Preview(name = "Light mode", uiMode = Configuration.UI_MODE_NIGHT_NO)
+@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES)
+@Composable
+private fun AboutScreenPreview() {
+ AppTheme {
+ Surface(color = MaterialTheme.colorScheme.background) {
+ AboutScreen(PaddingValues(8.dp))
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/ui/screens/PlaylistScreen.kt b/app/src/main/java/org/schabi/newpipe/ui/screens/PlaylistScreen.kt
index ddd978dc7..c63f8bcc3 100644
--- a/app/src/main/java/org/schabi/newpipe/ui/screens/PlaylistScreen.kt
+++ b/app/src/main/java/org/schabi/newpipe/ui/screens/PlaylistScreen.kt
@@ -19,10 +19,10 @@ import org.schabi.newpipe.extractor.stream.Description
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.extractor.stream.StreamType
import org.schabi.newpipe.ui.components.common.LoadingIndicator
+import org.schabi.newpipe.ui.components.items.ItemList
+import org.schabi.newpipe.ui.components.items.stream.StreamInfoItem
import org.schabi.newpipe.ui.components.playlist.PlaylistHeader
import org.schabi.newpipe.ui.components.playlist.PlaylistInfo
-import org.schabi.newpipe.ui.components.stream.StreamInfoItem
-import org.schabi.newpipe.ui.components.stream.StreamList
import org.schabi.newpipe.ui.theme.AppTheme
import org.schabi.newpipe.viewmodels.PlaylistViewModel
@@ -51,8 +51,8 @@ private fun PlaylistScreen(
}
}
- StreamList(
- streams = streams,
+ ItemList(
+ items = streams,
gridHeader = {
item(span = { GridItemSpan(maxLineSpan) }) {
PlaylistHeader(it, totalDuration)
diff --git a/app/src/main/java/org/schabi/newpipe/util/BridgeStateSaverInitializer.java b/app/src/main/java/org/schabi/newpipe/util/BridgeStateSaverInitializer.java
new file mode 100644
index 000000000..aeda4717c
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/util/BridgeStateSaverInitializer.java
@@ -0,0 +1,61 @@
+package org.schabi.newpipe.util;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.os.Parcelable;
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.evernote.android.state.StateSaver;
+import com.livefront.bridge.Bridge;
+import com.livefront.bridge.SavedStateHandler;
+import com.livefront.bridge.ViewSavedStateHandler;
+
+/**
+ * Configures Bridge's state saver.
+ */
+public final class BridgeStateSaverInitializer {
+
+ public static void init(final Context context) {
+ Bridge.initialize(
+ context,
+ new SavedStateHandler() {
+ @Override
+ public void saveInstanceState(
+ @NonNull final Object target,
+ @NonNull final Bundle state) {
+ StateSaver.saveInstanceState(target, state);
+ }
+
+ @Override
+ public void restoreInstanceState(
+ @NonNull final Object target,
+ @Nullable final Bundle state) {
+ StateSaver.restoreInstanceState(target, state);
+ }
+ },
+ new ViewSavedStateHandler() {
+ @NonNull
+ @Override
+ public Parcelable saveInstanceState(
+ @NonNull final T target,
+ @Nullable final Parcelable parentState) {
+ return StateSaver.saveInstanceState(target, parentState);
+ }
+
+ @Nullable
+ @Override
+ public Parcelable restoreInstanceState(
+ @NonNull final T target,
+ @Nullable final Parcelable state) {
+ return StateSaver.restoreInstanceState(target, state);
+ }
+ }
+ );
+ }
+
+ private BridgeStateSaverInitializer() {
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/util/Constants.kt b/app/src/main/java/org/schabi/newpipe/util/Constants.kt
index 054aadd70..216027291 100644
--- a/app/src/main/java/org/schabi/newpipe/util/Constants.kt
+++ b/app/src/main/java/org/schabi/newpipe/util/Constants.kt
@@ -9,6 +9,7 @@ const val DEFAULT_THROTTLE_TIMEOUT = 120L
const val KEY_SERVICE_ID = "key_service_id"
const val KEY_URL = "key_url"
+const val KEY_INFO = "info"
const val KEY_TITLE = "key_title"
const val KEY_LINK_TYPE = "key_link_type"
const val KEY_OPEN_SEARCH = "key_open_search"
diff --git a/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java b/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java
index e9678c2b0..7a357a0c1 100644
--- a/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java
+++ b/app/src/main/java/org/schabi/newpipe/util/DeviceUtils.java
@@ -130,7 +130,7 @@ public final class DeviceUtils {
}
isFireTV =
- App.getApp().getPackageManager().hasSystemFeature(AMAZON_FEATURE_FIRE_TV);
+ App.getInstance().getPackageManager().hasSystemFeature(AMAZON_FEATURE_FIRE_TV);
return isFireTV;
}
@@ -139,7 +139,7 @@ public final class DeviceUtils {
return isTV;
}
- final PackageManager pm = App.getApp().getPackageManager();
+ final PackageManager pm = App.getInstance().getPackageManager();
// from doc: https://developer.android.com/training/tv/start/hardware.html#runtime-check
boolean isTv = ContextCompat.getSystemService(context, UiModeManager.class)
diff --git a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
index e7da003d1..cd95535bc 100644
--- a/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/util/NavigationHelper.java
@@ -22,6 +22,7 @@ import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.fragment.app.FragmentManager;
import androidx.fragment.app.FragmentTransaction;
+import androidx.preference.PreferenceManager;
import com.jakewharton.processphoenix.ProcessPhoenix;
@@ -49,6 +50,7 @@ import org.schabi.newpipe.fragments.list.channel.ChannelFragment;
import org.schabi.newpipe.fragments.list.kiosk.KioskFragment;
import org.schabi.newpipe.fragments.list.playlist.PlaylistFragment;
import org.schabi.newpipe.fragments.list.search.SearchFragment;
+import org.schabi.newpipe.ktx.ContextKt;
import org.schabi.newpipe.local.bookmark.BookmarkFragment;
import org.schabi.newpipe.local.feed.FeedFragment;
import org.schabi.newpipe.local.history.StatisticsPlaylistFragment;
@@ -64,6 +66,7 @@ import org.schabi.newpipe.player.helper.PlayerHolder;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
import org.schabi.newpipe.settings.SettingsActivity;
+import org.schabi.newpipe.settings.SettingsV2Activity;
import org.schabi.newpipe.util.external_communication.ShareUtils;
import java.util.List;
@@ -472,32 +475,32 @@ public final class NavigationHelper {
.commit();
}
- public static void openChannelFragment(@NonNull final Fragment fragment,
+ public static void openChannelFragment(@NonNull final FragmentActivity activity,
@NonNull final StreamInfoItem item,
final String uploaderUrl) {
// For some reason `getParentFragmentManager()` doesn't work, but this does.
- openChannelFragment(
- fragment.requireActivity().getSupportFragmentManager(),
- item.getServiceId(), uploaderUrl, item.getUploaderName());
+ openChannelFragment(activity.getSupportFragmentManager(), item.getServiceId(), uploaderUrl,
+ item.getUploaderName());
}
/**
* Opens the comment author channel fragment, if the {@link CommentsInfoItem#getUploaderUrl()}
* of {@code comment} is non-null. Shows a UI-error snackbar if something goes wrong.
*
- * @param activity the activity with the fragment manager and in which to show the snackbar
+ * @param context the context to use for opening the fragment
* @param comment the comment whose uploader/author will be opened
*/
- public static void openCommentAuthorIfPresent(@NonNull final FragmentActivity activity,
+ public static void openCommentAuthorIfPresent(@NonNull final Context context,
@NonNull final CommentsInfoItem comment) {
if (isEmpty(comment.getUploaderUrl())) {
return;
}
try {
+ final var activity = ContextKt.findFragmentActivity(context);
openChannelFragment(activity.getSupportFragmentManager(), comment.getServiceId(),
comment.getUploaderUrl(), comment.getUploaderName());
} catch (final Exception e) {
- ErrorUtil.showUiErrorSnackbar(activity, "Opening channel fragment", e);
+ ErrorUtil.showUiErrorSnackbar(context, "Opening channel fragment", e);
}
}
@@ -643,7 +646,13 @@ public final class NavigationHelper {
}
public static void openSettings(final Context context) {
- final Intent intent = new Intent(context, SettingsActivity.class);
+ final Class> settingsClass = PreferenceManager.getDefaultSharedPreferences(context)
+ .getBoolean(
+ ContextCompat.getString(context, R.string.settings_layout_redesign_key),
+ false
+ ) ? SettingsV2Activity.class : SettingsActivity.class;
+
+ final Intent intent = new Intent(context, settingsClass);
context.startActivity(intent);
}
diff --git a/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt b/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt
index 3ea19fa4f..080f5bace 100644
--- a/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt
+++ b/app/src/main/java/org/schabi/newpipe/util/ReleaseVersionUtil.kt
@@ -21,7 +21,7 @@ object ReleaseVersionUtil {
val certificates = mapOf(
RELEASE_CERT_PUBLIC_KEY_SHA256.hexToByteArray() to PackageManager.CERT_INPUT_SHA256
)
- val app = App.getApp()
+ val app = App.instance
try {
PackageInfoCompat.hasSignatures(app.packageManager, app.packageName, certificates, false)
} catch (e: PackageManager.NameNotFoundException) {
diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java
index 9008a213d..4be5445bc 100644
--- a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java
+++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtils.java
@@ -1,6 +1,7 @@
package org.schabi.newpipe.util.external_communication;
import static org.schabi.newpipe.MainActivity.DEBUG;
+import static coil3.Image_androidKt.toBitmap;
import android.content.ActivityNotFoundException;
import android.content.ClipData;
@@ -31,9 +32,9 @@ import java.nio.file.Files;
import java.util.Collections;
import java.util.List;
-import coil.Coil;
-import coil.disk.DiskCache;
-import coil.memory.MemoryCache;
+import coil3.SingletonImageLoader;
+import coil3.disk.DiskCache;
+import coil3.memory.MemoryCache;
public final class ShareUtils {
private static final String TAG = ShareUtils.class.getSimpleName();
@@ -377,13 +378,13 @@ public final class ShareUtils {
// Save the image in memory to the application's cache because we need a URI to the
// image to generate a ClipData which will show the share sheet, and so an image file
final Context applicationContext = context.getApplicationContext();
- final var loader = Coil.imageLoader(context);
+ final var loader = SingletonImageLoader.get(context);
final var value = loader.getMemoryCache()
.get(new MemoryCache.Key(thumbnailUrl, Collections.emptyMap()));
final Bitmap cachedBitmap;
if (value != null) {
- cachedBitmap = value.getBitmap();
+ cachedBitmap = toBitmap(value.getImage());
} else {
try (var snapshot = loader.getDiskCache().openSnapshot(thumbnailUrl)) {
if (snapshot != null) {
diff --git a/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtilsKt.kt b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtilsKt.kt
new file mode 100644
index 000000000..fd60f348d
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/util/external_communication/ShareUtilsKt.kt
@@ -0,0 +1,28 @@
+package org.schabi.newpipe.util.external_communication
+
+import android.content.Context
+import android.os.Build
+import android.widget.Toast
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.ClipboardManager
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.text.AnnotatedString
+import org.schabi.newpipe.R
+
+fun ClipboardManager.setTextAndShowToast(context: Context, annotatedString: AnnotatedString) {
+ setText(annotatedString)
+ if (Build.VERSION.SDK_INT < 33) {
+ // Android 13 has its own "copied to clipboard" dialog
+ Toast.makeText(context, R.string.msg_copied, Toast.LENGTH_SHORT).show()
+ }
+}
+
+@Composable
+fun copyToClipboardCallback(annotatedString: () -> AnnotatedString): (() -> Unit) {
+ val clipboardManager = LocalClipboardManager.current
+ val context = LocalContext.current
+ return {
+ clipboardManager.setTextAndShowToast(context, annotatedString())
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/util/image/CoilHelper.kt b/app/src/main/java/org/schabi/newpipe/util/image/CoilHelper.kt
index 2608090dc..5b393658c 100644
--- a/app/src/main/java/org/schabi/newpipe/util/image/CoilHelper.kt
+++ b/app/src/main/java/org/schabi/newpipe/util/image/CoilHelper.kt
@@ -5,14 +5,18 @@ import android.graphics.Bitmap
import android.util.Log
import android.widget.ImageView
import androidx.annotation.DrawableRes
-import androidx.core.graphics.drawable.toBitmapOrNull
-import coil.executeBlocking
-import coil.imageLoader
-import coil.request.Disposable
-import coil.request.ImageRequest
-import coil.size.Size
-import coil.target.Target
-import coil.transform.Transformation
+import coil3.executeBlocking
+import coil3.imageLoader
+import coil3.request.Disposable
+import coil3.request.ImageRequest
+import coil3.request.error
+import coil3.request.placeholder
+import coil3.request.target
+import coil3.request.transformations
+import coil3.size.Size
+import coil3.target.Target
+import coil3.toBitmap
+import coil3.transform.Transformation
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.Image
@@ -26,84 +30,119 @@ object CoilHelper {
fun loadBitmapBlocking(
context: Context,
url: String?,
- @DrawableRes placeholderResId: Int = 0
- ): Bitmap? {
- val request = getImageRequest(context, url, placeholderResId).build()
- return context.imageLoader.executeBlocking(request).drawable?.toBitmapOrNull()
- }
+ @DrawableRes placeholderResId: Int = 0,
+ ): Bitmap? =
+ context.imageLoader
+ .executeBlocking(getImageRequest(context, url, placeholderResId).build())
+ .image
+ ?.toBitmap()
- fun loadAvatar(target: ImageView, images: List) {
+ fun loadAvatar(
+ target: ImageView,
+ images: List,
+ ) {
loadImageDefault(target, images, R.drawable.placeholder_person)
}
- fun loadAvatar(target: ImageView, url: String?) {
+ fun loadAvatar(
+ target: ImageView,
+ url: String?,
+ ) {
loadImageDefault(target, url, R.drawable.placeholder_person)
}
- fun loadThumbnail(target: ImageView, images: List) {
+ fun loadThumbnail(
+ target: ImageView,
+ images: List,
+ ) {
loadImageDefault(target, images, R.drawable.placeholder_thumbnail_video)
}
- fun loadThumbnail(target: ImageView, url: String?) {
+ fun loadThumbnail(
+ target: ImageView,
+ url: String?,
+ ) {
loadImageDefault(target, url, R.drawable.placeholder_thumbnail_video)
}
- fun loadScaledDownThumbnail(context: Context, images: List, target: Target): Disposable {
+ fun loadScaledDownThumbnail(
+ context: Context,
+ images: List,
+ target: Target,
+ ): Disposable {
val url = ImageStrategy.choosePreferredImage(images)
- val request = getImageRequest(context, url, R.drawable.placeholder_thumbnail_video)
- .target(target)
- .transformations(object : Transformation {
- override val cacheKey = "COIL_PLAYER_THUMBNAIL_TRANSFORMATION_KEY"
+ val request =
+ getImageRequest(context, url, R.drawable.placeholder_thumbnail_video)
+ .target(target)
+ .transformations(
+ object : Transformation() {
+ override val cacheKey = "COIL_PLAYER_THUMBNAIL_TRANSFORMATION_KEY"
- override suspend fun transform(input: Bitmap, size: Size): Bitmap {
- if (MainActivity.DEBUG) {
- Log.d(TAG, "Thumbnail - transform() called")
- }
+ override suspend fun transform(
+ input: Bitmap,
+ size: Size,
+ ): Bitmap {
+ if (MainActivity.DEBUG) {
+ Log.d(TAG, "Thumbnail - transform() called")
+ }
- val notificationThumbnailWidth = min(
- context.resources.getDimension(R.dimen.player_notification_thumbnail_width),
- input.width.toFloat()
- ).toInt()
+ val notificationThumbnailWidth =
+ min(
+ context.resources.getDimension(R.dimen.player_notification_thumbnail_width),
+ input.width.toFloat(),
+ ).toInt()
- var newHeight = input.height / (input.width / notificationThumbnailWidth)
- val result = input.scale(notificationThumbnailWidth, newHeight)
+ var newHeight = input.height / (input.width / notificationThumbnailWidth)
+ val result = input.scale(notificationThumbnailWidth, newHeight)
- return if (result == input || !result.isMutable) {
- // create a new mutable bitmap to prevent strange crashes on some
- // devices (see #4638)
- newHeight = input.height / (input.width / (notificationThumbnailWidth - 1))
- input.scale(notificationThumbnailWidth, newHeight)
- } else {
- result
- }
- }
- })
- .build()
+ return if (result == input || !result.isMutable) {
+ // create a new mutable bitmap to prevent strange crashes on some
+ // devices (see #4638)
+ newHeight = input.height / (input.width / (notificationThumbnailWidth - 1))
+ input.scale(notificationThumbnailWidth, newHeight)
+ } else {
+ result
+ }
+ }
+ },
+ ).build()
return context.imageLoader.enqueue(request)
}
- fun loadDetailsThumbnail(target: ImageView, images: List) {
+ fun loadDetailsThumbnail(
+ target: ImageView,
+ images: List,
+ ) {
val url = ImageStrategy.choosePreferredImage(images)
loadImageDefault(target, url, R.drawable.placeholder_thumbnail_video, false)
}
- fun loadBanner(target: ImageView, images: List) {
+ fun loadBanner(
+ target: ImageView,
+ images: List,
+ ) {
loadImageDefault(target, images, R.drawable.placeholder_channel_banner)
}
- fun loadPlaylistThumbnail(target: ImageView, images: List) {
+ fun loadPlaylistThumbnail(
+ target: ImageView,
+ images: List,
+ ) {
loadImageDefault(target, images, R.drawable.placeholder_thumbnail_playlist)
}
- fun loadPlaylistThumbnail(target: ImageView, url: String?) {
+ fun loadPlaylistThumbnail(
+ target: ImageView,
+ url: String?,
+ ) {
loadImageDefault(target, url, R.drawable.placeholder_thumbnail_playlist)
}
private fun loadImageDefault(
target: ImageView,
images: List,
- @DrawableRes placeholderResId: Int
+ @DrawableRes placeholderResId: Int,
) {
loadImageDefault(target, ImageStrategy.choosePreferredImage(images), placeholderResId)
}
@@ -112,11 +151,12 @@ object CoilHelper {
target: ImageView,
url: String?,
@DrawableRes placeholderResId: Int,
- showPlaceholder: Boolean = true
+ showPlaceholder: Boolean = true,
) {
- val request = getImageRequest(target.context, url, placeholderResId, showPlaceholder)
- .target(target)
- .build()
+ val request =
+ getImageRequest(target.context, url, placeholderResId, showPlaceholder)
+ .target(target)
+ .build()
target.context.imageLoader.enqueue(request)
}
@@ -124,14 +164,15 @@ object CoilHelper {
context: Context,
url: String?,
@DrawableRes placeholderResId: Int,
- showPlaceholderWhileLoading: Boolean = true
+ showPlaceholderWhileLoading: Boolean = true,
): ImageRequest.Builder {
// if the URL was chosen with `choosePreferredImage` it will be null, but check again
// `shouldLoadImages` in case the URL was chosen with `imageListToDbUrl` (which is the case
// for URLs stored in the database)
val takenUrl = url?.takeIf { it.isNotEmpty() && ImageStrategy.shouldLoadImages() }
- return ImageRequest.Builder(context)
+ return ImageRequest
+ .Builder(context)
.data(takenUrl)
.error(placeholderResId)
.memoryCacheKey(takenUrl)
diff --git a/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt
index 62babb186..007292498 100644
--- a/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt
+++ b/app/src/main/java/org/schabi/newpipe/viewmodels/CommentsViewModel.kt
@@ -6,17 +6,39 @@ import androidx.lifecycle.viewModelScope
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.cachedIn
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.SharingStarted
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.stateIn
+import org.schabi.newpipe.extractor.comments.CommentsInfo
import org.schabi.newpipe.paging.CommentsSource
-import org.schabi.newpipe.util.KEY_SERVICE_ID
+import org.schabi.newpipe.ui.components.video.comment.CommentInfo
import org.schabi.newpipe.util.KEY_URL
-import org.schabi.newpipe.util.NO_SERVICE_ID
+import org.schabi.newpipe.viewmodels.util.Resource
class CommentsViewModel(savedStateHandle: SavedStateHandle) : ViewModel() {
- private val serviceId = savedStateHandle[KEY_SERVICE_ID] ?: NO_SERVICE_ID
- private val url = savedStateHandle.get(KEY_URL)
+ val uiState = savedStateHandle.getStateFlow(KEY_URL, "")
+ .map {
+ try {
+ Resource.Success(CommentInfo(CommentsInfo.getInfo(it)))
+ } catch (e: Exception) {
+ Resource.Error(e)
+ }
+ }
+ .flowOn(Dispatchers.IO)
+ .stateIn(viewModelScope, SharingStarted.WhileSubscribed(), Resource.Loading)
- val comments = Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) {
- CommentsSource(serviceId, url, null)
- }.flow
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val comments = uiState
+ .filterIsInstance>()
+ .flatMapLatest {
+ Pager(PagingConfig(pageSize = 20, enablePlaceholders = false)) {
+ CommentsSource(it.data)
+ }.flow
+ }
.cachedIn(viewModelScope)
}
diff --git a/app/src/main/java/org/schabi/newpipe/viewmodels/StreamViewModel.kt b/app/src/main/java/org/schabi/newpipe/viewmodels/StreamViewModel.kt
new file mode 100644
index 000000000..fff8d6b71
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/viewmodels/StreamViewModel.kt
@@ -0,0 +1,26 @@
+package org.schabi.newpipe.viewmodels
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.rx3.await
+import kotlinx.coroutines.rx3.awaitSingleOrNull
+import org.schabi.newpipe.database.stream.model.StreamStateEntity
+import org.schabi.newpipe.extractor.InfoItem
+import org.schabi.newpipe.extractor.stream.StreamInfoItem
+import org.schabi.newpipe.local.history.HistoryRecordManager
+
+class StreamViewModel(application: Application) : AndroidViewModel(application) {
+ private val historyRecordManager = HistoryRecordManager(application)
+
+ suspend fun getStreamState(infoItem: InfoItem): StreamStateEntity? {
+ return historyRecordManager.loadStreamState(infoItem).awaitSingleOrNull()
+ }
+
+ fun markAsWatched(stream: StreamInfoItem) {
+ viewModelScope.launch {
+ historyRecordManager.markAsWatched(stream).await()
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/viewmodels/util/Resource.kt b/app/src/main/java/org/schabi/newpipe/viewmodels/util/Resource.kt
new file mode 100644
index 000000000..38bc81391
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/viewmodels/util/Resource.kt
@@ -0,0 +1,7 @@
+package org.schabi.newpipe.viewmodels.util
+
+sealed class Resource {
+ data object Loading : Resource()
+ class Success(val data: T) : Resource()
+ class Error(val throwable: Throwable) : Resource()
+}
diff --git a/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java b/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java
index f79e1e3a3..91b5ebd07 100644
--- a/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java
+++ b/app/src/main/java/org/schabi/newpipe/views/CollapsibleView.java
@@ -19,6 +19,9 @@
package org.schabi.newpipe.views;
+import static org.schabi.newpipe.MainActivity.DEBUG;
+import static java.lang.annotation.RetentionPolicy.SOURCE;
+
import android.animation.ValueAnimator;
import android.content.Context;
import android.os.Parcelable;
@@ -29,18 +32,15 @@ import android.widget.LinearLayout;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
+import com.evernote.android.state.State;
+import com.livefront.bridge.Bridge;
+
import org.schabi.newpipe.ktx.ViewUtils;
import java.lang.annotation.Retention;
import java.util.ArrayList;
import java.util.List;
-import icepick.Icepick;
-import icepick.State;
-
-import static java.lang.annotation.RetentionPolicy.SOURCE;
-import static org.schabi.newpipe.MainActivity.DEBUG;
-
/**
* A view that can be fully collapsed and expanded.
*/
@@ -207,12 +207,12 @@ public class CollapsibleView extends LinearLayout {
@Nullable
@Override
public Parcelable onSaveInstanceState() {
- return Icepick.saveInstanceState(this, super.onSaveInstanceState());
+ return Bridge.saveInstanceState(this, super.onSaveInstanceState());
}
@Override
public void onRestoreInstanceState(final Parcelable state) {
- super.onRestoreInstanceState(Icepick.restoreInstanceState(this, state));
+ super.onRestoreInstanceState(Bridge.restoreInstanceState(this, state));
ready();
}
diff --git a/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java b/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java
index 690ed4a97..ad9a3b7cd 100644
--- a/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java
+++ b/app/src/main/java/us/shandian/giga/ui/fragment/MissionsFragment.java
@@ -22,6 +22,7 @@ import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AlertDialog;
+import androidx.compose.ui.platform.ComposeView;
import androidx.fragment.app.Fragment;
import androidx.preference.PreferenceManager;
import androidx.recyclerview.widget.GridLayoutManager;
@@ -34,6 +35,7 @@ import org.schabi.newpipe.R;
import org.schabi.newpipe.settings.NewPipeSettings;
import org.schabi.newpipe.streams.io.NoFileManagerSafeGuard;
import org.schabi.newpipe.streams.io.StoredFileHelper;
+import org.schabi.newpipe.ui.emptystate.EmptyStateUtil;
import org.schabi.newpipe.util.FilePickerActivityHelper;
import java.io.File;
@@ -108,7 +110,8 @@ public class MissionsFragment extends Fragment {
mContext.bindService(new Intent(mContext, DownloadManagerService.class), mConnection, Context.BIND_AUTO_CREATE);
// Views
- mEmpty = v.findViewById(R.id.list_empty_view);
+ mEmpty = v.findViewById(R.id.empty_state_view);
+ EmptyStateUtil.setEmptyStateComposable((ComposeView) mEmpty);
mList = v.findViewById(R.id.mission_recycler);
// Init layouts managers
diff --git a/app/src/main/res/layout-land/list_stream_card_item.xml b/app/src/main/res/layout-land/list_stream_card_item.xml
deleted file mode 120000
index 70228ee1d..000000000
--- a/app/src/main/res/layout-land/list_stream_card_item.xml
+++ /dev/null
@@ -1 +0,0 @@
-../layout/list_stream_item.xml
\ No newline at end of file
diff --git a/app/src/main/res/layout-land/list_stream_card_item.xml b/app/src/main/res/layout-land/list_stream_card_item.xml
new file mode 100644
index 000000000..793942568
--- /dev/null
+++ b/app/src/main/res/layout-land/list_stream_card_item.xml
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/activity_about.xml b/app/src/main/res/layout/activity_about.xml
deleted file mode 100644
index 661c4affc..000000000
--- a/app/src/main/res/layout/activity_about.xml
+++ /dev/null
@@ -1,39 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml
deleted file mode 100644
index 5e6e11d00..000000000
--- a/app/src/main/res/layout/fragment_about.xml
+++ /dev/null
@@ -1,148 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_bookmarks.xml b/app/src/main/res/layout/fragment_bookmarks.xml
index 418c11964..9767a1081 100644
--- a/app/src/main/res/layout/fragment_bookmarks.xml
+++ b/app/src/main/res/layout/fragment_bookmarks.xml
@@ -24,15 +24,15 @@
android:visibility="gone"
tools:visibility="visible" />
-
+ tools:visibility="visible"
+ />
-
-
-
-
-
-
-
+ tools:visibility="visible"
+ />
-
+ tools:visibility="visible"
+ />
-
-
-
-
-
-
-
+ tools:visibility="visible"
+ />
-
+ android:layout_marginTop="90dp"
+ />
+
diff --git a/app/src/main/res/layout/fragment_feed.xml b/app/src/main/res/layout/fragment_feed.xml
index de2096605..3c61c824f 100644
--- a/app/src/main/res/layout/fragment_feed.xml
+++ b/app/src/main/res/layout/fragment_feed.xml
@@ -140,15 +140,15 @@
android:visibility="gone"
tools:visibility="visible" />
-
+ tools:visibility="visible"
+ />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_related_items.xml b/app/src/main/res/layout/fragment_related_items.xml
deleted file mode 100644
index 3591cdfd1..000000000
--- a/app/src/main/res/layout/fragment_related_items.xml
+++ /dev/null
@@ -1,70 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/fragment_search.xml b/app/src/main/res/layout/fragment_search.xml
index 15f45fd1a..7566e6c53 100644
--- a/app/src/main/res/layout/fragment_search.xml
+++ b/app/src/main/res/layout/fragment_search.xml
@@ -52,33 +52,14 @@
android:visibility="gone"
tools:visibility="visible" />
-
-
-
-
-
-
-
+ tools:visibility="visible"
+ />
-
+ tools:visibility="visible"
+ />
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/list_empty_view.xml b/app/src/main/res/layout/list_empty_view.xml
deleted file mode 100644
index a25042aed..000000000
--- a/app/src/main/res/layout/list_empty_view.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/list_empty_view_subscriptions.xml b/app/src/main/res/layout/list_empty_view_subscriptions.xml
index 74a5eced4..ad1820199 100644
--- a/app/src/main/res/layout/list_empty_view_subscriptions.xml
+++ b/app/src/main/res/layout/list_empty_view_subscriptions.xml
@@ -1,25 +1,6 @@
-
-
-
-
-
-
+ xmlns:android="http://schemas.android.com/apk/res/android" />
diff --git a/app/src/main/res/layout/missions.xml b/app/src/main/res/layout/missions.xml
index 641e28693..291d19306 100644
--- a/app/src/main/res/layout/missions.xml
+++ b/app/src/main/res/layout/missions.xml
@@ -3,10 +3,11 @@
android:layout_height="match_parent"
android:orientation="vertical">
-
+
-
-
-
-
-
-
-
diff --git a/app/src/main/res/layout/select_channel_fragment.xml b/app/src/main/res/layout/select_channel_fragment.xml
index bd62aefea..6985617fc 100644
--- a/app/src/main/res/layout/select_channel_fragment.xml
+++ b/app/src/main/res/layout/select_channel_fragment.xml
@@ -24,14 +24,11 @@
android:layout_height="wrap_content"
tools:listitem="@layout/select_channel_item" />
-
-
+ android:layout_margin="10dp" />
-
-
+ android:layout_margin="10dp" />
-
-
-
-
-
-
-
+ tools:visibility="gone"
+ />
SHA-1
reCAPTCHA
https://github.com/TeamNewPipe/NewPipe
+ https://github.com/TeamNewPipe/NewPipeExtractor
https://newpipe.net/donate/
https://newpipe.net/
https://newpipe.net/legal/privacy/
https://newpipe.net/FAQ/
+ TeamNewPipe
+ NewPipeExtractor
%1$s/%2$s
YouTube
@string/app_name
diff --git a/app/src/main/res/values/settings_keys.xml b/app/src/main/res/values/settings_keys.xml
index e31cebb92..b0fceb89b 100644
--- a/app/src/main/res/values/settings_keys.xml
+++ b/app/src/main/res/values/settings_keys.xml
@@ -246,6 +246,7 @@
crash_the_app_key
show_error_snackbar_key
create_error_notification_key
+ settings_layout_redesign_key
theme
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index bff35e5d9..1bcb98ca9 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -492,6 +492,7 @@
Crash the app
Show an error snackbar
Create an error notification
+ Enable the Redesigned Settings page
Import
Import from
@@ -855,4 +856,10 @@
Show more
Show less
The settings in the export being imported use a vulnerable format that was deprecated since NewPipe 0.27.0. Make sure the export being imported is from a trusted source, and prefer using only exports obtained from NewPipe 0.27.0 or newer in the future. Support for importing settings in this vulnerable format will soon be removed completely, and then old versions of NewPipe will not be able to import settings of exports from new versions anymore.
+ Next
+ NewPipeExtractor is a library for extracting things from streaming sites. It is a core component of NewPipe, but could be used independently.
+
+ - %d comment
+ - %d comments
+
diff --git a/app/src/main/res/xml/debug_settings.xml b/app/src/main/res/xml/debug_settings.xml
index d97c5aa1a..5b6909892 100644
--- a/app/src/main/res/xml/debug_settings.xml
+++ b/app/src/main/res/xml/debug_settings.xml
@@ -64,4 +64,11 @@
android:title="@string/create_error_notification"
app:singleLineTitle="false"
app:iconSpaceReserved="false" />
+
+
diff --git a/app/src/test/java/org/schabi/newpipe/error/ErrorActivityTest.java b/app/src/test/java/org/schabi/newpipe/error/ErrorActivityTest.java
deleted file mode 100644
index f77c7b268..000000000
--- a/app/src/test/java/org/schabi/newpipe/error/ErrorActivityTest.java
+++ /dev/null
@@ -1,35 +0,0 @@
-package org.schabi.newpipe.error;
-
-import android.app.Activity;
-
-import org.junit.Test;
-import org.schabi.newpipe.MainActivity;
-import org.schabi.newpipe.RouterActivity;
-import org.schabi.newpipe.fragments.detail.VideoDetailFragment;
-
-import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNull;
-
-/**
- * Unit tests for {@link ErrorActivity}.
- */
-public class ErrorActivityTest {
- @Test
- public void getReturnActivity() {
- Class extends Activity> returnActivity;
- returnActivity = ErrorActivity.getReturnActivity(MainActivity.class);
- assertEquals(MainActivity.class, returnActivity);
-
- returnActivity = ErrorActivity.getReturnActivity(RouterActivity.class);
- assertEquals(RouterActivity.class, returnActivity);
-
- returnActivity = ErrorActivity.getReturnActivity(null);
- assertNull(returnActivity);
-
- returnActivity = ErrorActivity.getReturnActivity(Integer.class);
- assertEquals(MainActivity.class, returnActivity);
-
- returnActivity = ErrorActivity.getReturnActivity(VideoDetailFragment.class);
- assertEquals(MainActivity.class, returnActivity);
- }
-}
diff --git a/build.gradle b/build.gradle
index 1acfb6f4a..f3772ac87 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,14 +1,16 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
- ext.kotlin_version = '2.0.0'
repositories {
google()
mavenCentral()
+ maven { url "https://plugins.gradle.org/m2/" }
}
dependencies {
- classpath 'com.android.tools.build:gradle:8.2.0'
- classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
+ classpath libs.android.tools.build.gradle
+ classpath libs.kotlin.gradle.plugin
+ classpath libs.hilt.android.gradle.plugin
+ classpath libs.aboutlibraries.plugin
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
diff --git a/doc/README.asm.md b/doc/README.asm.md
index 2a7c10f51..370319c15 100644
--- a/doc/README.asm.md
+++ b/doc/README.asm.md
@@ -106,7 +106,7 @@ NewPipe এ আপুনি ব্যৱহাৰ কৰা সেৱাৰ অ
## অৱদান
-আপোনাৰ ধাৰণা, অনুবাদ, ডিজাইন পৰিবৰ্তন, ক'ড পৰিষ্কাৰ কৰা, বা আনকি ডাঙৰ ক'ড পৰিৱৰ্তন হওক, সহায় সদায় আদৰণীয়। প্ৰতিটো অৱদানৰ লগে লগে এপটো ভাল হৈ পৰে, যিমানেই ডাঙৰ বা সৰু নহওক কিয়! যদি আপুনি জড়িত হ'ব বিচাৰে তেন্তে চাওক আমাৰ [অবদানৰ টোকা সমূহ](.github/CONTRIBUTING.md).
+আপোনাৰ ধাৰণা, অনুবাদ, ডিজাইন পৰিবৰ্তন, ক'ড পৰিষ্কাৰ কৰা, বা আনকি ডাঙৰ ক'ড পৰিৱৰ্তন হওক, সহায় সদায় আদৰণীয়। প্ৰতিটো অৱদানৰ লগে লগে এপটো ভাল হৈ পৰে, যিমানেই ডাঙৰ বা সৰু নহওক কিয়! যদি আপুনি জড়িত হ'ব বিচাৰে তেন্তে চাওক আমাৰ [অবদানৰ টোকা সমূহ](/.github/CONTRIBUTING.md).
## অনুদান
diff --git a/doc/README.de.md b/doc/README.de.md
index e269da05c..be7744332 100644
--- a/doc/README.de.md
+++ b/doc/README.de.md
@@ -126,7 +126,7 @@ So eine Aktion wird nicht unterstützt und du solltest sie nur in Erwägung zieh
## Beitrag
Egal ob du neue Ideen, Übersetzungen, Designvorschläge, kleine Code-Bereinigungen, oder sogar große Code-Verbesserungen hast, jegliche Unterstützung ist immer gern gesehen.
Die App wird mit _jedem_ Beitrag besser und besser - egal wie viel Arbeit in ihn gesteckt wird!
-Wenn du dich einbringen willst, sehe dir die [Beitragshinweise](.github/CONTRIBUTING.md) an.
+Wenn du dich einbringen willst, sieh dir die [Beitragshinweise](/.github/CONTRIBUTING.md) an.
diff --git a/doc/README.fr.md b/doc/README.fr.md
index 7d4673b69..864cc927a 100644
--- a/doc/README.fr.md
+++ b/doc/README.fr.md
@@ -109,7 +109,7 @@ Entre temps, si vous voulez changer de source pour une raison quelconque (par ex
## Contribuer
-Que vous ayez des idées, des traductions, des changements de design, du nettoyage de code, ou encore un changement de code majeur, toute aide est la bienvenue. L'app s'améliore un peu plus à chaque contribution, peu importe qu'elle soit grosse ou petite ! Si vous aimeriez être impliqué, jetez un coup d'oeil à nos [notes pour contribuer](.github/CONTRIBUTING.md).
+Que vous ayez des idées, des traductions, des changements de design, du nettoyage de code, ou encore un changement de code majeur, toute aide est la bienvenue. L'app s'améliore un peu plus à chaque contribution, peu importe qu'elle soit grosse ou petite ! Si vous aimeriez être impliqué, jetez un coup d'oeil à nos [notes pour contribuer](/.github/CONTRIBUTING.md).
diff --git a/doc/README.hi.md b/doc/README.hi.md
index 282e75420..d503f43a5 100644
--- a/doc/README.hi.md
+++ b/doc/README.hi.md
@@ -105,7 +105,7 @@ NewPipe पर कई सेवाएँ उपलब्ध हैं। हम
चाहे आप अपने विचार जोड़ना चाहे, या अनुवाद, डिज़ाइन में बदलाव, कोड में सफ़ाई, या कोड में भारी बदलाव, सहायता ज़रूर करें।
जितने योगदान हो, ऐप उतनी ही बेहतर होती जाती है!
-अगर आप योगदान करना चाहते हैं, हमारे [योगदान के दिशानिर्देश](.github/CONTRIBUTING.md) देखें।
+अगर आप योगदान करना चाहते हैं, हमारे [योगदान के दिशानिर्देश](/.github/CONTRIBUTING.md) देखें।
diff --git a/doc/README.it.md b/doc/README.it.md
index 55ae12380..8bf1eb380 100644
--- a/doc/README.it.md
+++ b/doc/README.it.md
@@ -107,7 +107,7 @@ Nel frattempo, se vuoi cambiare fonte per la stessa ragione (ad es. la funzional
## Contribuire
-Se hai idee, traduzioni, cambiamenti di *design*, pulizia di codice, o addirittura grossi cambiamenti di codice, l'aiuto è sempre apprezzato. L'app diventa sempre meglio con ogni contribuzione, non importa quanto grande o piccola essa sia! Se ti piacerebbe essere parte del progetto, vedi le nostre [note di contribuzione](.github/CONTRIBUTING.md).
+Se hai idee, traduzioni, cambiamenti di *design*, pulizia di codice, o addirittura grossi cambiamenti di codice, l'aiuto è sempre apprezzato. L'app diventa sempre meglio con ogni contribuzione, non importa quanto grande o piccola essa sia! Se ti piacerebbe essere parte del progetto, vedi le nostre [note di contribuzione](/.github/CONTRIBUTING.md).
diff --git a/doc/README.pa.md b/doc/README.pa.md
index 321e6b7d0..0ad5e0625 100644
--- a/doc/README.pa.md
+++ b/doc/README.pa.md
@@ -105,7 +105,7 @@ NewPipe ਤੁਹਾਡੇ ਦੁਆਰਾ ਵਰਤੀ ਜਾ ਰਹੀ ਸੇ
ਨੋਟ: ਜਦੋਂ ਤੁਸੀਂ ਅਧਿਕਾਰਤ ਐਪ ਵਿੱਚ ਇੱਕ ਡੇਟਾਬੇਸ ਨੂੰ ਆਯਾਤ ਕਰ ਰਹੇ ਹੋ, ਤਾਂ ਹਮੇਸ਼ਾਂ ਯਕੀਨੀ ਬਣਾਓ ਕਿ ਇਹ ਉਹੀ ਹੈ ਜੋ ਤੁਸੀਂ ਅਧਿਕਾਰਤ ਐਪ ਤੋਂ ਨਿਰਯਾਤ ਕੀਤਾ ਹੈ। ਜੇਕਰ ਤੁਸੀਂ ਅਧਿਕਾਰਤ ਐਪ ਤੋਂ ਇਲਾਵਾ ਕਿਸੇ ਏਪੀਕੇ ਤੋਂ ਨਿਰਯਾਤ ਕੀਤੇ ਡੇਟਾਬੇਸ ਨੂੰ ਆਯਾਤ ਕਰਦੇ ਹੋ, ਤਾਂ ਇਹ ਚੀਜ਼ਾਂ ਨੂੰ ਤੋੜ ਸਕਦਾ ਹੈ। ਅਜਿਹੀ ਕਾਰਵਾਈ ਅਸਮਰਥਿਤ ਹੈ, ਅਤੇ ਤੁਹਾਨੂੰ ਅਜਿਹਾ ਉਦੋਂ ਹੀ ਕਰਨਾ ਚਾਹੀਦਾ ਹੈ ਜਦੋਂ ਤੁਹਾਨੂੰ ਪੂਰੀ ਤਰ੍ਹਾਂ ਯਕੀਨ ਹੋਵੇ ਕਿ ਤੁਸੀਂ ਜਾਣਦੇ ਹੋ ਕਿ ਤੁਸੀਂ ਕੀ ਕਰ ਰਹੇ ਹੋ।
## ਯੋਗਦਾਨ
-ਭਾਵੇਂ ਤੁਹਾਡੇ ਕੋਲ ਵਿਚਾਰ, ਅਨੁਵਾਦ, ਡਿਜ਼ਾਈਨ ਤਬਦੀਲੀਆਂ, ਕੋਡ ਦੀ ਸਫਾਈ, ਜਾਂ ਇੱਥੋਂ ਤੱਕ ਕਿ ਵੱਡੀਆਂ ਕੋਡ ਤਬਦੀਲੀਆਂ ਹੋਣ, ਮਦਦ ਦਾ ਹਮੇਸ਼ਾ ਸਵਾਗਤ ਹੈ। ਐਪ ਹਰੇਕ ਯੋਗਦਾਨ ਦੇ ਨਾਲ ਬਿਹਤਰ ਅਤੇ ਬਿਹਤਰ ਹੋ ਜਾਂਦੀ ਹੈ, ਚਾਹੇ ਉਹ ਕਿੰਨਾ ਵੱਡਾ ਜਾਂ ਛੋਟਾ ਹੋਵੇ! ਜੇਕਰ ਤੁਸੀਂ ਸ਼ਾਮਲ ਹੋਣਾ ਚਾਹੁੰਦੇ ਹੋ, ਤਾਂ ਸਾਡੀ ਜਾਂਚ ਕਰੋ [contribution notes](.github/CONTRIBUTING.md).
+ਭਾਵੇਂ ਤੁਹਾਡੇ ਕੋਲ ਵਿਚਾਰ, ਅਨੁਵਾਦ, ਡਿਜ਼ਾਈਨ ਤਬਦੀਲੀਆਂ, ਕੋਡ ਦੀ ਸਫਾਈ, ਜਾਂ ਇੱਥੋਂ ਤੱਕ ਕਿ ਵੱਡੀਆਂ ਕੋਡ ਤਬਦੀਲੀਆਂ ਹੋਣ, ਮਦਦ ਦਾ ਹਮੇਸ਼ਾ ਸਵਾਗਤ ਹੈ। ਐਪ ਹਰੇਕ ਯੋਗਦਾਨ ਦੇ ਨਾਲ ਬਿਹਤਰ ਅਤੇ ਬਿਹਤਰ ਹੋ ਜਾਂਦੀ ਹੈ, ਚਾਹੇ ਉਹ ਕਿੰਨਾ ਵੱਡਾ ਜਾਂ ਛੋਟਾ ਹੋਵੇ! ਜੇਕਰ ਤੁਸੀਂ ਸ਼ਾਮਲ ਹੋਣਾ ਚਾਹੁੰਦੇ ਹੋ, ਤਾਂ ਸਾਡੀ ਜਾਂਚ ਕਰੋ [contribution notes](/.github/CONTRIBUTING.md).
diff --git a/doc/README.pt_BR.md b/doc/README.pt_BR.md
index 01fa718ad..73489bb41 100644
--- a/doc/README.pt_BR.md
+++ b/doc/README.pt_BR.md
@@ -1,3 +1,6 @@
+Nós estamos planejando reescrever grandes pedaços do código base, para gerar um novo, moderno e estável NewPipe !
+Por favor, não abra solicitações de pull para novos recursos por enquanto, apenas correções de bugs serão aceitas.
+
NewPipe
@@ -13,16 +16,16 @@
-Screenshots • Descrição • Características • Atualizações • Contribuição • Doar • Licença
+Screenshots • Serviços Suportados • Descrição • Recursos • Instalação e atualizações • Contribuições • Doar • Licença
Site • Blog • FAQ • Press
-*Read this document in other languages: [Deutsch](README.de.md), [English](../README.md), [Español](README.es.md), [Français](README.fr.md), [हिन्दी](README.hi.md), [Italiano](README.it.md), [한국어](README.ko.md), [Português Brasil](README.pt_BR.md), [Polski](README.pl.md), [ਪੰਜਾਬੀ ](README.pa.md), [日本語](README.ja.md), [Română](README.ro.md), [Soomaali](README.so.md), [Türkçe](README.tr.md), [正體中文](README.zh_TW.md), [অসমীয়া](README.asm.md), [うちなーぐち](README.ryu.md), [Српски](README.sr.md)*
+*Leia esse documento em outras línguas: [Deutsch](README.de.md), [English](../README.md), [Español](README.es.md), [Français](README.fr.md), [हिन्दी](README.hi.md), [Italiano](README.it.md), [한국어](README.ko.md), [Português Brasil](README.pt_BR.md), [Polski](README.pl.md), [ਪੰਜਾਬੀ ](README.pa.md), [日本語](README.ja.md), [Română](README.ro.md), [Soomaali](README.so.md), [Türkçe](README.tr.md), [正體中文](README.zh_TW.md), [অসমীয়া](README.asm.md), [うちなーぐち](README.ryu.md), [Српски](README.sr.md)*
> [!warning]
-> ESTA É UMA VERSÃO BETA, PORTANTO, VOCÊ PODE ENCONTRAR BUGS. ENCONTROU ALGUM, ABRA UM ISSUE ATRAVÉS DO NOSSO REPOSITÓRIO GITHUB.
+> ESTA É UMA VERSÃO BETA, PORTANTO, VOCÊ PODE ENCONTRAR BUGS. ENCONTROU ALGUM, ABRA UM ISSUE ATRAVÉS DO NOSSO REPOSITÓRIO GITHUB PREENCHENDO O MODELO.
>
-> COLOCAR NEWPIPE OU QUALQUER FORK DELE NA GOOGLE PLAY STORE VIOLA SEUS TERMOS E CONDIÇÕES.
+> COLOCAR NEWPIPE, OU QUALQUER FORK DELE, NA GOOGLE PLAY STORE VIOLA SEUS TERMOS E CONDIÇÕES.
## Screenshots
@@ -39,83 +42,84 @@
[ ](../fastlane/metadata/android/en-US/images/tenInchScreenshots/09.png)
[ ](../fastlane/metadata/android/en-US/images/tenInchScreenshots/10.png)
-## Descrição
-
-O NewPipe não usa nenhuma biblioteca de framework do Google, nem a API do YouTube. Os sites são apenas analisados para obter informações necessárias, para que este aplicativo possa ser usado em dispositivos sem os serviços do Google instalados. Além disso, você não precisa de uma conta no YouTube para usar o NewPipe, que é um software livre com copyleft.
-
-### Características
-
-* Procurar vídeos
-* Exibir informações gerais sobre vídeos
-* Assista aos vídeos do YouTube
-* Ouça vídeos do YouTube
-* Modo popup (player flutuante)
-* Selecione o player para assistir streaming
-* Baixar vídeos
-* Baixar somente áudio
-* Abrir vídeo no Kodi
-* Mostrar vídeos próximos/relacionados
-* Pesquise no YouTube em um idioma específico
-* Assistir/Bloquear material restrito
-* Exibir informações gerais sobre canais
-* Pesquisar canais
-* Assista a vídeos de um canal
-* Suporte Orbot/Tor (ainda não diretamente)
-* Suporte 1080p/2K/4K
-* Ver histórico
-* Inscreva-se nos canais
-* Procurar histórico
-* Porcurar/Assistir playlists
-* Assistir playlists em fila
-* Vídeos em fila
-* Playlists Local
-* Legenda
-* Suporte a live
-* Mostrar comentários
-
### Serviços Suportados
-O NewPipe suporta vários serviços. Nosso [documentação](https://teamnewpipe.github.io/documentation/) fornecer mais informações sobre como um novo serviço pode ser adicionado ao aplicativo e ao extrator. Por favor, entre em contato conosco se você pretende adicionar um novo. Atualmente, os serviços suportados são:
+Atualmente, os serviços suportados são:
-* YouTube
-* SoundCloud \[beta\]
-* media.ccc.de \[beta\]
-* PeerTube instances \[beta\]
-* Bandcamp \[beta\]
+* YouTube ([site](https://www.youtube.com/)) e YouTube Music ([site](https://music.youtube.com/)) ([wiki](https://en.wikipedia.org/wiki/YouTube))
+* PeerTube ([site](https://joinpeertube.org/)) e todas suas instâncias (abra o site para saber o que isso significa!) ([wiki](https://en.wikipedia.org/wiki/PeerTube))
+* Bandcamp ([site](https://bandcamp.com/)) ([wiki](https://en.wikipedia.org/wiki/Bandcamp))
+* SoundCloud ([site](https://soundcloud.com/)) ([wiki](https://en.wikipedia.org/wiki/SoundCloud))
+* media.ccc.de ([site](https://media.ccc.de/)) ([wiki](https://en.wikipedia.org/wiki/Chaos_Computer_Club))
-## Atualizações
-Quando uma alteração no código NewPipe (devido à adição de recursos ou fixação de bugs), eventualmente ocorrerá uma versão. Estes estão no formato x.xx.x . A fim de obter esta nova versão, você pode:
- 1. Construa um APK de depuração você mesmo. Esta é a maneira mais rápida de obter novos recursos em seu dispositivo, mas é muito mais complicado, por isso recomendamos usar um dos outros métodos.
- 2. Adicione nosso repo personalizado ao F-Droid e instale-o a partir daí assim que publicarmos um lançamento. As instruções estão aqui.: https://newpipe.net/FAQ/tutorials/install-add-fdroid-repo/
- 3. Baixe o APK do [GitHub Releases](https://github.com/TeamNewPipe/NewPipe/releases) e instalá-lo assim que publicarmos um lançamento.
- 4. Atualização via F-droid. Este é o método mais lento para obter atualizações, pois o F-Droid deve reconhecer alterações, construir o próprio APK, assiná-lo e, em seguida, enviar a atualização para os usuários.
+Como você pode ver, o NewPipe suporta múltiplos serviços de vídeo e áudio. Embora tenha começado com o YouTube, outras pessoas adicionaram mais serviços ao longo dos anos, tornando o NewPipe cada vez mais versátil!
-Recomendamos o método 2 para a maioria dos usuários. Os APKs instalados usando o método 2 ou 3 são compatíveis entre si, mas não com aqueles instalados usando o método 4. Isso se deve à mesma chave de assinatura (nossa) sendo usada para 2 e 3, mas uma chave de assinatura diferente (F-Droid's) está sendo usada para 4. Construir um APK depuração usando o método 1 exclui totalmente uma chave. Assinar chaves ajudam a garantir que um usuário não seja enganado para instalar uma atualização maliciosa em um aplicativo.
+Parcialmente devido as circustâncias e a sua popularidade, o YouTube tem o melhor suporte em relação a esses serviços. Se você usa ou é familarizado com qualquer um desses serviços, por favor ajude-nos a melhorar o suporte para eles! Estamos procurando mantenedores para o SoundCloud e o PeerTube.
+
+Se você pretende adicionar um novo serviço, por favor entre em contato conosco primeiro! Nossa [documentação](https://teamnewpipe.github.io/documentation/) traz mais informações em como um novo serviço pode ser adicionado ao aplicativo e no [NewPipe Extractor](https://github.com/TeamNewPipe/NewPipeExtractor).
+
+## Descrição
+
+NewPipe funciona buscando os dados necessários da API oficial (ex. PeerTube) ou do serviço que você está usando. Se a API oficial é restrita (ex. YouTube) para nossos propósitos, ou é proprietária, o aplicativo analisa o site ou usa uma API interna. Isso significa que não é preciso ter uma conta de qualquer serviço para usar o NewPipe.
+
+Também, desde que somos um software livre e de código aberto, nem o aplicativo e nem o Extractor usa qualquer biblioteca ou framework proprietário, como o Google Play Services. Isso significa que você pode usar o NewPipe em dispositivos ou ROMs customizadas em que não tem os aplicativos do Google instalados.
+
+### Recursos
+
+* Assistir vídeos em resoluções de até 4K
+* Escutar o áudio em segundo plano, carregando apenas o fluxo de áudio para salvar dados
+* Modo popup (player flutuante, aka Picture-in-Picture)
+* Assista a transmissões ao vivo
+* Mostrar/esconder legendas/closed captions
+* Buscar vídeos e áudios (no YouTube, você pode especificar o conteúdo da linguagem também)
+* Enfileirar vídeos (e opcionalmente salvar eles como playlists locais)
+* Mostrar/esconder informações gerais sobre os vídeos (como descrições e tags)
+* Mostrar/esconder vídeos próximos/relacionados
+* Mostrar/esconder comentários
+* Buscar vídeos, áudios, canais, playlists e álbuns
+* Navegar vídeos e áudios dentro de um canal
+* Inscrever-se a canais (sim, mesmo se não estiver logado a qualquer conta!)
+* Receba notificações sobre novos vídeos de canais em que você está inscrito
+* Crie e edite grupos de canais (para facilitar a navegação e o gerenciamento)
+* Navege feeds de vídeo gerados a partir dos seus grupos de canais
+* Veja e pesquise seu histórico de vídeos
+* Pesquise e assista playlists (Eles são playlists remotas, o que significa que eles serão obtidos do serviço que você está navegando)
+* Crie e edite playlists locais (Eles são criados e salvos no aplicativo, e não são relacionados com nenhum serviço)
+* Baixe vídeos/áudios/legendas (closed captions)
+* Abra no Kodi
+* Assista/Bloqueie material restrito
+
+## Instalação e atualizações
+Você pode instalar NewPipe com um dos seguintes métodos:
+ 1. Adicione nosso repo personalizado ao F-Droid e instale-o a partir daí. As instruções estão aqui: https://newpipe.net/FAQ/tutorials/install-add-fdroid-repo/
+ 2. Baixe o APK aqui no [GitHub Releases](https://github.com/TeamNewPipe/NewPipe/releases) e instalá-lo assim que publicarmos um lançamento.
+ 3. Atualização via F-droid. Este é o método mais lento para obter atualizações, pois o F-Droid deve reconhecer alterações, construir o próprio APK, assiná-lo e, em seguida, enviar a atualização para os usuários.
+ 4. Construa um APK de depuração você mesmo. Esta é a maneira mais rápida de obter novos recursos em seu dispositivo, mas é muito mais complicado, por isso recomendamos usar um dos outros métodos.
+ 5. Se você estiver interessado em um recurso específico ou uma correção de bug fornecido em uma solicitação de Pull nesse repositório, pode instalar o APK a partir de lá. Leia a descrição da solicitação para instruções. A grande vantagem dos APKs específicos de S.P é que eles são instalados lado a lado com o aplicativo oficial, então você não precisa se preocupar em perder seus dados ou estragar alguma coisa.
+
+Recomendamos o método 1 para a maioria dos usuários. Os APKs instalados usando o método 1 ou 2 são compatíveis entre si (o que significa que se você instalou o NewPipe usando o método 1 ou 2, você também pode atualizar o NewPipe usando o outro), mas não com aqueles instalados usando o método 3. Isso se deve à mesma chave de assinatura (nossa) sendo usada para 1 e 2, mas uma chave de assinatura diferente (F-Droid's) está sendo usada para 3. Construir um APK depuração usando o método 4 exclui totalmente uma chave. Assinar chaves ajudam a garantir que um usuário não seja enganado para instalar uma atualização maliciosa em um aplicativo. Ao usar o método 5, cada APK é assinado com uma chave aleatória diferente fornecida pelo GitHub Actions, portanto você não pode nem mesmo atualizá-lo. Você terá que fazer backup e restaurar os dados do aplicativo sempre que desejar usar um novo APK.
Enquanto isso, se você quiser trocar de fontes por algum motivo (por exemplo, a funcionalidade principal do NewPipe foi quebrada e o F-Droid ainda não tem a atualização), recomendamos seguir este procedimento:
-1. Faça backup de seus dados através de Configurações > Conteúdo > Exportar Base de Dados para que você mantenha seu histórico, inscrições e playlists
+1. Faça backup de seus dados através de Configurações > Backup e Restauração > Exportar Base de Dados para que você mantenha seu histórico, inscrições e playlists
2. Desinstale o NewPipe
3. Baixe o APK da nova fonte e instale-o
-4. Importe os dados da etapa 1 via Configurações > Conteúdo > Inportar Banco de Dados
+4. Importe os dados da etapa 1 via Configurações > Backup e Restauração > Importar Base de Dados
-## Contribuição
-Se você tem ideias, traduções, alterações de design, limpeza de códigos ou mudanças reais de código, a ajuda é sempre bem-vinda.
-Quanto mais for feito, melhor fica!
-
-Se você quiser se envolver, verifique nossa [notas de contribuição](../.github/CONTRIBUTING.md).
+## Contribuições
+Se você tem ideias, traduções, alterações de design, limpeza de códigos ou mudanças reais de código, a ajuda é sempre bem-vinda. O aplicativo fica cada vez melhor a cada contribuição, não importa quão grande ou pequena! Se você quiser se envolver, verifique nossas [notas de contribuição](/.github/CONTRIBUTING.md).
-
+
## Doar
-Se você gosta de NewPipe, ficaríamos felizes com uma doação. Você pode enviar bitcoin ou doar via Bountysource ou Liberapay. Para obter mais informações sobre como doar para a NewPipe, visite nosso [site](https://newpipe.net/donate).
+Se você gosta do NewPipe, pode enivar uma doação. Nós preferimos Liberapay, pois é de código aberto e sem fins lucrativos. Para mais informações sobre como doar para o NewPipe, visite nosso [site](https://newpipe.net/donate).
diff --git a/doc/README.ru.md b/doc/README.ru.md
index 35058c981..35867b8bf 100644
--- a/doc/README.ru.md
+++ b/doc/README.ru.md
@@ -106,7 +106,7 @@ NewPipe работает, извлекая необходимые данные
Примечание: когда вы импортируете базу данных в официальное приложение, убедитесь, что это именно та база данных, которую вы экспортировали _из_ официального приложения. Если вы импортируете базу данных, экспортированную из APK, отличного от официального приложения, это может привести к ошибке. Такое действие не поддерживается, и вы должны делать его только тогда, когда абсолютно уверены, что знаете, что делаете.
## Участие
-Если у вас есть идеи, переводы, изменения дизайна, очистка кода или даже серьезные изменения кода, помощь всегда приветствуется. Приложение становится всё лучше и лучше с каждым вкладом, независимо от того, большой он или маленький! Если вы хотите принять участие, ознакомьтесь с нашими [заметками об участии](.github/CONTRIBUTING.md).
+Если у вас есть идеи, переводы, изменения дизайна, очистка кода или даже серьезные изменения кода, помощь всегда приветствуется. Приложение становится всё лучше и лучше с каждым вкладом, независимо от того, большой он или маленький! Если вы хотите принять участие, ознакомьтесь с нашими [заметками об участии](/.github/CONTRIBUTING.md).
diff --git a/doc/README.sr.md b/doc/README.sr.md
index 7f0ee65b7..60a21ce69 100644
--- a/doc/README.sr.md
+++ b/doc/README.sr.md
@@ -104,7 +104,7 @@ NewPipe ради тако што преузима потребне податк
Напомена: када увозите базу података у званичну апликацију, увек се уверите да је то она коју сте извезли _из_ званичне апликације. Ако увезете базу података извезену из APK-а, који није званична апликација, то може покварити ствари. Таква радња није подржана и требало би да то урадите само када сте потпуно сигурни да знате шта радите.
## Допринос
-Без обзира да ли имате идеје, преводе, промене дизајна, чишћење кода или чак велике промене кода, помоћ је увек добродошла. Апликација постаје све боља и боља са сваким доприносом, без обзира колико је он велики или мали! Ако желите да се укључите, погледајте наше [напомене о доприносима](.github/CONTRIBUTING.md).
+Без обзира да ли имате идеје, преводе, промене дизајна, чишћење кода или чак велике промене кода, помоћ је увек добродошла. Апликација постаје све боља и боља са сваким доприносом, без обзира колико је он велики или мали! Ако желите да се укључите, погледајте наше [напомене о доприносима](/.github/CONTRIBUTING.md).
diff --git a/fastlane/metadata/android/ar/changelogs/998.txt b/fastlane/metadata/android/ar/changelogs/998.txt
new file mode 100644
index 000000000..562f16944
--- /dev/null
+++ b/fastlane/metadata/android/ar/changelogs/998.txt
@@ -0,0 +1 @@
+تم إصلاح YouTube الذي لا يقوم بتشغيل أي دفق
diff --git a/fastlane/metadata/android/ar/changelogs/999.txt b/fastlane/metadata/android/ar/changelogs/999.txt
new file mode 100644
index 000000000..562f16944
--- /dev/null
+++ b/fastlane/metadata/android/ar/changelogs/999.txt
@@ -0,0 +1 @@
+تم إصلاح YouTube الذي لا يقوم بتشغيل أي دفق
diff --git a/fastlane/metadata/android/az/changelogs/998.txt b/fastlane/metadata/android/az/changelogs/998.txt
new file mode 100644
index 000000000..16a2e1013
--- /dev/null
+++ b/fastlane/metadata/android/az/changelogs/998.txt
@@ -0,0 +1 @@
+YouTube-un heç bir yayım oynatmaması düzəldildi
diff --git a/fastlane/metadata/android/az/changelogs/999.txt b/fastlane/metadata/android/az/changelogs/999.txt
new file mode 100644
index 000000000..16a2e1013
--- /dev/null
+++ b/fastlane/metadata/android/az/changelogs/999.txt
@@ -0,0 +1 @@
+YouTube-un heç bir yayım oynatmaması düzəldildi
diff --git a/fastlane/metadata/android/cs/changelogs/998.txt b/fastlane/metadata/android/cs/changelogs/998.txt
new file mode 100644
index 000000000..7035a1112
--- /dev/null
+++ b/fastlane/metadata/android/cs/changelogs/998.txt
@@ -0,0 +1 @@
+Opraveno nepřehrávání jakéhokoli streamu ve službě YouTube
diff --git a/fastlane/metadata/android/cs/changelogs/999.txt b/fastlane/metadata/android/cs/changelogs/999.txt
new file mode 100644
index 000000000..7035a1112
--- /dev/null
+++ b/fastlane/metadata/android/cs/changelogs/999.txt
@@ -0,0 +1 @@
+Opraveno nepřehrávání jakéhokoli streamu ve službě YouTube
diff --git a/fastlane/metadata/android/de/changelogs/998.txt b/fastlane/metadata/android/de/changelogs/998.txt
new file mode 100644
index 000000000..43623578f
--- /dev/null
+++ b/fastlane/metadata/android/de/changelogs/998.txt
@@ -0,0 +1 @@
+Behoben, dass YouTube keinen Stream abspielte
diff --git a/fastlane/metadata/android/de/changelogs/999.txt b/fastlane/metadata/android/de/changelogs/999.txt
new file mode 100644
index 000000000..43623578f
--- /dev/null
+++ b/fastlane/metadata/android/de/changelogs/999.txt
@@ -0,0 +1 @@
+Behoben, dass YouTube keinen Stream abspielte
diff --git a/fastlane/metadata/android/en-US/changelogs/998.txt b/fastlane/metadata/android/en-US/changelogs/998.txt
new file mode 100644
index 000000000..468df0204
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/998.txt
@@ -0,0 +1,4 @@
+Fixed YouTube not playing any stream because of HTTP 403 errors.
+
+Occasional HTTP 403 errors in the middle of a YouTube video are not fixed yet.
+That issue will be addressed in another hotfix release as soon as possible.
diff --git a/fastlane/metadata/android/en-US/changelogs/999.txt b/fastlane/metadata/android/en-US/changelogs/999.txt
new file mode 100644
index 000000000..c089ed197
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/999.txt
@@ -0,0 +1,12 @@
+This hotfix release fixes HTTP 403 errors in the middle of YouTube videos.
+
+New
+• [SoundCloud] Add support for on.soundcloud.com URLs
+
+Improved
+• [Bandcamp] Show additional info in radio kiosk
+
+Fixed
+• [YouTube] Fix occasional HTTP 403 errors at the beginning or in the middle of videos
+• [YouTube] Extract avatar and banner from more channel header types
+• [Bandcamp] Fix various bugs and always use HTTPS
diff --git a/fastlane/metadata/android/es/changelogs/998.txt b/fastlane/metadata/android/es/changelogs/998.txt
new file mode 100644
index 000000000..80b4efa55
--- /dev/null
+++ b/fastlane/metadata/android/es/changelogs/998.txt
@@ -0,0 +1 @@
+Arreglo en YouTube no reproduciendo flujos
diff --git a/fastlane/metadata/android/es/changelogs/999.txt b/fastlane/metadata/android/es/changelogs/999.txt
new file mode 100644
index 000000000..80b4efa55
--- /dev/null
+++ b/fastlane/metadata/android/es/changelogs/999.txt
@@ -0,0 +1 @@
+Arreglo en YouTube no reproduciendo flujos
diff --git a/fastlane/metadata/android/fa/changelogs/998.txt b/fastlane/metadata/android/fa/changelogs/998.txt
new file mode 100644
index 000000000..ba5413d49
--- /dev/null
+++ b/fastlane/metadata/android/fa/changelogs/998.txt
@@ -0,0 +1 @@
+مشکل عدم نمایش پخشزنده برطرف شد
diff --git a/fastlane/metadata/android/fa/changelogs/999.txt b/fastlane/metadata/android/fa/changelogs/999.txt
new file mode 100644
index 000000000..ba5413d49
--- /dev/null
+++ b/fastlane/metadata/android/fa/changelogs/999.txt
@@ -0,0 +1 @@
+مشکل عدم نمایش پخشزنده برطرف شد
diff --git a/fastlane/metadata/android/fr/changelogs/998.txt b/fastlane/metadata/android/fr/changelogs/998.txt
new file mode 100644
index 000000000..3ad3bf279
--- /dev/null
+++ b/fastlane/metadata/android/fr/changelogs/998.txt
@@ -0,0 +1 @@
+Correction de YouTube qui ne lisait aucun média
diff --git a/fastlane/metadata/android/fr/changelogs/999.txt b/fastlane/metadata/android/fr/changelogs/999.txt
new file mode 100644
index 000000000..3ad3bf279
--- /dev/null
+++ b/fastlane/metadata/android/fr/changelogs/999.txt
@@ -0,0 +1 @@
+Correction de YouTube qui ne lisait aucun média
diff --git a/fastlane/metadata/android/he/changelogs/998.txt b/fastlane/metadata/android/he/changelogs/998.txt
new file mode 100644
index 000000000..50731171e
--- /dev/null
+++ b/fastlane/metadata/android/he/changelogs/998.txt
@@ -0,0 +1 @@
+תוקנה התקלה ש־YouTube לא מנגן אף תזרים
diff --git a/fastlane/metadata/android/he/changelogs/999.txt b/fastlane/metadata/android/he/changelogs/999.txt
new file mode 100644
index 000000000..50731171e
--- /dev/null
+++ b/fastlane/metadata/android/he/changelogs/999.txt
@@ -0,0 +1 @@
+תוקנה התקלה ש־YouTube לא מנגן אף תזרים
diff --git a/fastlane/metadata/android/hi/changelogs/998.txt b/fastlane/metadata/android/hi/changelogs/998.txt
new file mode 100644
index 000000000..071ab64e3
--- /dev/null
+++ b/fastlane/metadata/android/hi/changelogs/998.txt
@@ -0,0 +1 @@
+फिक्स्ड YouTube कोई स्ट्रीम नहीं चला रहा है
diff --git a/fastlane/metadata/android/hi/changelogs/999.txt b/fastlane/metadata/android/hi/changelogs/999.txt
new file mode 100644
index 000000000..071ab64e3
--- /dev/null
+++ b/fastlane/metadata/android/hi/changelogs/999.txt
@@ -0,0 +1 @@
+फिक्स्ड YouTube कोई स्ट्रीम नहीं चला रहा है
diff --git a/fastlane/metadata/android/id/changelogs/998.txt b/fastlane/metadata/android/id/changelogs/998.txt
new file mode 100644
index 000000000..d3fea84ab
--- /dev/null
+++ b/fastlane/metadata/android/id/changelogs/998.txt
@@ -0,0 +1 @@
+Memperbaiki YouTube yang tidak memutar streaming apa pun
diff --git a/fastlane/metadata/android/id/changelogs/999.txt b/fastlane/metadata/android/id/changelogs/999.txt
new file mode 100644
index 000000000..d3fea84ab
--- /dev/null
+++ b/fastlane/metadata/android/id/changelogs/999.txt
@@ -0,0 +1 @@
+Memperbaiki YouTube yang tidak memutar streaming apa pun
diff --git a/fastlane/metadata/android/it/changelogs/998.txt b/fastlane/metadata/android/it/changelogs/998.txt
new file mode 100644
index 000000000..73fc6f2d8
--- /dev/null
+++ b/fastlane/metadata/android/it/changelogs/998.txt
@@ -0,0 +1,4 @@
+Corretto problema di riproduzione di YouTube causato da errori HTTP 403.
+
+Gli errori HTTP 403 saltuari nel mezzo di un video di YouTube non sono ancora stati sistemati.
+Questo problema sarà affrontato in un altra release hotfix non appena possibile.
diff --git a/fastlane/metadata/android/it/changelogs/999.txt b/fastlane/metadata/android/it/changelogs/999.txt
new file mode 100644
index 000000000..c2652da28
--- /dev/null
+++ b/fastlane/metadata/android/it/changelogs/999.txt
@@ -0,0 +1,12 @@
+Questo aggiornamento hotfix risolve gli errori HTTP 403 nel mezzo dei video di YouTube.
+
+Novità
+• [SoundCloud] Aggiunto il supporto per gli URL on.soundcloud.com
+
+Migliorie
+• [Bandcamp] Mostra informazioni aggiuntive nel chiosco della radio
+
+Correzioni
+• [YouTube] Corretti errori HTTP 403 occasionali all'inizio o nel mezzo dei video
+• [YouTube] Estrazione avatar e banner da più tipi di canali
+• [Bandcamp] Risolti vari bug e forzato l'uso di HTTPS
diff --git a/fastlane/metadata/android/ka/changelogs/998.txt b/fastlane/metadata/android/ka/changelogs/998.txt
new file mode 100644
index 000000000..d20512f17
--- /dev/null
+++ b/fastlane/metadata/android/ka/changelogs/998.txt
@@ -0,0 +1 @@
+გაასწორა YouTube არ უკრავს არცერთ ნაკადს
diff --git a/fastlane/metadata/android/ka/changelogs/999.txt b/fastlane/metadata/android/ka/changelogs/999.txt
new file mode 100644
index 000000000..d20512f17
--- /dev/null
+++ b/fastlane/metadata/android/ka/changelogs/999.txt
@@ -0,0 +1 @@
+გაასწორა YouTube არ უკრავს არცერთ ნაკადს
diff --git a/fastlane/metadata/android/nl/changelogs/998.txt b/fastlane/metadata/android/nl/changelogs/998.txt
new file mode 100644
index 000000000..9bd8adf86
--- /dev/null
+++ b/fastlane/metadata/android/nl/changelogs/998.txt
@@ -0,0 +1 @@
+YouTube speelt geen stream af opgelost
diff --git a/fastlane/metadata/android/nl/changelogs/999.txt b/fastlane/metadata/android/nl/changelogs/999.txt
new file mode 100644
index 000000000..9bd8adf86
--- /dev/null
+++ b/fastlane/metadata/android/nl/changelogs/999.txt
@@ -0,0 +1 @@
+YouTube speelt geen stream af opgelost
diff --git a/fastlane/metadata/android/pa/changelogs/998.txt b/fastlane/metadata/android/pa/changelogs/998.txt
new file mode 100644
index 000000000..fe62a1330
--- /dev/null
+++ b/fastlane/metadata/android/pa/changelogs/998.txt
@@ -0,0 +1 @@
+ਸਥਿਰ YouTube ਕੋਈ ਸਟ੍ਰੀਮ ਨਹੀਂ ਚਲਾ ਰਿਹਾ
diff --git a/fastlane/metadata/android/pa/changelogs/999.txt b/fastlane/metadata/android/pa/changelogs/999.txt
new file mode 100644
index 000000000..fe62a1330
--- /dev/null
+++ b/fastlane/metadata/android/pa/changelogs/999.txt
@@ -0,0 +1 @@
+ਸਥਿਰ YouTube ਕੋਈ ਸਟ੍ਰੀਮ ਨਹੀਂ ਚਲਾ ਰਿਹਾ
diff --git a/fastlane/metadata/android/pt-BR/changelogs/998.txt b/fastlane/metadata/android/pt-BR/changelogs/998.txt
new file mode 100644
index 000000000..59fc6a5cd
--- /dev/null
+++ b/fastlane/metadata/android/pt-BR/changelogs/998.txt
@@ -0,0 +1 @@
+Corrigido YouTube não reproduzir qualquer transmissão
diff --git a/fastlane/metadata/android/pt-BR/changelogs/999.txt b/fastlane/metadata/android/pt-BR/changelogs/999.txt
new file mode 100644
index 000000000..59fc6a5cd
--- /dev/null
+++ b/fastlane/metadata/android/pt-BR/changelogs/999.txt
@@ -0,0 +1 @@
+Corrigido YouTube não reproduzir qualquer transmissão
diff --git a/fastlane/metadata/android/pt-PT/changelogs/998.txt b/fastlane/metadata/android/pt-PT/changelogs/998.txt
new file mode 100644
index 000000000..93519d64d
--- /dev/null
+++ b/fastlane/metadata/android/pt-PT/changelogs/998.txt
@@ -0,0 +1 @@
+Corrigido YouTube não reproduzir nenhuma transmissão
diff --git a/fastlane/metadata/android/pt-PT/changelogs/999.txt b/fastlane/metadata/android/pt-PT/changelogs/999.txt
new file mode 100644
index 000000000..93519d64d
--- /dev/null
+++ b/fastlane/metadata/android/pt-PT/changelogs/999.txt
@@ -0,0 +1 @@
+Corrigido YouTube não reproduzir nenhuma transmissão
diff --git a/fastlane/metadata/android/pt/changelogs/998.txt b/fastlane/metadata/android/pt/changelogs/998.txt
new file mode 100644
index 000000000..93519d64d
--- /dev/null
+++ b/fastlane/metadata/android/pt/changelogs/998.txt
@@ -0,0 +1 @@
+Corrigido YouTube não reproduzir nenhuma transmissão
diff --git a/fastlane/metadata/android/pt/changelogs/999.txt b/fastlane/metadata/android/pt/changelogs/999.txt
new file mode 100644
index 000000000..93519d64d
--- /dev/null
+++ b/fastlane/metadata/android/pt/changelogs/999.txt
@@ -0,0 +1 @@
+Corrigido YouTube não reproduzir nenhuma transmissão
diff --git a/fastlane/metadata/android/ru/changelogs/998.txt b/fastlane/metadata/android/ru/changelogs/998.txt
new file mode 100644
index 000000000..d3978869d
--- /dev/null
+++ b/fastlane/metadata/android/ru/changelogs/998.txt
@@ -0,0 +1 @@
+Исправлено: YouTube не воспроизводил никакие потоки
diff --git a/fastlane/metadata/android/ru/changelogs/999.txt b/fastlane/metadata/android/ru/changelogs/999.txt
new file mode 100644
index 000000000..d3978869d
--- /dev/null
+++ b/fastlane/metadata/android/ru/changelogs/999.txt
@@ -0,0 +1 @@
+Исправлено: YouTube не воспроизводил никакие потоки
diff --git a/fastlane/metadata/android/sv/changelogs/998.txt b/fastlane/metadata/android/sv/changelogs/998.txt
new file mode 100644
index 000000000..35f298dbf
--- /dev/null
+++ b/fastlane/metadata/android/sv/changelogs/998.txt
@@ -0,0 +1 @@
+Åtgärdat att YouTube inte spelar någon stream
diff --git a/fastlane/metadata/android/sv/changelogs/999.txt b/fastlane/metadata/android/sv/changelogs/999.txt
new file mode 100644
index 000000000..35f298dbf
--- /dev/null
+++ b/fastlane/metadata/android/sv/changelogs/999.txt
@@ -0,0 +1 @@
+Åtgärdat att YouTube inte spelar någon stream
diff --git a/fastlane/metadata/android/tr/changelogs/998.txt b/fastlane/metadata/android/tr/changelogs/998.txt
new file mode 100644
index 000000000..e5979c68d
--- /dev/null
+++ b/fastlane/metadata/android/tr/changelogs/998.txt
@@ -0,0 +1 @@
+YouTube'un herhangi bir akışı oynatmaması düzeltildi
diff --git a/fastlane/metadata/android/tr/changelogs/999.txt b/fastlane/metadata/android/tr/changelogs/999.txt
new file mode 100644
index 000000000..e5979c68d
--- /dev/null
+++ b/fastlane/metadata/android/tr/changelogs/999.txt
@@ -0,0 +1 @@
+YouTube'un herhangi bir akışı oynatmaması düzeltildi
diff --git a/fastlane/metadata/android/uk/changelogs/998.txt b/fastlane/metadata/android/uk/changelogs/998.txt
new file mode 100644
index 000000000..905287c74
--- /dev/null
+++ b/fastlane/metadata/android/uk/changelogs/998.txt
@@ -0,0 +1 @@
+Виправлено проблему невідтворюваності трансляцій
diff --git a/fastlane/metadata/android/uk/changelogs/999.txt b/fastlane/metadata/android/uk/changelogs/999.txt
new file mode 100644
index 000000000..905287c74
--- /dev/null
+++ b/fastlane/metadata/android/uk/changelogs/999.txt
@@ -0,0 +1 @@
+Виправлено проблему невідтворюваності трансляцій
diff --git a/fastlane/metadata/android/vi/changelogs/998.txt b/fastlane/metadata/android/vi/changelogs/998.txt
new file mode 100644
index 000000000..d2086b62c
--- /dev/null
+++ b/fastlane/metadata/android/vi/changelogs/998.txt
@@ -0,0 +1 @@
+Đã sửa lỗi YouTube không phát bất kỳ luồng nào
diff --git a/fastlane/metadata/android/vi/changelogs/999.txt b/fastlane/metadata/android/vi/changelogs/999.txt
new file mode 100644
index 000000000..d2086b62c
--- /dev/null
+++ b/fastlane/metadata/android/vi/changelogs/999.txt
@@ -0,0 +1 @@
+Đã sửa lỗi YouTube không phát bất kỳ luồng nào
diff --git a/fastlane/metadata/android/zh-Hans/changelogs/998.txt b/fastlane/metadata/android/zh-Hans/changelogs/998.txt
new file mode 100644
index 000000000..8a5424c9e
--- /dev/null
+++ b/fastlane/metadata/android/zh-Hans/changelogs/998.txt
@@ -0,0 +1 @@
+修复YouTube无法播放任何视频
diff --git a/fastlane/metadata/android/zh-Hans/changelogs/999.txt b/fastlane/metadata/android/zh-Hans/changelogs/999.txt
new file mode 100644
index 000000000..8a5424c9e
--- /dev/null
+++ b/fastlane/metadata/android/zh-Hans/changelogs/999.txt
@@ -0,0 +1 @@
+修复YouTube无法播放任何视频
diff --git a/fastlane/metadata/android/zh-Hant/changelogs/998.txt b/fastlane/metadata/android/zh-Hant/changelogs/998.txt
new file mode 100644
index 000000000..4e8bf6537
--- /dev/null
+++ b/fastlane/metadata/android/zh-Hant/changelogs/998.txt
@@ -0,0 +1 @@
+修正 YouTube 無法播放任何串流
diff --git a/fastlane/metadata/android/zh-Hant/changelogs/999.txt b/fastlane/metadata/android/zh-Hant/changelogs/999.txt
new file mode 100644
index 000000000..4e8bf6537
--- /dev/null
+++ b/fastlane/metadata/android/zh-Hant/changelogs/999.txt
@@ -0,0 +1 @@
+修正 YouTube 無法播放任何串流
diff --git a/fastlane/metadata/android/zh_Hant_HK/changelogs/998.txt b/fastlane/metadata/android/zh_Hant_HK/changelogs/998.txt
new file mode 100644
index 000000000..9a4721551
--- /dev/null
+++ b/fastlane/metadata/android/zh_Hant_HK/changelogs/998.txt
@@ -0,0 +1 @@
+修正咗 YouTube 乜嘢實況串流都播唔到嘅問題
diff --git a/fastlane/metadata/android/zh_Hant_HK/changelogs/999.txt b/fastlane/metadata/android/zh_Hant_HK/changelogs/999.txt
new file mode 100644
index 000000000..9a4721551
--- /dev/null
+++ b/fastlane/metadata/android/zh_Hant_HK/changelogs/999.txt
@@ -0,0 +1 @@
+修正咗 YouTube 乜嘢實況串流都播唔到嘅問題
diff --git a/gradle.properties b/gradle.properties
index 0ca913222..ed32303da 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,4 +1,3 @@
-android.defaults.buildfeatures.buildconfig=true
android.enableJetifier=false
android.nonFinalResIds=false
android.nonTransitiveRClass=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 000000000..b5dc0379d
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,155 @@
+[versions]
+aboutLibraries = "11.2.3"
+acraCore = "5.11.3"
+androidState = "1.4.1"
+androidx-junit = "1.1.5"
+appcompat = "1.6.1"
+assertjCore = "3.24.2"
+auto-service = "1.1.1"
+bridge = "2.0.2"
+cardview = "1.0.0"
+checkstyle = "10.12.1"
+coil = "3.0.4"
+constraintlayout = "2.1.4"
+core-ktx = "1.12.0"
+desugar-jdk-libs-nio = "2.0.4"
+documentFile = "1.0.1"
+exoplayer = "2.18.7"
+fragment-compose = "1.8.2"
+gradle = "8.7.1"
+groupie = "2.10.1"
+hilt = "2.51.1"
+jetpack-compose = "2024.10.01"
+jsoup = "1.17.2"
+junit = "4.13.2"
+kotlin = "2.0.21"
+kotlinxCoroutinesRx3 = "1.8.1"
+ktlint = "0.45.2"
+lazycolumnscrollbar = "2.2.0"
+leakcanary = "2.12"
+lifecycle = "2.6.2"
+localbroadcastmanager = "1.1.0"
+markwon = "4.6.2"
+material = "1.11.0"
+media = "1.7.0"
+mockitoCore = "5.6.0"
+navigationCompose = "2.8.3"
+okhttp = "4.12.0"
+pagingCompose = "3.3.2"
+preference = "1.2.1"
+prettytime = "5.0.8.Final"
+processPhoenix = "2.1.2"
+recyclerview = "1.3.2"
+room = "2.6.1"
+runner = "1.5.2"
+rxandroid = "3.0.2"
+rxbinding = "4.0.0"
+rxjava = "3.1.8"
+sonarqube = "4.0.0.2929"
+stetho = "1.6.0"
+swiperefreshlayout = "1.1.0"
+# 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/
+teamnewpipe-filepicker = "5.0.0"
+teamnewpipe-nanojson = "1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751"
+teamnewpipe-newpipe-extractor = "d3d5f2b3f03a5f2b479b9f6fdf1c2555cbb9de0e"
+work = "2.8.1"
+
+[plugins]
+aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin" }
+android-application = { id = "com.android.application" }
+checkstyle = { id = "checkstyle" }
+hilt = { id = "com.google.dagger.hilt.android" }
+kotlin-android = { id = "kotlin-android" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+kotlin-kapt = { id = "kotlin-kapt" }
+kotlin-parcelize = { id = "kotlin-parcelize" }
+sonarqube = { id = "org.sonarqube", version.ref = "sonarqube" }
+
+[libraries]
+aboutlibraries-compose-m3 = { group = "com.mikepenz", name = "aboutlibraries-compose-m3", version.ref = "aboutLibraries" }
+aboutlibraries-plugin = { group = "com.mikepenz.aboutlibraries.plugin", name = "aboutlibraries-plugin", version.ref = "aboutLibraries" }
+acra-core = { group = "ch.acra", name = "acra-core", version.ref = "acraCore" }
+android-state = { group = "com.evernote", name = "android-state", version.ref = "androidState" }
+android-state-processor = { group = "com.evernote", name = "android-state-processor", version.ref = "androidState" }
+android-tools-build-gradle = { group = "com.android.tools.build", name = "gradle", version.ref = "gradle" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose" }
+androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
+androidx-cardview = { group = "androidx.cardview", name = "cardview", version.ref = "cardview" }
+androidx-compose-adaptive = { group = "androidx.compose.material3.adaptive", name = "adaptive" }
+androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "jetpack-compose" }
+androidx-compose-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
+androidx-compose-ui-text = { group = "androidx.compose.ui", name = "ui-text" }
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
+androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
+androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "core-ktx" }
+androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "documentFile" }
+androidx-fragment-compose = { group = "androidx.fragment", name = "fragment-compose", version.ref = "fragment-compose" }
+androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-junit" }
+androidx-lifecycle-livedata = { group = "androidx.lifecycle", name = "lifecycle-livedata-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" }
+androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose" }
+androidx-localbroadcastmanager = { group = "androidx.localbroadcastmanager", name = "localbroadcastmanager", version.ref = "localbroadcastmanager" }
+androidx-material = { group = "com.google.android.material", name = "material", version.ref = "material" }
+androidx-media = { group = "androidx.media", name = "media", version.ref = "media" }
+androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" }
+androidx-paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "pagingCompose" }
+androidx-preference = { group = "androidx.preference", name = "preference", version.ref = "preference" }
+androidx-recyclerview = { group = "androidx.recyclerview", name = "recyclerview", version.ref = "recyclerview" }
+androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
+androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
+androidx-room-rxjava3 = { group = "androidx.room", name = "room-rxjava3", version.ref = "room" }
+androidx-room-testing = { group = "androidx.room", name = "room-testing", version.ref = "room" }
+androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" }
+androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" }
+androidx-work-runtime = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" }
+androidx-work-rxjava3 = { group = "androidx.work", name = "work-rxjava3", version.ref = "work" }
+assertj-core = { group = "org.assertj", name = "assertj-core", version.ref = "assertjCore" }
+auto-service = { group = "com.google.auto.service", name = "auto-service-annotations", version.ref = "auto-service" }
+auto-service-kapt = { group = "com.google.auto.service", name = "auto-service", version.ref = "auto-service" }
+coil-compose = { group = "io.coil-kt.coil3", name = 'coil-compose', version.ref = "coil" }
+coil-network-okhttp = { group = "io.coil-kt.coil3", name = 'coil-network-okhttp', version.ref = "coil" }
+desugar-jdk-libs-nio = { group = "com.android.tools", name = "desugar_jdk_libs_nio", version.ref = "desugar-jdk-libs-nio" }
+exoplayer-core = { group = "com.google.android.exoplayer", name = "exoplayer-core", version.ref = "exoplayer" }
+exoplayer-dash = { module = "com.google.android.exoplayer:exoplayer-dash", version.ref = "exoplayer" }
+exoplayer-database = { group = "com.google.android.exoplayer", name = "exoplayer-database", version.ref = "exoplayer" }
+exoplayer-datasource = { group = "com.google.android.exoplayer", name = "exoplayer-datasource", version.ref = "exoplayer" }
+exoplayer-hls = { group = "com.google.android.exoplayer", name = "exoplayer-hls", version.ref = "exoplayer" }
+exoplayer-smoothstreaming = { group = "com.google.android.exoplayer", name = "exoplayer-smoothstreaming", version.ref = "exoplayer" }
+exoplayer-ui = { group = "com.google.android.exoplayer", name = "exoplayer-ui", version.ref = "exoplayer" }
+extension-mediasession = { group = "com.google.android.exoplayer", name = "extension-mediasession", version.ref = "exoplayer" }
+hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
+hilt-android-gradle-plugin = { group = "com.google.dagger", name = "hilt-android-gradle-plugin", version.ref = "hilt" }
+hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
+jsoup = { group = "org.jsoup", name = "jsoup", version.ref = "jsoup" }
+junit = { group = "junit", name = "junit", version.ref = "junit" }
+kotlin-gradle-plugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" }
+kotlin-stdlib = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib", version.ref = "kotlin" }
+kotlinx-coroutines-rx3 = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-rx3", version.ref = "kotlinxCoroutinesRx3" }
+lazycolumnscrollbar = { group = "com.github.nanihadesuka", name = "LazyColumnScrollbar", version.ref = "lazycolumnscrollbar" }
+leakcanary-android-core = { module = "com.squareup.leakcanary:leakcanary-android-core", version.ref = "leakcanary" }
+leakcanary-object-watcher = { group = "com.squareup.leakcanary", name = "leakcanary-object-watcher-android", version.ref = "leakcanary" }
+leakcanary-plumber-android = { group = "com.squareup.leakcanary", name = "plumber-android", version.ref = "leakcanary" }
+lisawray-groupie = { group = "com.github.lisawray.groupie", name = "groupie", version.ref = "groupie" }
+lisawray-groupie-viewbinding = { group = "com.github.lisawray.groupie", name = "groupie-viewbinding", version.ref = "groupie" }
+livefront-bridge = { group = "com.github.livefront", name = "bridge", version.ref = "bridge" }
+markwon-core = { group = "io.noties.markwon", name = "core", version.ref = "markwon" }
+markwon-linkify = { group = "io.noties.markwon", name = "linkify", version.ref = "markwon" }
+mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockitoCore" }
+okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
+prettytime = { group = "org.ocpsoft.prettytime", name = "prettytime", version.ref = "prettytime" }
+process-phoenix = { group = "com.jakewharton", name = "process-phoenix", version.ref = "processPhoenix" }
+rxbinding4-rxbinding = { group = "com.jakewharton.rxbinding4", name = "rxbinding", version.ref = "rxbinding" }
+rxjava3-rxandroid = { group = "io.reactivex.rxjava3", name = "rxandroid", version.ref = "rxandroid" }
+rxjava3-rxjava = { group = "io.reactivex.rxjava3", name = "rxjava", version.ref = "rxjava" }
+stetho = { group = "com.facebook.stetho", name = "stetho", version.ref = "stetho" }
+stetho-okhttp3 = { group = "com.facebook.stetho", name = "stetho-okhttp3", version.ref = "stetho" }
+teamnewpipe-nanojson = { group = "com.github.TeamNewPipe", name = "nanojson", version.ref = "teamnewpipe-nanojson" }
+teamnewpipe-newpipe-extractor = { group = "com.github.TeamNewPipe", name = "NewPipeExtractor", version.ref = "teamnewpipe-newpipe-extractor" }
+teamnewpipe-nononsense-filepicker = { group = "com.github.TeamNewPipe", name = "NoNonsense-FilePicker", version.ref = "teamnewpipe-filepicker" }
+tools-checkstyle = { group = "com.puppycrawl.tools", name = "checkstyle", version.ref = "checkstyle" }
+tools-ktlint = { group = "com.pinterest", name = "ktlint", version.ref = "ktlint" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index d022615ff..4ea536e77 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionSha256Sum=38f66cd6eef217b4c35855bb11ea4e9fbc53594ccccb5fb82dfd317ef8c2c5a3
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip
+distributionSha256Sum=d725d707bfabd4dfdc958c624003b3c80accc03f7037b5122c4b1d0ef15cecab
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
networkTimeout=10000
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists