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 getReturnActivity(final Class returnActivity) { - Class 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 @@ - - - - - - - - - - - - - - - - - -