diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000000..77feb3181ba
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,44 @@
+#
+# SPDX-FileCopyrightText: 2025 NewPipe e.V.
+# SPDX-License-Identifier: GPL-3.0-or-later
+#
+
+root = true
+
+[*.{kt,kts}]
+ktlint_standard_annotation = disabled
+ktlint_standard_argument-list-wrapping = disabled
+ktlint_standard_backing-property-naming = disabled
+ktlint_standard_blank-line-before-declaration = disabled
+ktlint_standard_blank-line-between-when-conditions = disabled
+ktlint_standard_chain-method-continuation = disabled
+ktlint_standard_class-signature = disabled
+ktlint_standard_comment-wrapping = disabled
+ktlint_standard_enum-wrapping = disabled
+ktlint_standard_function-expression-body = disabled
+ktlint_standard_function-literal = disabled
+ktlint_standard_function-signature = disabled
+ktlint_standard_indent = disabled
+ktlint_standard_kdoc = disabled
+ktlint_standard_max-line-length = disabled
+ktlint_standard_mixed-condition-operators = disabled
+ktlint_standard_multiline-expression-wrapping = disabled
+ktlint_standard_multiline-if-else = disabled
+ktlint_standard_no-blank-line-in-list = disabled
+ktlint_standard_no-consecutive-comments = disabled
+ktlint_standard_no-empty-first-line-in-class-body = disabled
+ktlint_standard_no-empty-first-line-in-method-block = disabled
+ktlint_standard_no-line-break-after-else = disabled
+ktlint_standard_no-semi = disabled
+ktlint_standard_no-single-line-block-comment = disabled
+ktlint_standard_package-name = disabled
+ktlint_standard_parameter-list-wrapping = disabled
+ktlint_standard_property-naming = disabled
+ktlint_standard_spacing-between-declarations-with-annotations = disabled
+ktlint_standard_spacing-between-declarations-with-comments = disabled
+ktlint_standard_statement-wrapping = disabled
+ktlint_standard_string-template-indent = disabled
+ktlint_standard_trailing-comma-on-call-site = disabled
+ktlint_standard_trailing-comma-on-declaration-site = disabled
+ktlint_standard_try-catch-finally-spacing = disabled
+ktlint_standard_when-entry-bracing = disabled
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 686ae233a75..069f003f432 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -3,6 +3,19 @@
NewPipe contribution guidelines
===============================
+## AI policy
+
+* Using generative AI to develop new features or making larger code changes is generally prohibited. Please refrain from contributions which are heavily depending on AI generated source code because they are usually lacking a fundamental understanding of the overall project structure and thus come with poor quality. However, you are allowed to use gen. AI if you
+ * are aware of the project structure,
+ * ensure that the generated code follows the project structure,
+ * fully understand the generated code, and
+ * review the generated code completely.
+* Using AI to find the root cause of bugs and generating small fixes might be acceptable. However, gen. AI often does not fix the underlying problem but is trying to fix the symptoms. If you are using AI to fix bugs, ensure that the root cause is tackled.
+* The use of AI to generate documentation is allowed. We ask you to thoroughly check the quality of generated documentation – wrong, misleading or uninformative documentation is useless and wastes the reader's time. Ensure that reasoning is documented.
+* Using generative AI to write or fill in PR or issue templates is prohibited. Those texts are often lengthy and miss critical information.
+* PRs and issues that do not follow this AI policy can be closed without further explanation.
+
+
## Crash reporting
Report crashes through the **automated crash report system** of NewPipe.
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index 52897f1acb7..60c94ad2594 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -26,6 +26,8 @@ body:
required: true
- label: "I have read and understood the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/dev/.github/CONTRIBUTING.md)."
required: true
+ - label: "I have read and understood the [AI policy](https://github.com/TeamNewPipe/NewPipe/blob/dev/.github/CONTRIBUTING.md#ai-policy). The content of this bug report is not generated by AI."
+ required: true
- type: input
id: app-version
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index 31ef92c44fe..97a3e38b507 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -25,6 +25,8 @@ body:
required: true
- label: "I have read and understood the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/dev/.github/CONTRIBUTING.md)."
required: true
+ - label: "I have read and understood the [AI policy](https://github.com/TeamNewPipe/NewPipe/blob/dev/.github/CONTRIBUTING.md#ai-policy). The content of this request is not generated by AI."
+ required: true
- type: textarea
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 407c00a39b7..2af1556d438 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -2,7 +2,7 @@
#### What is it?
- [ ] Bugfix (user facing)
-- [ ] Feature (user facing)
+- [ ] Feature (user facing) ⚠️ **Your PR must target the [`refactor`](https://github.com/TeamNewPipe/NewPipe/tree/refactor) branch**
- [ ] Codebase improvement (dev facing)
- [ ] Meta improvement to the project (dev facing)
@@ -32,3 +32,5 @@ The APK can be found by going to the "Checks" tab below the title. On the left p
#### Due diligence
- [ ] I read the [contribution guidelines](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md).
+- [ ] The proposed changes follow the [AI policy](https://github.com/TeamNewPipe/NewPipe/blob/HEAD/.github/CONTRIBUTING.md#ai-policy).
+- [ ] I tested the changes using an emulator or a physical device.
diff --git a/.github/workflows/build-release-apk.yml b/.github/workflows/build-release-apk.yml
index 0fad8e1695e..b558d90dd4f 100644
--- a/.github/workflows/build-release-apk.yml
+++ b/.github/workflows/build-release-apk.yml
@@ -7,11 +7,11 @@ jobs:
release:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
ref: 'master'
- - uses: actions/setup-java@v4
+ - uses: actions/setup-java@v5
with:
distribution: 'temurin'
java-version: '21'
@@ -32,7 +32,7 @@ jobs:
mv app/build/outputs/apk/release/*.apk "app/build/outputs/apk/release/NewPipe_v$VERSION_NAME.apk"
- name: "Upload APK"
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: app
path: app/build/outputs/apk/release/*.apk
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f6708fa832c..d42c5a0b4a2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -37,8 +37,8 @@ jobs:
contents: read
steps:
- - uses: actions/checkout@v4
- - uses: gradle/wrapper-validation-action@v2
+ - uses: actions/checkout@v6
+ - uses: gradle/actions/wrapper-validation@v4
- name: create and checkout branch
# push events already checked out the branch
@@ -48,7 +48,7 @@ jobs:
run: git checkout -B "$BRANCH"
- name: set up JDK
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
java-version: 21
distribution: "temurin"
@@ -58,7 +58,7 @@ jobs:
run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint
- name: Upload APK
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: app
path: app/build/outputs/apk/debug/*.apk
@@ -72,15 +72,15 @@ jobs:
- api-level: 21
target: default
arch: x86
- - api-level: 33
- target: google_apis # emulator API 33 only exists with Google APIs
+ - api-level: 35
+ target: default
arch: x86_64
permissions:
contents: read
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- name: Enable KVM
run: |
@@ -89,7 +89,7 @@ jobs:
sudo udevadm trigger --name-match=kvm
- name: set up JDK
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
java-version: 21
distribution: "temurin"
@@ -104,7 +104,7 @@ jobs:
script: ./gradlew connectedCheck --stacktrace
- name: Upload test report when tests fail # because the printed out stacktrace (console) is too short, see also #7553
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
if: failure()
with:
name: android-test-report-api${{ matrix.api-level }}
@@ -118,19 +118,19 @@ jobs:
contents: read
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Set up JDK
- uses: actions/setup-java@v4
+ uses: actions/setup-java@v5
with:
java-version: 21
distribution: "temurin"
cache: 'gradle'
- name: Cache SonarCloud packages
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: ~/.sonar/cache
key: ${{ runner.os }}-sonar
diff --git a/.github/workflows/image-minimizer.yml b/.github/workflows/image-minimizer.yml
index d9241c33b62..264a0ac6c5d 100644
--- a/.github/workflows/image-minimizer.yml
+++ b/.github/workflows/image-minimizer.yml
@@ -17,9 +17,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v6
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v6
with:
node-version: 16
@@ -27,7 +27,7 @@ jobs:
run: npm i probe-image-size@7.2.3 --ignore-scripts
- name: Minimize simple images
- uses: actions/github-script@v7
+ uses: actions/github-script@v8
timeout-minutes: 3
with:
script: |
diff --git a/.gitignore b/.gitignore
index 1352b69172a..49267a9f064 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@ captures/
*.iml
*~
.weblate
+.kotlin
*.class
app/debug/
app/release/
diff --git a/app/build.gradle b/app/build.gradle
deleted file mode 100644
index d6c93c1f77a..00000000000
--- a/app/build.gradle
+++ /dev/null
@@ -1,360 +0,0 @@
-import com.android.tools.profgen.ArtProfileKt
-import com.android.tools.profgen.ArtProfileSerializer
-import com.android.tools.profgen.DexFile
-
-plugins {
- id "com.android.application"
- id "kotlin-android"
- id "kotlin-kapt"
- id "kotlin-parcelize"
- id "checkstyle"
- id "org.sonarqube" version "4.0.0.2929"
-}
-
-android {
- compileSdk 34
- namespace 'org.schabi.newpipe'
-
- defaultConfig {
- applicationId "org.schabi.newpipe"
- resValue "string", "app_name", "NewPipe"
- minSdk 21
- targetSdk 33
- if (System.properties.containsKey('versionCodeOverride')) {
- versionCode System.getProperty('versionCodeOverride') as Integer
- } else {
- versionCode 1005
- }
- versionName "0.28.0"
- if (System.properties.containsKey('versionNameSuffix')) {
- versionNameSuffix System.getProperty('versionNameSuffix')
- }
-
- testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
-
- javaCompileOptions {
- annotationProcessorOptions {
- arguments = ["room.schemaLocation": "$projectDir/schemas".toString()]
- }
- }
- }
-
- buildTypes {
- debug {
- debuggable true
-
- // suffix the app id and the app name with git branch name
- def workingBranch = getGitWorkingBranch()
- def normalizedWorkingBranch = workingBranch.replaceFirst("^[^A-Za-z]+", "").replaceAll("[^0-9A-Za-z]+", "")
- if (normalizedWorkingBranch.isEmpty() || workingBranch == "master" || workingBranch == "dev") {
- // default values when branch name could not be determined or is master or dev
- applicationIdSuffix ".debug"
- resValue "string", "app_name", "NewPipe Debug"
- } else {
- applicationIdSuffix ".debug." + normalizedWorkingBranch
- resValue "string", "app_name", "NewPipe " + workingBranch
- archivesBaseName = 'NewPipe_' + normalizedWorkingBranch
- }
- }
-
- release {
- if (System.properties.containsKey('packageSuffix')) {
- applicationIdSuffix System.getProperty('packageSuffix')
- resValue "string", "app_name", "NewPipe " + System.getProperty('packageSuffix')
- archivesBaseName = 'NewPipe_' + System.getProperty('packageSuffix')
- }
- minifyEnabled true
- shrinkResources false // disabled to fix F-Droid's reproducible build
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
- archivesBaseName = 'app'
- }
- }
-
- lint {
- checkReleaseBuilds false
- // Or, if you prefer, you can continue to check for errors in release builds,
- // but continue the build even when errors are found:
- abortOnError false
- // suppress false warning ("Resource IDs will be non-final in Android Gradle Plugin version
- // 5.0, avoid using them in switch case statements"), which affects only library projects
- disable 'NonConstantResourceId'
- }
-
- compileOptions {
- // Flag to enable support for the new language APIs
- coreLibraryDesugaringEnabled true
-
- sourceCompatibility JavaVersion.VERSION_17
- targetCompatibility JavaVersion.VERSION_17
- encoding 'utf-8'
- }
-
- kotlinOptions {
- jvmTarget = JavaVersion.VERSION_17
- }
-
- sourceSets {
- androidTest.assets.srcDirs += files("$projectDir/schemas".toString())
- }
-
- androidResources {
- generateLocaleConfig = true
- }
-
- buildFeatures {
- viewBinding true
- buildConfig true
- }
-
- packagingOptions {
- resources {
- // remove two files which belong to jsoup
- // no idea how they ended up in the META-INF dir...
- excludes += ['META-INF/README.md', 'META-INF/CHANGES',
- // 'COPYRIGHT' belongs to RxJava...
- 'META-INF/COPYRIGHT']
- }
- }
-}
-
-ext {
- checkstyleVersion = '10.12.1'
-
- androidxLifecycleVersion = '2.6.2'
- androidxRoomVersion = '2.6.1'
- androidxWorkVersion = '2.8.1'
-
- stateSaverVersion = '1.4.1'
- 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
-}
-
-checkstyle {
- getConfigDirectory().set(rootProject.file("checkstyle"))
- ignoreFailures false
- showViolations true
- toolVersion = checkstyleVersion
-}
-
-tasks.register('runCheckstyle', Checkstyle) {
- source 'src'
- include '**/*.java'
- exclude '**/gen/**'
- exclude '**/R.java'
- exclude '**/BuildConfig.java'
- exclude 'main/java/us/shandian/giga/**'
-
- classpath = configurations.checkstyle
-
- showViolations true
-
- reports {
- xml.getRequired().set(true)
- html.getRequired().set(true)
- }
-}
-
-def outputDir = "${project.buildDir}/reports/ktlint/"
-def inputFiles = project.fileTree(dir: "src", include: "**/*.kt")
-
-tasks.register('runKtlint', JavaExec) {
- inputs.files(inputFiles)
- outputs.dir(outputDir)
- getMainClass().set("com.pinterest.ktlint.Main")
- classpath = configurations.ktlint
- args "src/**/*.kt"
- jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED")
-}
-
-tasks.register('formatKtlint', JavaExec) {
- inputs.files(inputFiles)
- outputs.dir(outputDir)
- getMainClass().set("com.pinterest.ktlint.Main")
- classpath = configurations.ktlint
- args "-F", "src/**/*.kt"
- jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED")
-}
-
-afterEvaluate {
- if (!System.properties.containsKey('skipFormatKtlint')) {
- preDebugBuild.dependsOn formatKtlint
- }
- preDebugBuild.dependsOn runCheckstyle, runKtlint
-}
-
-sonar {
- properties {
- property "sonar.projectKey", "TeamNewPipe_NewPipe"
- property "sonar.organization", "teamnewpipe"
- property "sonar.host.url", "https://sonarcloud.io"
- }
-}
-
-dependencies {
-/** Desugaring **/
- coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs_nio:2.0.4'
-
-/** 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:e9d656ddb49a412a5a0a5d5ef20ca7ef09549996'
- // WORKAROUND: if you get errors with the NewPipeExtractor dependency, replace `v0.24.3` with
- // the corresponding commit hash, since JitPack sometimes deletes artifacts.
- // If there’s already a git hash, just add more of it to the end (or remove a letter)
- // to cause jitpack to regenerate the artifact.
- implementation 'com.github.TeamNewPipe:NewPipeExtractor:v0.24.8'
- implementation 'com.github.TeamNewPipe:NoNonsense-FilePicker:5.0.0'
-
-/** Checkstyle **/
- checkstyle "com.puppycrawl.tools:checkstyle:${checkstyleVersion}"
- ktlint 'com.pinterest:ktlint:0.45.2'
-
-/** Kotlin **/
- implementation "org.jetbrains.kotlin:kotlin-stdlib:${kotlin_version}"
-
-/** AndroidX **/
- implementation 'androidx.appcompat:appcompat:1.7.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 "androidx.webkit:webkit:1.9.0"
-
-/** Third-party libraries **/
- // Instance state boilerplate elimination
- implementation 'com.github.livefront:bridge:v2.0.2'
- implementation "com.evernote:android-state:$stateSaverVersion"
- kapt "com.evernote:android-state-processor:$stateSaverVersion"
-
- // HTML parser
- implementation "org.jsoup:jsoup:1.17.2"
-
- // HTTP client
- implementation "com.squareup.okhttp3:okhttp:4.12.0"
-
- // 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}"
-
- // Metadata generator for service descriptors
- compileOnly "com.google.auto.service:auto-service-annotations:${googleAutoServiceVersion}"
- kapt "com.google.auto.service:auto-service:${googleAutoServiceVersion}"
-
- // Manager for complex RecyclerView layouts
- implementation "com.github.lisawray.groupie:groupie:${groupieVersion}"
- implementation "com.github.lisawray.groupie:groupie-viewbinding:${groupieVersion}"
-
- // Image loading
- //noinspection GradleDependency --> 2.8 is the last version, not 2.71828!
- implementation "com.squareup.picasso:picasso:2.8"
-
- // Markdown library for Android
- implementation "io.noties.markwon:core:${markwonVersion}"
- implementation "io.noties.markwon:linkify:${markwonVersion}"
-
- // Crash reporting
- implementation "ch.acra:acra-core:5.11.3"
-
- // Properly restarting
- implementation 'com.jakewharton:process-phoenix:2.1.2'
-
- // Reactive extensions for Java VM
- implementation "io.reactivex.rxjava3:rxjava:3.1.8"
- implementation "io.reactivex.rxjava3:rxandroid:3.0.2"
- // RxJava binding APIs for Android UI widgets
- implementation "com.jakewharton.rxbinding4:rxbinding:4.0.0"
-
- // Date and time formatting
- implementation "org.ocpsoft.prettytime:prettytime:5.0.8.Final"
-
-/** 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}"
- // Debug bridge for Android
- debugImplementation "com.facebook.stetho:stetho:${stethoVersion}"
- debugImplementation "com.facebook.stetho:stetho-okhttp3:${stethoVersion}"
-
-/** Testing **/
- testImplementation 'junit:junit:4.13.2'
- testImplementation 'org.mockito:mockito-core:5.6.0'
-
- 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"
-}
-
-static String getGitWorkingBranch() {
- try {
- def gitProcess = "git rev-parse --abbrev-ref HEAD".execute()
- gitProcess.waitFor()
- if (gitProcess.exitValue() == 0) {
- return gitProcess.text.trim()
- } else {
- // not a git repository
- return ""
- }
- } catch (IOException ignored) {
- // git was not found
- return ""
- }
-}
-
-// fix reproducible builds
-project.afterEvaluate {
- tasks.compileReleaseArtProfile.doLast {
- outputs.files.each { file ->
- if (file.toString().endsWith(".profm")) {
- println("Sorting ${file} ...")
- def version = ArtProfileSerializer.valueOf("METADATA_0_0_2")
- def profile = ArtProfileKt.ArtProfile(file)
- def keys = new ArrayList(profile.profileData.keySet())
- def sortedData = new LinkedHashMap()
- Collections.sort keys, new DexFile.Companion()
- keys.each { key -> sortedData[key] = profile.profileData[key] }
- new FileOutputStream(file).with {
- write(version.magicBytes$profgen)
- write(version.versionBytes$profgen)
- version.write$profgen(it, sortedData, "")
- }
- }
- }
- }
-}
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 00000000000..1aa5297c557
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,306 @@
+/*
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+plugins {
+ alias(libs.plugins.android.application)
+ alias(libs.plugins.jetbrains.kotlin.android)
+ alias(libs.plugins.jetbrains.kotlin.kapt)
+ alias(libs.plugins.google.ksp)
+ alias(libs.plugins.jetbrains.kotlin.parcelize)
+ alias(libs.plugins.sonarqube)
+ checkstyle
+}
+
+val gitWorkingBranch = providers.exec {
+ commandLine("git", "rev-parse", "--abbrev-ref", "HEAD")
+}.standardOutput.asText.map { it.trim() }
+
+java {
+ toolchain {
+ languageVersion = JavaLanguageVersion.of(17)
+ }
+}
+
+kotlin {
+ compilerOptions {
+ // TODO: Drop annotation default target when it is stable
+ freeCompilerArgs.addAll(
+ "-Xannotation-default-target=param-property"
+ )
+ }
+}
+
+android {
+ compileSdk = 36
+ namespace = "org.schabi.newpipe"
+
+ defaultConfig {
+ applicationId = "org.schabi.newpipe"
+ resValue("string", "app_name", "NewPipe")
+ minSdk = 21
+ targetSdk = 35
+
+ versionCode = System.getProperty("versionCodeOverride")?.toInt() ?: 1006
+
+ versionName = "0.28.1"
+ System.getProperty("versionNameSuffix")?.let { versionNameSuffix = it }
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildTypes {
+ debug {
+ isDebuggable = true
+
+ // suffix the app id and the app name with git branch name
+ val defaultBranches = listOf("master", "dev")
+ val workingBranch = gitWorkingBranch.getOrElse("")
+ val normalizedWorkingBranch = workingBranch
+ .replaceFirst("^[^A-Za-z]+".toRegex(), "")
+ .replace("[^0-9A-Za-z]+".toRegex(), "")
+
+ if (normalizedWorkingBranch.isEmpty() || workingBranch in defaultBranches) {
+ // default values when branch name could not be determined or is master or dev
+ applicationIdSuffix = ".debug"
+ resValue("string", "app_name", "NewPipe Debug")
+ } else {
+ applicationIdSuffix = ".debug.$normalizedWorkingBranch"
+ resValue("string", "app_name", "NewPipe $workingBranch")
+ }
+ }
+
+ release {
+ System.getProperty("packageSuffix")?.let { suffix ->
+ applicationIdSuffix = suffix
+ resValue("string", "app_name", "NewPipe $suffix")
+ }
+ isMinifyEnabled = true
+ isShrinkResources = false // disabled to fix F-Droid"s reproducible build
+ proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
+ }
+ }
+
+ lint {
+ checkReleaseBuilds = false
+ // Or, if you prefer, you can continue to check for errors in release builds,
+ // but continue the build even when errors are found:
+ abortOnError = false
+ // suppress false warning ("Resource IDs will be non-final in Android Gradle Plugin version
+ // 5.0, avoid using them in switch case statements"), which affects only library projects
+ disable += "NonConstantResourceId"
+ }
+
+ compileOptions {
+ // Flag to enable support for the new language APIs
+ isCoreLibraryDesugaringEnabled = true
+ encoding = "utf-8"
+ }
+
+ sourceSets {
+ getByName("androidTest") {
+ assets.srcDir("$projectDir/schemas")
+ }
+ }
+
+ androidResources {
+ generateLocaleConfig = true
+ }
+
+ buildFeatures {
+ viewBinding = true
+ buildConfig = true
+ }
+
+ packaging {
+ resources {
+ // remove two files which belong to jsoup
+ // no idea how they ended up in the META-INF dir...
+ excludes += setOf(
+ "META-INF/README.md",
+ "META-INF/CHANGES",
+ "META-INF/COPYRIGHT" // "COPYRIGHT" belongs to RxJava...
+ )
+ }
+ }
+}
+
+ksp {
+ arg("room.schemaLocation", "$projectDir/schemas")
+}
+
+
+// Custom dependency configuration for ktlint
+val ktlint by configurations.creating
+
+checkstyle {
+ configDirectory = rootProject.file("checkstyle")
+ isIgnoreFailures = false
+ isShowViolations = true
+ toolVersion = libs.versions.checkstyle.get()
+}
+
+tasks.register("runCheckstyle") {
+ source("src")
+ include("**/*.java")
+ exclude("**/gen/**")
+ exclude("**/R.java")
+ exclude("**/BuildConfig.java")
+ exclude("main/java/us/shandian/giga/**")
+
+ classpath = configurations.getByName("checkstyle")
+
+ isShowViolations = true
+
+ reports {
+ xml.required = true
+ html.required = true
+ }
+}
+
+val outputDir = project.layout.buildDirectory.dir("reports/ktlint/")
+val inputFiles = fileTree("src") { include("**/*.kt") }
+
+tasks.register("runKtlint") {
+ inputs.files(inputFiles)
+ outputs.dir(outputDir)
+ mainClass.set("com.pinterest.ktlint.Main")
+ classpath = configurations.getByName("ktlint")
+ args = listOf("--editorconfig=../.editorconfig", "src/**/*.kt")
+ jvmArgs = listOf("--add-opens", "java.base/java.lang=ALL-UNNAMED")
+}
+
+tasks.register("formatKtlint") {
+ inputs.files(inputFiles)
+ outputs.dir(outputDir)
+ mainClass.set("com.pinterest.ktlint.Main")
+ classpath = configurations.getByName("ktlint")
+ args = listOf("--editorconfig=../.editorconfig", "-F", "src/**/*.kt")
+ jvmArgs = listOf("--add-opens", "java.base/java.lang=ALL-UNNAMED")
+}
+
+tasks.register("checkDependenciesOrder") {
+ tomlFile = layout.projectDirectory.file("../gradle/libs.versions.toml")
+}
+
+afterEvaluate {
+ tasks.named("preDebugBuild").configure {
+ if (!System.getProperties().containsKey("skipFormatKtlint")) {
+ dependsOn("formatKtlint")
+ }
+ dependsOn("runCheckstyle", "runKtlint", "checkDependenciesOrder")
+ }
+}
+
+sonar {
+ properties {
+ property("sonar.projectKey", "TeamNewPipe_NewPipe")
+ property("sonar.organization", "teamnewpipe")
+ property("sonar.host.url", "https://sonarcloud.io")
+ }
+}
+
+dependencies {
+ /** Desugaring **/
+ coreLibraryDesugaring(libs.android.desugar)
+
+ /** NewPipe libraries **/
+ implementation(libs.newpipe.nanojson)
+ implementation(libs.newpipe.extractor)
+ implementation(libs.newpipe.filepicker)
+
+ /** Checkstyle **/
+ checkstyle(libs.puppycrawl.checkstyle)
+ ktlint(libs.pinterest.ktlint)
+
+ /** AndroidX **/
+ implementation(libs.androidx.appcompat)
+ implementation(libs.androidx.cardview)
+ implementation(libs.androidx.constraintlayout)
+ implementation(libs.androidx.core)
+ implementation(libs.androidx.documentfile)
+ implementation(libs.androidx.fragment)
+ 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)
+ ksp(libs.androidx.room.compiler)
+ implementation(libs.androidx.swiperefreshlayout)
+ implementation(libs.androidx.viewpager2)
+ implementation(libs.androidx.work.runtime)
+ implementation(libs.androidx.work.rxjava3)
+ implementation(libs.google.android.material)
+ implementation(libs.androidx.webkit)
+
+ /** Third-party libraries **/
+ implementation(libs.livefront.bridge)
+ implementation(libs.evernote.statesaver.core)
+ kapt(libs.evernote.statesaver.compiler)
+
+ // HTML parser
+ implementation(libs.jsoup)
+
+ // HTTP client
+ implementation(libs.squareup.okhttp)
+
+ // Media player
+ implementation(libs.google.exoplayer.core)
+ implementation(libs.google.exoplayer.dash)
+ implementation(libs.google.exoplayer.database)
+ implementation(libs.google.exoplayer.datasource)
+ implementation(libs.google.exoplayer.hls)
+ implementation(libs.google.exoplayer.mediasession)
+ implementation(libs.google.exoplayer.smoothstreaming)
+ implementation(libs.google.exoplayer.ui)
+
+ // Manager for complex RecyclerView layouts
+ implementation(libs.lisawray.groupie.core)
+ implementation(libs.lisawray.groupie.viewbinding)
+
+ // Image loading
+ implementation(libs.squareup.picasso)
+
+ // Markdown library for Android
+ implementation(libs.noties.markwon.core)
+ implementation(libs.noties.markwon.linkify)
+
+ // Crash reporting
+ implementation(libs.acra.core)
+ compileOnly(libs.google.autoservice.annotations)
+ ksp(libs.zacsweers.autoservice.compiler)
+
+ // Properly restarting
+ implementation(libs.jakewharton.phoenix)
+
+ // Reactive extensions for Java VM
+ implementation(libs.reactivex.rxjava)
+ implementation(libs.reactivex.rxandroid)
+ // RxJava binding APIs for Android UI widgets
+ implementation(libs.jakewharton.rxbinding)
+
+ // Date and time formatting
+ implementation(libs.ocpsoft.prettytime)
+
+ /** Debugging **/
+ // Memory leak detection
+ debugImplementation(libs.squareup.leakcanary.watcher)
+ debugImplementation(libs.squareup.leakcanary.plumber)
+ debugImplementation(libs.squareup.leakcanary.core)
+ // Debug bridge for Android
+ debugImplementation(libs.facebook.stetho.core)
+ debugImplementation(libs.facebook.stetho.okhttp3)
+
+ /** Testing **/
+ testImplementation(libs.junit)
+ testImplementation(libs.mockito.core)
+
+ androidTestImplementation(libs.androidx.junit)
+ androidTestImplementation(libs.androidx.runner)
+ androidTestImplementation(libs.androidx.room.testing)
+ androidTestImplementation(libs.assertj.core)
+}
diff --git a/app/schemas/org.schabi.newpipe.database.AppDatabase/9.json b/app/schemas/org.schabi.newpipe.database.AppDatabase/9.json
index aced06c0ab0..b9a618638e7 100644
--- a/app/schemas/org.schabi.newpipe.database.AppDatabase/9.json
+++ b/app/schemas/org.schabi.newpipe.database.AppDatabase/9.json
@@ -458,7 +458,7 @@
"notNull": true
},
{
- "fieldPath": "name",
+ "fieldPath": "orderingName",
"columnName": "name",
"affinity": "TEXT",
"notNull": false
diff --git a/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt b/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt
index a34cfece671..4327271f410 100644
--- a/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt
+++ b/app/src/androidTest/java/org/schabi/newpipe/database/DatabaseMigrationTest.kt
@@ -129,7 +129,7 @@ class DatabaseMigrationTest {
)
val migratedDatabaseV3 = getMigratedDatabase()
- val listFromDB = migratedDatabaseV3.streamDAO().all.blockingFirst()
+ val listFromDB = migratedDatabaseV3.streamDAO().getAll().blockingFirst()
// Only expect 2, the one with the null url will be ignored
assertEquals(2, listFromDB.size)
@@ -217,7 +217,7 @@ class DatabaseMigrationTest {
)
val migratedDatabaseV8 = getMigratedDatabase()
- val listFromDB = migratedDatabaseV8.searchHistoryDAO().all.blockingFirst()
+ val listFromDB = migratedDatabaseV8.searchHistoryDAO().getAll().blockingFirst()
assertEquals(2, listFromDB.size)
assertEquals("abc", listFromDB[0].search)
@@ -283,8 +283,8 @@ class DatabaseMigrationTest {
)
val migratedDatabaseV9 = getMigratedDatabase()
- var localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst()
- var remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst()
+ var localListFromDB = migratedDatabaseV9.playlistDAO().getAll().blockingFirst()
+ var remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().getAll().blockingFirst()
assertEquals(1, localListFromDB.size)
assertEquals(localUid2, localListFromDB[0].uid)
@@ -294,17 +294,27 @@ class DatabaseMigrationTest {
assertEquals(-1, remoteListFromDB[0].displayIndex)
val localUid3 = migratedDatabaseV9.playlistDAO().insert(
- PlaylistEntity(DEFAULT_NAME + "3", false, -1, -1)
+ PlaylistEntity(
+ name = "${DEFAULT_NAME}3",
+ isThumbnailPermanent = false,
+ thumbnailStreamId = -1,
+ displayIndex = -1
+ )
)
val remoteUid3 = migratedDatabaseV9.playlistRemoteDAO().insert(
PlaylistRemoteEntity(
- DEFAULT_THIRD_SERVICE_ID, DEFAULT_NAME, DEFAULT_THIRD_URL,
- DEFAULT_THUMBNAIL, DEFAULT_UPLOADER_NAME, -1, 10
+ serviceId = DEFAULT_THIRD_SERVICE_ID,
+ orderingName = DEFAULT_NAME,
+ url = DEFAULT_THIRD_URL,
+ thumbnailUrl = DEFAULT_THUMBNAIL,
+ uploader = DEFAULT_UPLOADER_NAME,
+ displayIndex = -1,
+ streamCount = 10
)
)
- localListFromDB = migratedDatabaseV9.playlistDAO().all.blockingFirst()
- remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().all.blockingFirst()
+ localListFromDB = migratedDatabaseV9.playlistDAO().getAll().blockingFirst()
+ remoteListFromDB = migratedDatabaseV9.playlistRemoteDAO().getAll().blockingFirst()
assertEquals(2, localListFromDB.size)
assertEquals(localUid3, localListFromDB[1].uid)
assertEquals(-1, localListFromDB[1].displayIndex)
diff --git a/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.java b/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.java
index 891824a5542..892d1df0f57 100644
--- a/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.java
+++ b/app/src/androidTest/java/org/schabi/newpipe/error/ErrorInfoTest.java
@@ -12,6 +12,7 @@
import org.schabi.newpipe.extractor.exceptions.ParsingException;
import java.util.Arrays;
+import java.util.Objects;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
@@ -23,8 +24,23 @@
@LargeTest
public class ErrorInfoTest {
+ /**
+ * @param errorInfo the error info to access
+ * @return the private field errorInfo.message.stringRes using reflection
+ */
+ private int getMessageFromErrorInfo(final ErrorInfo errorInfo)
+ throws NoSuchFieldException, IllegalAccessException {
+ final var message = ErrorInfo.class.getDeclaredField("message");
+ message.setAccessible(true);
+ final var messageValue = (ErrorInfo.Companion.ErrorMessage) message.get(errorInfo);
+
+ final var stringRes = ErrorInfo.Companion.ErrorMessage.class.getDeclaredField("stringRes");
+ stringRes.setAccessible(true);
+ return (int) Objects.requireNonNull(stringRes.get(messageValue));
+ }
+
@Test
- public void errorInfoTestParcelable() {
+ public void errorInfoTestParcelable() throws NoSuchFieldException, IllegalAccessException {
final ErrorInfo info = new ErrorInfo(new ParsingException("Hello"),
UserAction.USER_REPORT, "request", ServiceList.YouTube.getServiceId());
// Obtain a Parcel object and write the parcelable object to it:
@@ -39,7 +55,7 @@ public void errorInfoTestParcelable() {
assertEquals(ServiceList.YouTube.getServiceInfo().getName(),
infoFromParcel.getServiceName());
assertEquals("request", infoFromParcel.getRequest());
- assertEquals(R.string.parsing_error, infoFromParcel.getMessageStringId());
+ assertEquals(R.string.parsing_error, getMessageFromErrorInfo(infoFromParcel));
parcel.recycle();
}
diff --git a/app/src/androidTest/java/org/schabi/newpipe/local/history/HistoryRecordManagerTest.kt b/app/src/androidTest/java/org/schabi/newpipe/local/history/HistoryRecordManagerTest.kt
index 24be0f868d1..0de9dd268f0 100644
--- a/app/src/androidTest/java/org/schabi/newpipe/local/history/HistoryRecordManagerTest.kt
+++ b/app/src/androidTest/java/org/schabi/newpipe/local/history/HistoryRecordManagerTest.kt
@@ -41,7 +41,7 @@ class HistoryRecordManagerTest {
// For some reason the Flowable returned by getAll() never completes, so we can't assert
// that the number of Lists it returns is exactly 1, we can only check if the first List is
// correct. Why on earth has a Flowable been used instead of a Single for getAll()?!?
- val entities = database.searchHistoryDAO().all.blockingFirst()
+ val entities = database.searchHistoryDAO().getAll().blockingFirst()
assertThat(entities).hasSize(1)
assertThat(entities[0].id).isEqualTo(1)
assertThat(entities[0].serviceId).isEqualTo(0)
@@ -51,50 +51,50 @@ class HistoryRecordManagerTest {
@Test
fun deleteSearchHistory() {
val entries = listOf(
- SearchHistoryEntry(time.minusSeconds(1), 0, "A"),
- SearchHistoryEntry(time.minusSeconds(2), 2, "A"),
- SearchHistoryEntry(time.minusSeconds(3), 1, "B"),
- SearchHistoryEntry(time.minusSeconds(4), 0, "B"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 0, search = "A"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 2, search = "A"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 1, search = "B"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(4), serviceId = 0, search = "B"),
)
// make sure all 4 were inserted
database.searchHistoryDAO().insertAll(entries)
- assertThat(database.searchHistoryDAO().all.blockingFirst()).hasSameSizeAs(entries)
+ assertThat(database.searchHistoryDAO().getAll().blockingFirst()).hasSameSizeAs(entries)
// try to delete only "A" entries, "B" entries should be untouched
manager.deleteSearchHistory("A").test().await().assertValue(2)
- val entities = database.searchHistoryDAO().all.blockingFirst()
+ val entities = database.searchHistoryDAO().getAll().blockingFirst()
assertThat(entities).hasSize(2)
assertThat(entities).usingElementComparator { o1, o2 -> if (o1.hasEqualValues(o2)) 0 else 1 }
.containsExactly(*entries.subList(2, 4).toTypedArray())
// assert that nothing happens if we delete a search query that does exist in the db
manager.deleteSearchHistory("A").test().await().assertValue(0)
- val entities2 = database.searchHistoryDAO().all.blockingFirst()
+ val entities2 = database.searchHistoryDAO().getAll().blockingFirst()
assertThat(entities2).hasSize(2)
assertThat(entities2).usingElementComparator { o1, o2 -> if (o1.hasEqualValues(o2)) 0 else 1 }
.containsExactly(*entries.subList(2, 4).toTypedArray())
// delete all remaining entries
manager.deleteSearchHistory("B").test().await().assertValue(2)
- assertThat(database.searchHistoryDAO().all.blockingFirst()).isEmpty()
+ assertThat(database.searchHistoryDAO().getAll().blockingFirst()).isEmpty()
}
@Test
fun deleteCompleteSearchHistory() {
val entries = listOf(
- SearchHistoryEntry(time.minusSeconds(1), 1, "A"),
- SearchHistoryEntry(time.minusSeconds(2), 2, "B"),
- SearchHistoryEntry(time.minusSeconds(3), 0, "C"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 1, search = "A"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 2, search = "B"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 0, search = "C"),
)
// make sure all 3 were inserted
database.searchHistoryDAO().insertAll(entries)
- assertThat(database.searchHistoryDAO().all.blockingFirst()).hasSameSizeAs(entries)
+ assertThat(database.searchHistoryDAO().getAll().blockingFirst()).hasSameSizeAs(entries)
// should remove everything
manager.deleteCompleteSearchHistory().test().await().assertValue(entries.size)
- assertThat(database.searchHistoryDAO().all.blockingFirst()).isEmpty()
+ assertThat(database.searchHistoryDAO().getAll().blockingFirst()).isEmpty()
}
private fun insertShuffledRelatedSearches(relatedSearches: Collection) {
@@ -107,7 +107,7 @@ class HistoryRecordManagerTest {
// make sure all entries were inserted
assertEquals(
relatedSearches.size,
- database.searchHistoryDAO().all.blockingFirst().size
+ database.searchHistoryDAO().getAll().blockingFirst().size
)
}
@@ -127,19 +127,18 @@ class HistoryRecordManagerTest {
@Test
fun getRelatedSearches_emptyQuery_manyDuplicates() {
- insertShuffledRelatedSearches(
- listOf(
- SearchHistoryEntry(time.minusSeconds(9), 3, "A"),
- SearchHistoryEntry(time.minusSeconds(8), 3, "AB"),
- SearchHistoryEntry(time.minusSeconds(7), 3, "A"),
- SearchHistoryEntry(time.minusSeconds(6), 3, "A"),
- SearchHistoryEntry(time.minusSeconds(5), 3, "BA"),
- SearchHistoryEntry(time.minusSeconds(4), 3, "A"),
- SearchHistoryEntry(time.minusSeconds(3), 3, "A"),
- SearchHistoryEntry(time.minusSeconds(2), 0, "A"),
- SearchHistoryEntry(time.minusSeconds(1), 2, "AA"),
- )
+ val relatedSearches = listOf(
+ SearchHistoryEntry(creationDate = time.minusSeconds(9), serviceId = 3, search = "A"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(8), serviceId = 3, search = "AB"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(7), serviceId = 3, search = "A"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(6), serviceId = 3, search = "A"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(5), serviceId = 3, search = "BA"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(4), serviceId = 3, search = "A"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 3, search = "A"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 0, search = "A"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 2, search = "AA"),
)
+ insertShuffledRelatedSearches(relatedSearches)
val searches = manager.getRelatedSearches("", 9, 3).blockingFirst()
assertThat(searches).containsExactly("AA", "A", "BA")
@@ -166,13 +165,13 @@ class HistoryRecordManagerTest {
private val time = OffsetDateTime.of(LocalDateTime.of(2000, 1, 1, 1, 1), ZoneOffset.UTC)
private val RELATED_SEARCHES_ENTRIES = listOf(
- SearchHistoryEntry(time.minusSeconds(7), 2, "AC"),
- SearchHistoryEntry(time.minusSeconds(6), 0, "ABC"),
- SearchHistoryEntry(time.minusSeconds(5), 1, "BA"),
- SearchHistoryEntry(time.minusSeconds(4), 3, "A"),
- SearchHistoryEntry(time.minusSeconds(2), 0, "B"),
- SearchHistoryEntry(time.minusSeconds(3), 2, "AA"),
- SearchHistoryEntry(time.minusSeconds(1), 1, "A"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(7), serviceId = 2, search = "AC"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(6), serviceId = 0, search = "ABC"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(5), serviceId = 1, search = "BA"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(4), serviceId = 3, search = "A"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(2), serviceId = 0, search = "B"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(3), serviceId = 2, search = "AA"),
+ SearchHistoryEntry(creationDate = time.minusSeconds(1), serviceId = 1, search = "A"),
)
}
}
diff --git a/app/src/androidTest/java/org/schabi/newpipe/local/playlist/LocalPlaylistManagerTest.kt b/app/src/androidTest/java/org/schabi/newpipe/local/playlist/LocalPlaylistManagerTest.kt
index c392d8d3d66..ce3aeb84ac0 100644
--- a/app/src/androidTest/java/org/schabi/newpipe/local/playlist/LocalPlaylistManagerTest.kt
+++ b/app/src/androidTest/java/org/schabi/newpipe/local/playlist/LocalPlaylistManagerTest.kt
@@ -72,6 +72,6 @@ class LocalPlaylistManagerTest {
val result = manager.createPlaylist("name", listOf(stream, upserted))
result.test().await().assertComplete()
- database.streamDAO().all.test().awaitCount(1).assertValue(listOf(stream, upserted))
+ database.streamDAO().getAll().test().awaitCount(1).assertValue(listOf(stream, upserted))
}
}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index e0abd977b24..20e9a6ca9a3 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -9,6 +9,8 @@
+
+
@@ -94,9 +96,22 @@
android:exported="false"
android:label="@string/title_activity_about" />
-
-
-
+
+
+
+
+
+
+
-
+
+
@@ -368,6 +385,7 @@
+
@@ -421,6 +439,7 @@
diff --git a/app/src/main/java/org/schabi/newpipe/App.java b/app/src/main/java/org/schabi/newpipe/App.java
index a8827c33e73..cf41aad46cd 100644
--- a/app/src/main/java/org/schabi/newpipe/App.java
+++ b/app/src/main/java/org/schabi/newpipe/App.java
@@ -65,6 +65,8 @@ public class App extends Application {
private static final String TAG = App.class.toString();
private boolean isFirstRun = false;
+ private boolean notificationsRequested = false;
+
private static App app;
@NonNull
@@ -72,6 +74,14 @@ public static App getApp() {
return app;
}
+ public boolean getNotificationsRequested() {
+ return notificationsRequested;
+ }
+
+ public void setNotificationsRequested() {
+ notificationsRequested = true;
+ }
+
@Override
protected void attachBaseContext(final Context base) {
super.attachBaseContext(base);
diff --git a/app/src/main/java/org/schabi/newpipe/MainActivity.java b/app/src/main/java/org/schabi/newpipe/MainActivity.java
index 4fbd562b44c..8dac39682fc 100644
--- a/app/src/main/java/org/schabi/newpipe/MainActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/MainActivity.java
@@ -48,6 +48,7 @@
import androidx.appcompat.app.ActionBarDrawerToggle;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
+import androidx.core.content.ContextCompat;
import androidx.core.view.GravityCompat;
import androidx.drawerlayout.widget.DrawerLayout;
import androidx.fragment.app.Fragment;
@@ -896,7 +897,8 @@ public void onReceive(final Context context, final Intent intent) {
};
final IntentFilter intentFilter = new IntentFilter();
intentFilter.addAction(VideoDetailFragment.ACTION_PLAYER_STARTED);
- registerReceiver(broadcastReceiver, intentFilter);
+ ContextCompat.registerReceiver(this, broadcastReceiver, intentFilter,
+ ContextCompat.RECEIVER_EXPORTED);
// If the PlayerHolder is not bound yet, but the service is running, try to bind to it.
// Once the connection is established, the ACTION_PLAYER_STARTED will be sent.
diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java
deleted file mode 100644
index 21c5354f44d..00000000000
--- a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.java
+++ /dev/null
@@ -1,72 +0,0 @@
-package org.schabi.newpipe;
-
-import static org.schabi.newpipe.database.AppDatabase.DATABASE_NAME;
-import static org.schabi.newpipe.database.Migrations.MIGRATION_1_2;
-import static org.schabi.newpipe.database.Migrations.MIGRATION_2_3;
-import static org.schabi.newpipe.database.Migrations.MIGRATION_3_4;
-import static org.schabi.newpipe.database.Migrations.MIGRATION_4_5;
-import static org.schabi.newpipe.database.Migrations.MIGRATION_5_6;
-import static org.schabi.newpipe.database.Migrations.MIGRATION_6_7;
-import static org.schabi.newpipe.database.Migrations.MIGRATION_7_8;
-import static org.schabi.newpipe.database.Migrations.MIGRATION_8_9;
-
-import android.content.Context;
-import android.database.Cursor;
-
-import androidx.annotation.NonNull;
-import androidx.room.Room;
-
-import org.schabi.newpipe.database.AppDatabase;
-
-public final class NewPipeDatabase {
- private static volatile AppDatabase databaseInstance;
-
- private NewPipeDatabase() {
- //no instance
- }
-
- private static AppDatabase getDatabase(final Context context) {
- return Room
- .databaseBuilder(context.getApplicationContext(), AppDatabase.class, DATABASE_NAME)
- .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
- MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9)
- .build();
- }
-
- @NonNull
- public static AppDatabase getInstance(@NonNull final Context context) {
- AppDatabase result = databaseInstance;
- if (result == null) {
- synchronized (NewPipeDatabase.class) {
- result = databaseInstance;
- if (result == null) {
- databaseInstance = getDatabase(context);
- result = databaseInstance;
- }
- }
- }
-
- return result;
- }
-
- public static void checkpoint() {
- if (databaseInstance == null) {
- throw new IllegalStateException("database is not initialized");
- }
- final Cursor c = databaseInstance.query("pragma wal_checkpoint(full)", null);
- if (c.moveToFirst() && c.getInt(0) == 1) {
- throw new RuntimeException("Checkpoint was blocked from completing");
- }
- }
-
- public static void close() {
- if (databaseInstance != null) {
- synchronized (NewPipeDatabase.class) {
- if (databaseInstance != null) {
- databaseInstance.close();
- databaseInstance = null;
- }
- }
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt
new file mode 100644
index 00000000000..c3ce515240b
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/NewPipeDatabase.kt
@@ -0,0 +1,80 @@
+/*
+ * SPDX-FileCopyrightText: 2017-2024 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe
+
+import android.content.Context
+import androidx.room.Room.databaseBuilder
+import org.schabi.newpipe.database.AppDatabase
+import org.schabi.newpipe.database.Migrations.MIGRATION_1_2
+import org.schabi.newpipe.database.Migrations.MIGRATION_2_3
+import org.schabi.newpipe.database.Migrations.MIGRATION_3_4
+import org.schabi.newpipe.database.Migrations.MIGRATION_4_5
+import org.schabi.newpipe.database.Migrations.MIGRATION_5_6
+import org.schabi.newpipe.database.Migrations.MIGRATION_6_7
+import org.schabi.newpipe.database.Migrations.MIGRATION_7_8
+import org.schabi.newpipe.database.Migrations.MIGRATION_8_9
+import kotlin.concurrent.Volatile
+
+object NewPipeDatabase {
+
+ @Volatile
+ private var databaseInstance: AppDatabase? = null
+
+ private fun getDatabase(context: Context): AppDatabase {
+ return databaseBuilder(
+ context.applicationContext,
+ AppDatabase::class.java,
+ AppDatabase.Companion.DATABASE_NAME
+ ).addMigrations(
+ MIGRATION_1_2,
+ MIGRATION_2_3,
+ MIGRATION_3_4,
+ MIGRATION_4_5,
+ MIGRATION_5_6,
+ MIGRATION_6_7,
+ MIGRATION_7_8,
+ MIGRATION_8_9
+ ).build()
+ }
+
+ @JvmStatic
+ fun getInstance(context: Context): AppDatabase {
+ var result = databaseInstance
+ if (result == null) {
+ synchronized(NewPipeDatabase::class.java) {
+ result = databaseInstance
+ if (result == null) {
+ databaseInstance = getDatabase(context)
+ result = databaseInstance
+ }
+ }
+ }
+
+ return result!!
+ }
+
+ @JvmStatic
+ fun checkpoint() {
+ checkNotNull(databaseInstance) { "database is not initialized" }
+ val c = databaseInstance!!.query("pragma wal_checkpoint(full)", null)
+ if (c.moveToFirst() && c.getInt(0) == 1) {
+ throw RuntimeException("Checkpoint was blocked from completing")
+ }
+ }
+
+ @JvmStatic
+ fun close() {
+ if (databaseInstance != null) {
+ synchronized(NewPipeDatabase::class.java) {
+ if (databaseInstance != null) {
+ databaseInstance!!.close()
+ databaseInstance = null
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/RouterActivity.java b/app/src/main/java/org/schabi/newpipe/RouterActivity.java
index 3294cae0b03..d85fdf7de0b 100644
--- a/app/src/main/java/org/schabi/newpipe/RouterActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/RouterActivity.java
@@ -58,20 +58,10 @@
import org.schabi.newpipe.extractor.StreamingService;
import org.schabi.newpipe.extractor.StreamingService.LinkType;
import org.schabi.newpipe.extractor.channel.ChannelInfo;
-import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException;
-import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException;
-import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException;
import org.schabi.newpipe.extractor.exceptions.ExtractionException;
-import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException;
-import org.schabi.newpipe.extractor.exceptions.PaidContentException;
-import org.schabi.newpipe.extractor.exceptions.PrivateContentException;
-import org.schabi.newpipe.extractor.exceptions.ReCaptchaException;
-import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException;
-import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException;
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler;
import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
import org.schabi.newpipe.extractor.stream.StreamInfo;
-import org.schabi.newpipe.ktx.ExceptionUtils;
import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.helper.PlayerHelper;
@@ -260,7 +250,8 @@ private void handleUrl(final String url) {
showUnsupportedUrlDialog(url);
}
}, throwable -> handleError(this, new ErrorInfo(throwable,
- UserAction.SHARE_TO_NEWPIPE, "Getting service from url: " + url))));
+ UserAction.SHARE_TO_NEWPIPE, "Getting service from url: " + url,
+ null, url))));
}
/**
@@ -269,40 +260,19 @@ private void handleUrl(final String url) {
* @param errorInfo the error information
*/
private static void handleError(final Context context, final ErrorInfo errorInfo) {
- if (errorInfo.getThrowable() != null) {
- errorInfo.getThrowable().printStackTrace();
- }
-
- if (errorInfo.getThrowable() instanceof ReCaptchaException) {
+ if (errorInfo.getRecaptchaUrl() != null) {
Toast.makeText(context, R.string.recaptcha_request_toast, Toast.LENGTH_LONG).show();
// Starting ReCaptcha Challenge Activity
final Intent intent = new Intent(context, ReCaptchaActivity.class);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, errorInfo.getRecaptchaUrl());
context.startActivity(intent);
- } else if (errorInfo.getThrowable() != null
- && ExceptionUtils.isNetworkRelated(errorInfo.getThrowable())) {
- Toast.makeText(context, R.string.network_error, Toast.LENGTH_LONG).show();
- } else if (errorInfo.getThrowable() instanceof AgeRestrictedContentException) {
- Toast.makeText(context, R.string.restricted_video_no_stream,
- Toast.LENGTH_LONG).show();
- } else if (errorInfo.getThrowable() instanceof GeographicRestrictionException) {
- Toast.makeText(context, R.string.georestricted_content, Toast.LENGTH_LONG).show();
- } else if (errorInfo.getThrowable() instanceof PaidContentException) {
- Toast.makeText(context, R.string.paid_content, Toast.LENGTH_LONG).show();
- } else if (errorInfo.getThrowable() instanceof PrivateContentException) {
- Toast.makeText(context, R.string.private_content, Toast.LENGTH_LONG).show();
- } else if (errorInfo.getThrowable() instanceof SoundCloudGoPlusContentException) {
- Toast.makeText(context, R.string.soundcloud_go_plus_content,
- Toast.LENGTH_LONG).show();
- } else if (errorInfo.getThrowable() instanceof YoutubeMusicPremiumContentException) {
- Toast.makeText(context, R.string.youtube_music_premium_content,
- Toast.LENGTH_LONG).show();
- } else if (errorInfo.getThrowable() instanceof ContentNotAvailableException) {
- Toast.makeText(context, R.string.content_not_available, Toast.LENGTH_LONG).show();
- } else if (errorInfo.getThrowable() instanceof ContentNotSupportedException) {
- Toast.makeText(context, R.string.content_not_supported, Toast.LENGTH_LONG).show();
- } else {
+ } else if (errorInfo.isReportable()) {
ErrorUtil.createNotification(context, errorInfo);
+ } else {
+ // this exception does not usually indicate a problem that should be reported,
+ // so just show a toast instead of the notification
+ Toast.makeText(context, errorInfo.getMessage(context), Toast.LENGTH_LONG).show();
}
if (context instanceof RouterActivity) {
@@ -346,7 +316,8 @@ protected void onSuccess() {
if (choiceChecker.isAvailableAndSelected(
R.string.video_player_key,
R.string.background_player_key,
- R.string.popup_player_key)) {
+ R.string.popup_player_key,
+ R.string.enqueue_key)) {
final String selectedChoice = choiceChecker.getSelectedChoiceKey();
@@ -359,6 +330,8 @@ protected void onSuccess() {
|| selectedChoice.equals(getString(R.string.popup_player_key));
final boolean isAudioPlayerSelected =
selectedChoice.equals(getString(R.string.background_player_key));
+ final boolean isEnqueueSelected =
+ selectedChoice.equals(getString(R.string.enqueue_key));
if (currentLinkType != LinkType.STREAM
&& ((isExtAudioEnabled && isAudioPlayerSelected)
@@ -375,7 +348,9 @@ protected void onSuccess() {
// Check if the service supports the choice
if ((isVideoPlayerSelected && capabilities.contains(VIDEO))
- || (isAudioPlayerSelected && capabilities.contains(AUDIO))) {
+ || (isAudioPlayerSelected && capabilities.contains(AUDIO))
+ || (isEnqueueSelected && (capabilities.contains(VIDEO)
+ || capabilities.contains(AUDIO)))) {
handleChoice(selectedChoice);
} else {
handleChoice(getString(R.string.show_info_key));
@@ -556,7 +531,7 @@ private List getChoicesForService(final StreamingService serv
final List capabilities =
service.getServiceInfo().getMediaCapabilities();
- if (linkType == LinkType.STREAM) {
+ if (linkType == LinkType.STREAM || linkType == LinkType.PLAYLIST) {
if (capabilities.contains(VIDEO)) {
returnedItems.add(videoPlayer);
returnedItems.add(popupPlayer);
@@ -564,17 +539,28 @@ private List getChoicesForService(final StreamingService serv
if (capabilities.contains(AUDIO)) {
returnedItems.add(backgroundPlayer);
}
- // download is redundant for linkType CHANNEL AND PLAYLIST (till playlist downloading is
- // not supported )
- returnedItems.add(new AdapterChoiceItem(getString(R.string.download_key),
- getString(R.string.download),
- R.drawable.ic_file_download));
-
- // Add to playlist is not necessary for CHANNEL and PLAYLIST linkType since those can
- // not be added to a playlist
- returnedItems.add(new AdapterChoiceItem(getString(R.string.add_to_playlist_key),
- getString(R.string.add_to_playlist),
- R.drawable.ic_add));
+
+ // Enqueue is only shown if the current queue is not empty.
+ // However, if the playqueue or the player is cleared after this item was chosen and
+ // while the item is extracted, it will automatically fall back to background player.
+ if (PlayerHolder.getInstance().getQueueSize() > 0) {
+ returnedItems.add(new AdapterChoiceItem(getString(R.string.enqueue_key),
+ getString(R.string.enqueue_stream), R.drawable.ic_add));
+ }
+
+ if (linkType == LinkType.STREAM) {
+ // download is redundant for linkType CHANNEL AND PLAYLIST
+ // (till playlist downloading is not supported )
+ returnedItems.add(new AdapterChoiceItem(getString(R.string.download_key),
+ getString(R.string.download),
+ R.drawable.ic_file_download));
+
+ // Add to playlist is not necessary for CHANNEL and PLAYLIST linkType
+ // since those can not be added to a playlist
+ returnedItems.add(new AdapterChoiceItem(getString(R.string.add_to_playlist_key),
+ getString(R.string.add_to_playlist),
+ R.drawable.ic_playlist_add));
+ }
} else {
// LinkType.NONE is never present because it's filtered out before
// channels and playlist can be played as they contain a list of videos
@@ -665,7 +651,8 @@ private void handleChoice(final String selectedChoiceKey) {
startActivity(intent);
finish();
}, throwable -> handleError(this, new ErrorInfo(throwable,
- UserAction.SHARE_TO_NEWPIPE, "Starting info activity: " + currentUrl)))
+ UserAction.SHARE_TO_NEWPIPE, "Starting info activity: " + currentUrl,
+ null, currentUrl)))
);
return;
}
@@ -852,10 +839,10 @@ private void openAddToPlaylistDialog(final int currentServiceId, final String cu
})
)),
throwable -> runOnVisible(ctx -> handleError(ctx, new ErrorInfo(
- throwable,
- UserAction.REQUESTED_STREAM,
+ throwable, UserAction.REQUESTED_STREAM,
"Tried to add " + currentUrl + " to a playlist",
- ((RouterActivity) ctx).currentService.getServiceId())
+ ((RouterActivity) ctx).currentService.getServiceId(),
+ currentUrl)
))
)
);
@@ -995,7 +982,7 @@ public void handleChoice(final Choice choice) {
}
}, throwable -> handleError(this, new ErrorInfo(throwable, finalUserAction,
choice.url + " opened with " + choice.playerChoice,
- choice.serviceId)));
+ choice.serviceId, choice.url)));
}
}
@@ -1045,6 +1032,8 @@ public Consumer getResultHandler(final Choice choice) {
NavigationHelper.playOnBackgroundPlayer(this, playQueue, true);
} else if (choice.playerChoice.equals(popupPlayerKey)) {
NavigationHelper.playOnPopupPlayer(this, playQueue, true);
+ } else if (choice.playerChoice.equals(getString(R.string.enqueue_key))) {
+ NavigationHelper.enqueueOnPlayer(this, playQueue);
}
};
}
diff --git a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt b/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt
index 189fa148b71..240e2f42b7e 100644
--- a/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt
+++ b/app/src/main/java/org/schabi/newpipe/about/LicenseFragment.kt
@@ -7,6 +7,7 @@ import android.view.View
import android.view.ViewGroup
import android.webkit.WebView
import androidx.appcompat.app.AlertDialog
+import androidx.core.os.BundleCompat
import androidx.core.os.bundleOf
import androidx.fragment.app.Fragment
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
@@ -33,7 +34,9 @@ class LicenseFragment : Fragment() {
super.onCreate(savedInstanceState)
softwareComponents = arguments?.parcelableArrayList(ARG_COMPONENTS)!!
.sortedBy { it.name } // Sort components by name
- activeSoftwareComponent = savedInstanceState?.getSerializable(SOFTWARE_COMPONENT_KEY) as? SoftwareComponent
+ activeSoftwareComponent = savedInstanceState?.let {
+ BundleCompat.getSerializable(it, SOFTWARE_COMPONENT_KEY, SoftwareComponent::class.java)
+ }
}
override fun onDestroy() {
diff --git a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java
deleted file mode 100644
index 04d93a238d5..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.java
+++ /dev/null
@@ -1,65 +0,0 @@
-package org.schabi.newpipe.database;
-
-import static org.schabi.newpipe.database.Migrations.DB_VER_9;
-
-import androidx.room.Database;
-import androidx.room.RoomDatabase;
-import androidx.room.TypeConverters;
-
-import org.schabi.newpipe.database.feed.dao.FeedDAO;
-import org.schabi.newpipe.database.feed.dao.FeedGroupDAO;
-import org.schabi.newpipe.database.feed.model.FeedEntity;
-import org.schabi.newpipe.database.feed.model.FeedGroupEntity;
-import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity;
-import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity;
-import org.schabi.newpipe.database.history.dao.SearchHistoryDAO;
-import org.schabi.newpipe.database.history.dao.StreamHistoryDAO;
-import org.schabi.newpipe.database.history.model.SearchHistoryEntry;
-import org.schabi.newpipe.database.history.model.StreamHistoryEntity;
-import org.schabi.newpipe.database.playlist.dao.PlaylistDAO;
-import org.schabi.newpipe.database.playlist.dao.PlaylistRemoteDAO;
-import org.schabi.newpipe.database.playlist.dao.PlaylistStreamDAO;
-import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
-import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
-import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity;
-import org.schabi.newpipe.database.stream.dao.StreamDAO;
-import org.schabi.newpipe.database.stream.dao.StreamStateDAO;
-import org.schabi.newpipe.database.stream.model.StreamEntity;
-import org.schabi.newpipe.database.stream.model.StreamStateEntity;
-import org.schabi.newpipe.database.subscription.SubscriptionDAO;
-import org.schabi.newpipe.database.subscription.SubscriptionEntity;
-
-@TypeConverters({Converters.class})
-@Database(
- entities = {
- SubscriptionEntity.class, SearchHistoryEntry.class,
- StreamEntity.class, StreamHistoryEntity.class, StreamStateEntity.class,
- PlaylistEntity.class, PlaylistStreamEntity.class, PlaylistRemoteEntity.class,
- FeedEntity.class, FeedGroupEntity.class, FeedGroupSubscriptionEntity.class,
- FeedLastUpdatedEntity.class
- },
- version = DB_VER_9
-)
-public abstract class AppDatabase extends RoomDatabase {
- public static final String DATABASE_NAME = "newpipe.db";
-
- public abstract SearchHistoryDAO searchHistoryDAO();
-
- public abstract StreamDAO streamDAO();
-
- public abstract StreamHistoryDAO streamHistoryDAO();
-
- public abstract StreamStateDAO streamStateDAO();
-
- public abstract PlaylistDAO playlistDAO();
-
- public abstract PlaylistStreamDAO playlistStreamDAO();
-
- public abstract PlaylistRemoteDAO playlistRemoteDAO();
-
- public abstract FeedDAO feedDAO();
-
- public abstract FeedGroupDAO feedGroupDAO();
-
- public abstract SubscriptionDAO subscriptionDAO();
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt
new file mode 100644
index 00000000000..286eddf7b76
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/AppDatabase.kt
@@ -0,0 +1,68 @@
+/*
+ * SPDX-FileCopyrightText: 2017-2024 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverters
+import org.schabi.newpipe.database.feed.dao.FeedDAO
+import org.schabi.newpipe.database.feed.dao.FeedGroupDAO
+import org.schabi.newpipe.database.feed.model.FeedEntity
+import org.schabi.newpipe.database.feed.model.FeedGroupEntity
+import org.schabi.newpipe.database.feed.model.FeedGroupSubscriptionEntity
+import org.schabi.newpipe.database.feed.model.FeedLastUpdatedEntity
+import org.schabi.newpipe.database.history.dao.SearchHistoryDAO
+import org.schabi.newpipe.database.history.dao.StreamHistoryDAO
+import org.schabi.newpipe.database.history.model.SearchHistoryEntry
+import org.schabi.newpipe.database.history.model.StreamHistoryEntity
+import org.schabi.newpipe.database.playlist.dao.PlaylistDAO
+import org.schabi.newpipe.database.playlist.dao.PlaylistRemoteDAO
+import org.schabi.newpipe.database.playlist.dao.PlaylistStreamDAO
+import org.schabi.newpipe.database.playlist.model.PlaylistEntity
+import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
+import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
+import org.schabi.newpipe.database.stream.dao.StreamDAO
+import org.schabi.newpipe.database.stream.dao.StreamStateDAO
+import org.schabi.newpipe.database.stream.model.StreamEntity
+import org.schabi.newpipe.database.stream.model.StreamStateEntity
+import org.schabi.newpipe.database.subscription.SubscriptionDAO
+import org.schabi.newpipe.database.subscription.SubscriptionEntity
+
+@TypeConverters(Converters::class)
+@Database(
+ version = Migrations.DB_VER_9,
+ entities = [
+ SubscriptionEntity::class,
+ SearchHistoryEntry::class,
+ StreamEntity::class,
+ StreamHistoryEntity::class,
+ StreamStateEntity::class,
+ PlaylistEntity::class,
+ PlaylistStreamEntity::class,
+ PlaylistRemoteEntity::class,
+ FeedEntity::class,
+ FeedGroupEntity::class,
+ FeedGroupSubscriptionEntity::class,
+ FeedLastUpdatedEntity::class
+ ]
+)
+abstract class AppDatabase : RoomDatabase() {
+ abstract fun feedDAO(): FeedDAO
+ abstract fun feedGroupDAO(): FeedGroupDAO
+ abstract fun playlistDAO(): PlaylistDAO
+ abstract fun playlistRemoteDAO(): PlaylistRemoteDAO
+ abstract fun playlistStreamDAO(): PlaylistStreamDAO
+ abstract fun searchHistoryDAO(): SearchHistoryDAO
+ abstract fun streamDAO(): StreamDAO
+ abstract fun streamHistoryDAO(): StreamHistoryDAO
+ abstract fun streamStateDAO(): StreamStateDAO
+ abstract fun subscriptionDAO(): SubscriptionDAO
+
+ companion object {
+ const val DATABASE_NAME: String = "newpipe.db"
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java b/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java
deleted file mode 100644
index 255f5ba8deb..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/BasicDAO.java
+++ /dev/null
@@ -1,39 +0,0 @@
-package org.schabi.newpipe.database;
-
-import androidx.room.Dao;
-import androidx.room.Delete;
-import androidx.room.Insert;
-import androidx.room.Update;
-
-import java.util.Collection;
-import java.util.List;
-
-import io.reactivex.rxjava3.core.Flowable;
-
-@Dao
-public interface BasicDAO {
- /* Inserts */
- @Insert
- long insert(Entity entity);
-
- @Insert
- List insertAll(Collection entities);
-
- /* Searches */
- Flowable> getAll();
-
- Flowable> listByService(int serviceId);
-
- /* Deletes */
- @Delete
- void delete(Entity entity);
-
- int deleteAll();
-
- /* Updates */
- @Update
- int update(Entity entity);
-
- @Update
- void update(Collection entities);
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt b/app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt
new file mode 100644
index 00000000000..74c7cc87c86
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/BasicDAO.kt
@@ -0,0 +1,42 @@
+/*
+ * SPDX-FileCopyrightText: 2017-2022 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.Update
+import io.reactivex.rxjava3.core.Flowable
+
+@Dao
+interface BasicDAO {
+
+ /* Inserts */
+ @Insert
+ fun insert(entity: Entity): Long
+
+ @Insert
+ fun insertAll(entities: Collection): List
+
+ /* Searches */
+ fun getAll(): Flowable>
+
+ fun listByService(serviceId: Int): Flowable>
+
+ /* Deletes */
+ @Delete
+ fun delete(entity: Entity)
+
+ fun deleteAll(): Int
+
+ /* Updates */
+ @Update
+ fun update(entity: Entity): Int
+
+ @Update
+ fun update(entities: Collection)
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/LocalItem.java b/app/src/main/java/org/schabi/newpipe/database/LocalItem.java
deleted file mode 100644
index 54b856b0653..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/LocalItem.java
+++ /dev/null
@@ -1,13 +0,0 @@
-package org.schabi.newpipe.database;
-
-public interface LocalItem {
- LocalItemType getLocalItemType();
-
- enum LocalItemType {
- PLAYLIST_LOCAL_ITEM,
- PLAYLIST_REMOTE_ITEM,
-
- PLAYLIST_STREAM_ITEM,
- STATISTIC_STREAM_ITEM,
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt b/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt
new file mode 100644
index 00000000000..50529610b88
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/LocalItem.kt
@@ -0,0 +1,19 @@
+/*
+ * SPDX-FileCopyrightText: 2018-2020 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database
+
+interface LocalItem {
+ val localItemType: LocalItemType
+
+ enum class LocalItemType {
+ PLAYLIST_LOCAL_ITEM,
+ PLAYLIST_REMOTE_ITEM,
+
+ PLAYLIST_STREAM_ITEM,
+ STATISTIC_STREAM_ITEM,
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/Migrations.java b/app/src/main/java/org/schabi/newpipe/database/Migrations.java
deleted file mode 100644
index c9f630869c9..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/Migrations.java
+++ /dev/null
@@ -1,307 +0,0 @@
-package org.schabi.newpipe.database;
-
-import android.util.Log;
-
-import androidx.annotation.NonNull;
-import androidx.room.migration.Migration;
-import androidx.sqlite.db.SupportSQLiteDatabase;
-
-import org.schabi.newpipe.MainActivity;
-
-public final class Migrations {
-
- /////////////////////////////////////////////////////////////////////////////
- // Test new migrations manually by importing a database from daily usage //
- // and checking if the migration works (Use the Database Inspector //
- // https://developer.android.com/studio/inspect/database). //
- // If you add a migration point it out in the pull request, so that //
- // others remember to test it themselves. //
- /////////////////////////////////////////////////////////////////////////////
-
- public static final int DB_VER_1 = 1;
- public static final int DB_VER_2 = 2;
- public static final int DB_VER_3 = 3;
- public static final int DB_VER_4 = 4;
- public static final int DB_VER_5 = 5;
- public static final int DB_VER_6 = 6;
- public static final int DB_VER_7 = 7;
- public static final int DB_VER_8 = 8;
- public static final int DB_VER_9 = 9;
-
- private static final String TAG = Migrations.class.getName();
- public static final boolean DEBUG = MainActivity.DEBUG;
-
- public static final Migration MIGRATION_1_2 = new Migration(DB_VER_1, DB_VER_2) {
- @Override
- public void migrate(@NonNull final SupportSQLiteDatabase database) {
- if (DEBUG) {
- Log.d(TAG, "Start migrating database");
- }
- /*
- * Unfortunately these queries must be hardcoded due to the possibility of
- * schema and names changing at a later date, thus invalidating the older migration
- * scripts if they are not hardcoded.
- * */
-
- // Not much we can do about this, since room doesn't create tables before migration.
- // It's either this or blasting the entire database anew.
- database.execSQL("CREATE INDEX `index_search_history_search` "
- + "ON `search_history` (`search`)");
- database.execSQL("CREATE TABLE IF NOT EXISTS `streams` "
- + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
- + "`service_id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, "
- + "`stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, "
- + "`thumbnail_url` TEXT)");
- database.execSQL("CREATE UNIQUE INDEX `index_streams_service_id_url` "
- + "ON `streams` (`service_id`, `url`)");
- database.execSQL("CREATE TABLE IF NOT EXISTS `stream_history` "
- + "(`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, "
- + "`repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), "
- + "FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) "
- + "ON UPDATE CASCADE ON DELETE CASCADE )");
- database.execSQL("CREATE INDEX `index_stream_history_stream_id` "
- + "ON `stream_history` (`stream_id`)");
- database.execSQL("CREATE TABLE IF NOT EXISTS `stream_state` "
- + "(`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, "
- + "PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) "
- + "REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )");
- database.execSQL("CREATE TABLE IF NOT EXISTS `playlists` "
- + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
- + "`name` TEXT, `thumbnail_url` TEXT)");
- database.execSQL("CREATE INDEX `index_playlists_name` ON `playlists` (`name`)");
- database.execSQL("CREATE TABLE IF NOT EXISTS `playlist_stream_join` "
- + "(`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, "
- + "`join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), "
- + "FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) "
- + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, "
- + "FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) "
- + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)");
- database.execSQL("CREATE UNIQUE INDEX "
- + "`index_playlist_stream_join_playlist_id_join_index` "
- + "ON `playlist_stream_join` (`playlist_id`, `join_index`)");
- database.execSQL("CREATE INDEX `index_playlist_stream_join_stream_id` "
- + "ON `playlist_stream_join` (`stream_id`)");
- database.execSQL("CREATE TABLE IF NOT EXISTS `remote_playlists` "
- + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
- + "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, "
- + "`thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)");
- database.execSQL("CREATE INDEX `index_remote_playlists_name` "
- + "ON `remote_playlists` (`name`)");
- database.execSQL("CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` "
- + "ON `remote_playlists` (`service_id`, `url`)");
-
- // Populate streams table with existing entries in watch history
- // Latest data first, thus ignoring older entries with the same indices
- database.execSQL("INSERT OR IGNORE INTO streams (service_id, url, title, "
- + "stream_type, duration, uploader, thumbnail_url) "
-
- + "SELECT service_id, url, title, 'VIDEO_STREAM', duration, "
- + "uploader, thumbnail_url "
-
- + "FROM watch_history "
- + "ORDER BY creation_date DESC");
-
- // Once the streams have PKs, join them with the normalized history table
- // and populate it with the remaining data from watch history
- database.execSQL("INSERT INTO stream_history (stream_id, access_date, repeat_count)"
- + "SELECT uid, creation_date, 1 "
- + "FROM watch_history INNER JOIN streams "
- + "ON watch_history.service_id == streams.service_id "
- + "AND watch_history.url == streams.url "
- + "ORDER BY creation_date DESC");
-
- database.execSQL("DROP TABLE IF EXISTS watch_history");
-
- if (DEBUG) {
- Log.d(TAG, "Stop migrating database");
- }
- }
- };
-
- public static final Migration MIGRATION_2_3 = new Migration(DB_VER_2, DB_VER_3) {
- @Override
- public void migrate(@NonNull final SupportSQLiteDatabase database) {
- // Add NOT NULLs and new fields
- database.execSQL("CREATE TABLE IF NOT EXISTS streams_new "
- + "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
- + "service_id INTEGER NOT NULL, url TEXT NOT NULL, title TEXT NOT NULL, "
- + "stream_type TEXT NOT NULL, duration INTEGER NOT NULL, "
- + "uploader TEXT NOT NULL, thumbnail_url TEXT, view_count INTEGER, "
- + "textual_upload_date TEXT, upload_date INTEGER, "
- + "is_upload_date_approximation INTEGER)");
-
- database.execSQL("INSERT INTO streams_new (uid, service_id, url, title, stream_type, "
- + "duration, uploader, thumbnail_url, view_count, textual_upload_date, "
- + "upload_date, is_upload_date_approximation) "
-
- + "SELECT uid, service_id, url, ifnull(title, ''), "
- + "ifnull(stream_type, 'VIDEO_STREAM'), ifnull(duration, 0), "
- + "ifnull(uploader, ''), ifnull(thumbnail_url, ''), NULL, NULL, NULL, NULL "
-
- + "FROM streams WHERE url IS NOT NULL");
-
- database.execSQL("DROP TABLE streams");
- database.execSQL("ALTER TABLE streams_new RENAME TO streams");
- database.execSQL("CREATE UNIQUE INDEX index_streams_service_id_url "
- + "ON streams (service_id, url)");
-
- // Tables for feed feature
- database.execSQL("CREATE TABLE IF NOT EXISTS feed "
- + "(stream_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, "
- + "PRIMARY KEY(stream_id, subscription_id), "
- + "FOREIGN KEY(stream_id) REFERENCES streams(uid) "
- + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, "
- + "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) "
- + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)");
- database.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)");
- database.execSQL("CREATE TABLE IF NOT EXISTS feed_group "
- + "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, "
- + "icon_id INTEGER NOT NULL, sort_order INTEGER NOT NULL)");
- database.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)");
- database.execSQL("CREATE TABLE IF NOT EXISTS feed_group_subscription_join "
- + "(group_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, "
- + "PRIMARY KEY(group_id, subscription_id), "
- + "FOREIGN KEY(group_id) REFERENCES feed_group(uid) "
- + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, "
- + "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) "
- + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)");
- database.execSQL("CREATE INDEX index_feed_group_subscription_join_subscription_id "
- + "ON feed_group_subscription_join (subscription_id)");
- database.execSQL("CREATE TABLE IF NOT EXISTS feed_last_updated "
- + "(subscription_id INTEGER NOT NULL, last_updated INTEGER, "
- + "PRIMARY KEY(subscription_id), "
- + "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) "
- + "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)");
- }
- };
-
- public static final Migration MIGRATION_3_4 = new Migration(DB_VER_3, DB_VER_4) {
- @Override
- public void migrate(@NonNull final SupportSQLiteDatabase database) {
- database.execSQL(
- "ALTER TABLE streams ADD COLUMN uploader_url TEXT"
- );
- }
- };
-
- public static final Migration MIGRATION_4_5 = new Migration(DB_VER_4, DB_VER_5) {
- @Override
- public void migrate(@NonNull final SupportSQLiteDatabase database) {
- database.execSQL("ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` "
- + "INTEGER NOT NULL DEFAULT 0");
- }
- };
-
- public static final Migration MIGRATION_5_6 = new Migration(DB_VER_5, DB_VER_6) {
- @Override
- public void migrate(@NonNull final SupportSQLiteDatabase database) {
- database.execSQL("ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` "
- + "INTEGER NOT NULL DEFAULT 0");
- }
- };
-
- public static final Migration MIGRATION_6_7 = new Migration(DB_VER_6, DB_VER_7) {
- @Override
- public void migrate(@NonNull final SupportSQLiteDatabase database) {
- // Create a new column thumbnail_stream_id
- database.execSQL("ALTER TABLE `playlists` ADD COLUMN `thumbnail_stream_id` "
- + "INTEGER NOT NULL DEFAULT -1");
-
- // Migrate the thumbnail_url to the thumbnail_stream_id
- database.execSQL("UPDATE playlists SET thumbnail_stream_id = ("
- + " SELECT CASE WHEN COUNT(*) != 0 then stream_uid ELSE -1 END"
- + " FROM ("
- + " SELECT p.uid AS playlist_uid, s.uid AS stream_uid"
- + " FROM playlists p"
- + " LEFT JOIN playlist_stream_join ps ON p.uid = ps.playlist_id"
- + " LEFT JOIN streams s ON s.uid = ps.stream_id"
- + " WHERE s.thumbnail_url = p.thumbnail_url) AS temporary_table"
- + " WHERE playlist_uid = playlists.uid)");
-
- // Remove the thumbnail_url field in the playlist table
- database.execSQL("CREATE TABLE IF NOT EXISTS `playlists_new`"
- + "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
- + "name TEXT, "
- + "is_thumbnail_permanent INTEGER NOT NULL, "
- + "thumbnail_stream_id INTEGER NOT NULL)");
-
- database.execSQL("INSERT INTO playlists_new"
- + " SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id "
- + " FROM playlists");
-
-
- database.execSQL("DROP TABLE playlists");
- database.execSQL("ALTER TABLE playlists_new RENAME TO playlists");
- database.execSQL("CREATE INDEX IF NOT EXISTS "
- + "`index_playlists_name` ON `playlists` (`name`)");
- }
- };
-
- public static final Migration MIGRATION_7_8 = new Migration(DB_VER_7, DB_VER_8) {
- @Override
- public void migrate(@NonNull final SupportSQLiteDatabase database) {
- database.execSQL("DELETE FROM search_history WHERE id NOT IN (SELECT id FROM (SELECT "
- + "MIN(id) as id FROM search_history GROUP BY trim(search), service_id ) tmp)");
- database.execSQL("UPDATE search_history SET search = trim(search)");
- }
- };
-
- public static final Migration MIGRATION_8_9 = new Migration(DB_VER_8, DB_VER_9) {
- @Override
- public void migrate(@NonNull final SupportSQLiteDatabase database) {
- try {
- database.beginTransaction();
-
- // Update playlists.
- // Create a temp table to initialize display_index.
- database.execSQL("CREATE TABLE `playlists_tmp` "
- + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
- + "`name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, "
- + "`thumbnail_stream_id` INTEGER NOT NULL, "
- + "`display_index` INTEGER NOT NULL)");
- database.execSQL("INSERT INTO `playlists_tmp` "
- + "(`uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, "
- + "`display_index`) "
- + "SELECT `uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, "
- + "-1 "
- + "FROM `playlists`");
-
- // Replace the old table, note that this also removes the index on the name which
- // we don't need anymore.
- database.execSQL("DROP TABLE `playlists`");
- database.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`");
-
-
- // Update remote_playlists.
- // Create a temp table to initialize display_index.
- database.execSQL("CREATE TABLE `remote_playlists_tmp` "
- + "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, "
- + "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, "
- + "`thumbnail_url` TEXT, `uploader` TEXT, "
- + "`display_index` INTEGER NOT NULL,"
- + "`stream_count` INTEGER)");
- database.execSQL("INSERT INTO `remote_playlists_tmp` (`uid`, `service_id`, "
- + "`name`, `url`, `thumbnail_url`, `uploader`, `display_index`, "
- + "`stream_count`)"
- + "SELECT `uid`, `service_id`, `name`, `url`, `thumbnail_url`, `uploader`, "
- + "-1, `stream_count` FROM `remote_playlists`");
-
- // Replace the old table, note that this also removes the index on the name which
- // we don't need anymore.
- database.execSQL("DROP TABLE `remote_playlists`");
- database.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`");
-
- // Create index on the new table.
- database.execSQL("CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` "
- + "ON `remote_playlists` (`service_id`, `url`)");
-
- database.setTransactionSuccessful();
- } finally {
- database.endTransaction();
- }
- }
- };
-
- private Migrations() {
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/Migrations.kt b/app/src/main/java/org/schabi/newpipe/database/Migrations.kt
new file mode 100644
index 00000000000..8988708e679
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/Migrations.kt
@@ -0,0 +1,368 @@
+/*
+ * SPDX-FileCopyrightText: 2018-2024 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database
+
+import android.util.Log
+import androidx.room.migration.Migration
+import androidx.sqlite.db.SupportSQLiteDatabase
+import org.schabi.newpipe.MainActivity
+
+object Migrations {
+
+ // /////////////////////////////////////////////////////////////////////// //
+ // Test new migrations manually by importing a database from daily usage //
+ // and checking if the migration works (Use the Database Inspector //
+ // https://developer.android.com/studio/inspect/database). //
+ // If you add a migration point it out in the pull request, so that //
+ // others remember to test it themselves. //
+ // /////////////////////////////////////////////////////////////////////// //
+
+ const val DB_VER_1 = 1
+ const val DB_VER_2 = 2
+ const val DB_VER_3 = 3
+ const val DB_VER_4 = 4
+ const val DB_VER_5 = 5
+ const val DB_VER_6 = 6
+ const val DB_VER_7 = 7
+ const val DB_VER_8 = 8
+ const val DB_VER_9 = 9
+
+ private val TAG = Migrations::class.java.getName()
+ private val isDebug = MainActivity.DEBUG
+
+ val MIGRATION_1_2 = object : Migration(DB_VER_1, DB_VER_2) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ if (isDebug) {
+ Log.d(TAG, "Start migrating database")
+ }
+
+ /*
+ * Unfortunately these queries must be hardcoded due to the possibility of
+ * schema and names changing at a later date, thus invalidating the older migration
+ * scripts if they are not hardcoded.
+ * */
+
+ // Not much we can do about this, since room doesn't create tables before migration.
+ // It's either this or blasting the entire database anew.
+ db.execSQL(
+ "CREATE INDEX `index_search_history_search` " +
+ "ON `search_history` (`search`)"
+ )
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS `streams` " +
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
+ "`service_id` INTEGER NOT NULL, `url` TEXT, `title` TEXT, " +
+ "`stream_type` TEXT, `duration` INTEGER, `uploader` TEXT, " +
+ "`thumbnail_url` TEXT)"
+ )
+ db.execSQL(
+ "CREATE UNIQUE INDEX `index_streams_service_id_url` " +
+ "ON `streams` (`service_id`, `url`)"
+ )
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS `stream_history` " +
+ "(`stream_id` INTEGER NOT NULL, `access_date` INTEGER NOT NULL, " +
+ "`repeat_count` INTEGER NOT NULL, PRIMARY KEY(`stream_id`, `access_date`), " +
+ "FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) " +
+ "ON UPDATE CASCADE ON DELETE CASCADE )"
+ )
+ db.execSQL(
+ "CREATE INDEX `index_stream_history_stream_id` " +
+ "ON `stream_history` (`stream_id`)"
+ )
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS `stream_state` " +
+ "(`stream_id` INTEGER NOT NULL, `progress_time` INTEGER NOT NULL, " +
+ "PRIMARY KEY(`stream_id`), FOREIGN KEY(`stream_id`) " +
+ "REFERENCES `streams`(`uid`) ON UPDATE CASCADE ON DELETE CASCADE )"
+ )
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS `playlists` " +
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
+ "`name` TEXT, `thumbnail_url` TEXT)"
+ )
+ db.execSQL("CREATE INDEX `index_playlists_name` ON `playlists` (`name`)")
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS `playlist_stream_join` " +
+ "(`playlist_id` INTEGER NOT NULL, `stream_id` INTEGER NOT NULL, " +
+ "`join_index` INTEGER NOT NULL, PRIMARY KEY(`playlist_id`, `join_index`), " +
+ "FOREIGN KEY(`playlist_id`) REFERENCES `playlists`(`uid`) " +
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " +
+ "FOREIGN KEY(`stream_id`) REFERENCES `streams`(`uid`) " +
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
+ )
+ db.execSQL(
+ "CREATE UNIQUE INDEX " +
+ "`index_playlist_stream_join_playlist_id_join_index` " +
+ "ON `playlist_stream_join` (`playlist_id`, `join_index`)"
+ )
+ db.execSQL(
+ "CREATE INDEX `index_playlist_stream_join_stream_id` " +
+ "ON `playlist_stream_join` (`stream_id`)"
+ )
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS `remote_playlists` " +
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
+ "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " +
+ "`thumbnail_url` TEXT, `uploader` TEXT, `stream_count` INTEGER)"
+ )
+ db.execSQL(
+ "CREATE INDEX `index_remote_playlists_name` " +
+ "ON `remote_playlists` (`name`)"
+ )
+ db.execSQL(
+ "CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " +
+ "ON `remote_playlists` (`service_id`, `url`)"
+ )
+
+ // Populate streams table with existing entries in watch history
+ // Latest data first, thus ignoring older entries with the same indices
+ db.execSQL(
+ "INSERT OR IGNORE INTO streams (service_id, url, title, " +
+ "stream_type, duration, uploader, thumbnail_url) " +
+
+ "SELECT service_id, url, title, 'VIDEO_STREAM', duration, " +
+ "uploader, thumbnail_url " +
+
+ "FROM watch_history " +
+ "ORDER BY creation_date DESC"
+ )
+
+ // Once the streams have PKs, join them with the normalized history table
+ // and populate it with the remaining data from watch history
+ db.execSQL(
+ "INSERT INTO stream_history (stream_id, access_date, repeat_count)" +
+ "SELECT uid, creation_date, 1 " +
+ "FROM watch_history INNER JOIN streams " +
+ "ON watch_history.service_id == streams.service_id " +
+ "AND watch_history.url == streams.url " +
+ "ORDER BY creation_date DESC"
+ )
+
+ db.execSQL("DROP TABLE IF EXISTS watch_history")
+
+ if (isDebug) {
+ Log.d(TAG, "Stop migrating database")
+ }
+ }
+ }
+
+ val MIGRATION_2_3 = object : Migration(DB_VER_2, DB_VER_3) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ // Add NOT NULLs and new fields
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS streams_new " +
+ "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
+ "service_id INTEGER NOT NULL, url TEXT NOT NULL, title TEXT NOT NULL, " +
+ "stream_type TEXT NOT NULL, duration INTEGER NOT NULL, " +
+ "uploader TEXT NOT NULL, thumbnail_url TEXT, view_count INTEGER, " +
+ "textual_upload_date TEXT, upload_date INTEGER, " +
+ "is_upload_date_approximation INTEGER)"
+ )
+
+ db.execSQL(
+ "INSERT INTO streams_new (uid, service_id, url, title, stream_type, " +
+ "duration, uploader, thumbnail_url, view_count, textual_upload_date, " +
+ "upload_date, is_upload_date_approximation) " +
+
+ "SELECT uid, service_id, url, ifnull(title, ''), " +
+ "ifnull(stream_type, 'VIDEO_STREAM'), ifnull(duration, 0), " +
+ "ifnull(uploader, ''), ifnull(thumbnail_url, ''), NULL, NULL, NULL, NULL " +
+
+ "FROM streams WHERE url IS NOT NULL"
+ )
+
+ db.execSQL("DROP TABLE streams")
+ db.execSQL("ALTER TABLE streams_new RENAME TO streams")
+ db.execSQL(
+ "CREATE UNIQUE INDEX index_streams_service_id_url " +
+ "ON streams (service_id, url)"
+ )
+
+ // Tables for feed feature
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS feed " +
+ "(stream_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, " +
+ "PRIMARY KEY(stream_id, subscription_id), " +
+ "FOREIGN KEY(stream_id) REFERENCES streams(uid) " +
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " +
+ "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " +
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
+ )
+ db.execSQL("CREATE INDEX index_feed_subscription_id ON feed (subscription_id)")
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS feed_group " +
+ "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT NULL, " +
+ "icon_id INTEGER NOT NULL, sort_order INTEGER NOT NULL)"
+ )
+ db.execSQL("CREATE INDEX index_feed_group_sort_order ON feed_group (sort_order)")
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS feed_group_subscription_join " +
+ "(group_id INTEGER NOT NULL, subscription_id INTEGER NOT NULL, " +
+ "PRIMARY KEY(group_id, subscription_id), " +
+ "FOREIGN KEY(group_id) REFERENCES feed_group(uid) " +
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED, " +
+ "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " +
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
+ )
+ db.execSQL(
+ "CREATE INDEX index_feed_group_subscription_join_subscription_id " +
+ "ON feed_group_subscription_join (subscription_id)"
+ )
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS feed_last_updated " +
+ "(subscription_id INTEGER NOT NULL, last_updated INTEGER, " +
+ "PRIMARY KEY(subscription_id), " +
+ "FOREIGN KEY(subscription_id) REFERENCES subscriptions(uid) " +
+ "ON UPDATE CASCADE ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED)"
+ )
+ }
+ }
+
+ val MIGRATION_3_4 = object : Migration(DB_VER_3, DB_VER_4) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL("ALTER TABLE streams ADD COLUMN uploader_url TEXT")
+ }
+ }
+
+ val MIGRATION_4_5 = object : Migration(DB_VER_4, DB_VER_5) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ "ALTER TABLE `subscriptions` ADD COLUMN `notification_mode` " +
+ "INTEGER NOT NULL DEFAULT 0"
+ )
+ }
+ }
+
+ val MIGRATION_5_6 = object : Migration(DB_VER_5, DB_VER_6) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ "ALTER TABLE `playlists` ADD COLUMN `is_thumbnail_permanent` " +
+ "INTEGER NOT NULL DEFAULT 0"
+ )
+ }
+ }
+
+ val MIGRATION_6_7 = object : Migration(DB_VER_6, DB_VER_7) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ // Create a new column thumbnail_stream_id
+ db.execSQL(
+ "ALTER TABLE `playlists` ADD COLUMN `thumbnail_stream_id` " +
+ "INTEGER NOT NULL DEFAULT -1"
+ )
+
+ // Migrate the thumbnail_url to the thumbnail_stream_id
+ db.execSQL(
+ "UPDATE playlists SET thumbnail_stream_id = (" +
+ " SELECT CASE WHEN COUNT(*) != 0 then stream_uid ELSE -1 END" +
+ " FROM (" +
+ " SELECT p.uid AS playlist_uid, s.uid AS stream_uid" +
+ " FROM playlists p" +
+ " LEFT JOIN playlist_stream_join ps ON p.uid = ps.playlist_id" +
+ " LEFT JOIN streams s ON s.uid = ps.stream_id" +
+ " WHERE s.thumbnail_url = p.thumbnail_url) AS temporary_table" +
+ " WHERE playlist_uid = playlists.uid)"
+ )
+
+ // Remove the thumbnail_url field in the playlist table
+ db.execSQL(
+ "CREATE TABLE IF NOT EXISTS `playlists_new`" +
+ "(uid INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
+ "name TEXT, " +
+ "is_thumbnail_permanent INTEGER NOT NULL, " +
+ "thumbnail_stream_id INTEGER NOT NULL)"
+ )
+
+ db.execSQL(
+ "INSERT INTO playlists_new" +
+ " SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id " +
+ " FROM playlists"
+ )
+
+ db.execSQL("DROP TABLE playlists")
+ db.execSQL("ALTER TABLE playlists_new RENAME TO playlists")
+ db.execSQL(
+ "CREATE INDEX IF NOT EXISTS " +
+ "`index_playlists_name` ON `playlists` (`name`)"
+ )
+ }
+ }
+
+ val MIGRATION_7_8 = object : Migration(DB_VER_7, DB_VER_8) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ db.execSQL(
+ "DELETE FROM search_history WHERE id NOT IN (SELECT id FROM (SELECT " +
+ "MIN(id) as id FROM search_history GROUP BY trim(search), service_id ) tmp)"
+ )
+ db.execSQL("UPDATE search_history SET search = trim(search)")
+ }
+ }
+
+ val MIGRATION_8_9 = object : Migration(DB_VER_8, DB_VER_9) {
+ override fun migrate(db: SupportSQLiteDatabase) {
+ try {
+ db.beginTransaction()
+
+ // Update playlists.
+ // Create a temp table to initialize display_index.
+ db.execSQL(
+ "CREATE TABLE `playlists_tmp` " +
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
+ "`name` TEXT, `is_thumbnail_permanent` INTEGER NOT NULL, " +
+ "`thumbnail_stream_id` INTEGER NOT NULL, " +
+ "`display_index` INTEGER NOT NULL)"
+ )
+ db.execSQL(
+ "INSERT INTO `playlists_tmp` " +
+ "(`uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " +
+ "`display_index`) " +
+ "SELECT `uid`, `name`, `is_thumbnail_permanent`, `thumbnail_stream_id`, " +
+ "-1 " +
+ "FROM `playlists`"
+ )
+
+ // Replace the old table, note that this also removes the index on the name which
+ // we don't need anymore.
+ db.execSQL("DROP TABLE `playlists`")
+ db.execSQL("ALTER TABLE `playlists_tmp` RENAME TO `playlists`")
+
+ // Update remote_playlists.
+ // Create a temp table to initialize display_index.
+ db.execSQL(
+ "CREATE TABLE `remote_playlists_tmp` " +
+ "(`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, " +
+ "`service_id` INTEGER NOT NULL, `name` TEXT, `url` TEXT, " +
+ "`thumbnail_url` TEXT, `uploader` TEXT, " +
+ "`display_index` INTEGER NOT NULL," +
+ "`stream_count` INTEGER)"
+ )
+ db.execSQL(
+ "INSERT INTO `remote_playlists_tmp` (`uid`, `service_id`, " +
+ "`name`, `url`, `thumbnail_url`, `uploader`, `display_index`, " +
+ "`stream_count`)" +
+ "SELECT `uid`, `service_id`, `name`, `url`, `thumbnail_url`, `uploader`, " +
+ "-1, `stream_count` FROM `remote_playlists`"
+ )
+
+ // Replace the old table, note that this also removes the index on the name which
+ // we don't need anymore.
+ db.execSQL("DROP TABLE `remote_playlists`")
+ db.execSQL("ALTER TABLE `remote_playlists_tmp` RENAME TO `remote_playlists`")
+
+ // Create index on the new table.
+ db.execSQL(
+ "CREATE UNIQUE INDEX `index_remote_playlists_service_id_url` " +
+ "ON `remote_playlists` (`service_id`, `url`)"
+ )
+
+ db.setTransactionSuccessful()
+ } finally {
+ db.endTransaction()
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt
index e7ed934977a..d756df8b1f7 100644
--- a/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt
+++ b/app/src/main/java/org/schabi/newpipe/database/feed/dao/FeedDAO.kt
@@ -168,10 +168,10 @@ abstract class FeedDAO {
ON fgs.subscription_id = lu.subscription_id AND fgs.group_id = :groupId
"""
)
- abstract fun oldestSubscriptionUpdate(groupId: Long): Flowable>
+ abstract fun oldestSubscriptionUpdate(groupId: Long): Flowable>
@Query("SELECT MIN(last_updated) FROM feed_last_updated")
- abstract fun oldestSubscriptionUpdateFromAll(): Flowable>
+ abstract fun oldestSubscriptionUpdateFromAll(): Flowable>
@Query("SELECT COUNT(*) FROM feed_last_updated WHERE last_updated IS NULL")
abstract fun notLoadedCount(): Flowable
diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/HistoryDAO.java b/app/src/main/java/org/schabi/newpipe/database/history/dao/HistoryDAO.java
deleted file mode 100644
index 1ade08122c8..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/history/dao/HistoryDAO.java
+++ /dev/null
@@ -1,7 +0,0 @@
-package org.schabi.newpipe.database.history.dao;
-
-import org.schabi.newpipe.database.BasicDAO;
-
-public interface HistoryDAO extends BasicDAO {
- T getLatestEntry();
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.java b/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.java
deleted file mode 100644
index 8a281bdb48c..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.java
+++ /dev/null
@@ -1,52 +0,0 @@
-package org.schabi.newpipe.database.history.dao;
-
-import androidx.annotation.Nullable;
-import androidx.room.Dao;
-import androidx.room.Query;
-
-import org.schabi.newpipe.database.history.model.SearchHistoryEntry;
-
-import java.util.List;
-
-import io.reactivex.rxjava3.core.Flowable;
-
-import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.CREATION_DATE;
-import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.ID;
-import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.SEARCH;
-import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.SERVICE_ID;
-import static org.schabi.newpipe.database.history.model.SearchHistoryEntry.TABLE_NAME;
-
-@Dao
-public interface SearchHistoryDAO extends HistoryDAO {
- String ORDER_BY_CREATION_DATE = " ORDER BY " + CREATION_DATE + " DESC";
- String ORDER_BY_MAX_CREATION_DATE = " ORDER BY MAX(" + CREATION_DATE + ") DESC";
-
- @Query("SELECT * FROM " + TABLE_NAME
- + " WHERE " + ID + " = (SELECT MAX(" + ID + ") FROM " + TABLE_NAME + ")")
- @Nullable
- SearchHistoryEntry getLatestEntry();
-
- @Query("DELETE FROM " + TABLE_NAME)
- @Override
- int deleteAll();
-
- @Query("DELETE FROM " + TABLE_NAME + " WHERE " + SEARCH + " = :query")
- int deleteAllWhereQuery(String query);
-
- @Query("SELECT * FROM " + TABLE_NAME + ORDER_BY_CREATION_DATE)
- @Override
- Flowable> getAll();
-
- @Query("SELECT " + SEARCH + " FROM " + TABLE_NAME + " GROUP BY " + SEARCH
- + ORDER_BY_MAX_CREATION_DATE + " LIMIT :limit")
- Flowable> getUniqueEntries(int limit);
-
- @Query("SELECT * FROM " + TABLE_NAME
- + " WHERE " + SERVICE_ID + " = :serviceId" + ORDER_BY_CREATION_DATE)
- @Override
- Flowable> listByService(int serviceId);
-
- @Query("SELECT " + SEARCH + " FROM " + TABLE_NAME + " WHERE " + SEARCH + " LIKE :query || '%'"
- + " GROUP BY " + SEARCH + ORDER_BY_MAX_CREATION_DATE + " LIMIT :limit")
- Flowable> getSimilarEntries(String query, int limit);
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.kt b/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.kt
new file mode 100644
index 00000000000..ddcb00489e3
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/history/dao/SearchHistoryDAO.kt
@@ -0,0 +1,43 @@
+/*
+ * SPDX-FileCopyrightText: 2017-2021 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database.history.dao
+
+import androidx.room.Dao
+import androidx.room.Query
+import io.reactivex.rxjava3.core.Flowable
+import org.schabi.newpipe.database.BasicDAO
+import org.schabi.newpipe.database.history.model.SearchHistoryEntry
+
+@Dao
+interface SearchHistoryDAO : BasicDAO {
+
+ @get:Query("SELECT * FROM search_history WHERE id = (SELECT MAX(id) FROM search_history)")
+ val latestEntry: SearchHistoryEntry?
+
+ @Query("DELETE FROM search_history")
+ override fun deleteAll(): Int
+
+ @Query("DELETE FROM search_history WHERE search = :query")
+ fun deleteAllWhereQuery(query: String): Int
+
+ @Query("SELECT * FROM search_history ORDER BY creation_date DESC")
+ override fun getAll(): Flowable>
+
+ @Query("SELECT search FROM search_history GROUP BY search ORDER BY MAX(creation_date) DESC LIMIT :limit")
+ fun getUniqueEntries(limit: Int): Flowable>
+
+ @Query("SELECT * FROM search_history WHERE service_id = :serviceId ORDER BY creation_date DESC")
+ override fun listByService(serviceId: Int): Flowable>
+
+ @Query(
+ """
+ SELECT search FROM search_history WHERE search LIKE :query ||
+ '%' GROUP BY search ORDER BY MAX(creation_date) DESC LIMIT :limit
+ """
+ )
+ fun getSimilarEntries(query: String, limit: Int): Flowable>
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java
deleted file mode 100644
index 150d4a8e5b5..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.java
+++ /dev/null
@@ -1,89 +0,0 @@
-package org.schabi.newpipe.database.history.dao;
-
-import androidx.annotation.Nullable;
-import androidx.room.Dao;
-import androidx.room.Query;
-import androidx.room.RewriteQueriesToDropUnusedColumns;
-
-import org.schabi.newpipe.database.history.model.StreamHistoryEntity;
-import org.schabi.newpipe.database.history.model.StreamHistoryEntry;
-import org.schabi.newpipe.database.stream.StreamStatisticsEntry;
-
-import java.util.List;
-
-import io.reactivex.rxjava3.core.Flowable;
-
-import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.JOIN_STREAM_ID;
-import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_ACCESS_DATE;
-import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_HISTORY_TABLE;
-import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_REPEAT_COUNT;
-import static org.schabi.newpipe.database.stream.StreamStatisticsEntry.STREAM_LATEST_DATE;
-import static org.schabi.newpipe.database.stream.StreamStatisticsEntry.STREAM_WATCH_COUNT;
-import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID;
-import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE;
-import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS;
-import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS;
-import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
-
-@Dao
-public abstract class StreamHistoryDAO implements HistoryDAO {
- @Query("SELECT * FROM " + STREAM_HISTORY_TABLE
- + " WHERE " + STREAM_ACCESS_DATE + " = "
- + "(SELECT MAX(" + STREAM_ACCESS_DATE + ") FROM " + STREAM_HISTORY_TABLE + ")")
- @Override
- @Nullable
- public abstract StreamHistoryEntity getLatestEntry();
-
- @Override
- @Query("SELECT * FROM " + STREAM_HISTORY_TABLE)
- public abstract Flowable> getAll();
-
- @Override
- @Query("DELETE FROM " + STREAM_HISTORY_TABLE)
- public abstract int deleteAll();
-
- @Override
- public Flowable> listByService(final int serviceId) {
- throw new UnsupportedOperationException();
- }
-
- @Query("SELECT * FROM " + STREAM_TABLE
- + " INNER JOIN " + STREAM_HISTORY_TABLE
- + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
- + " ORDER BY " + STREAM_ACCESS_DATE + " DESC")
- public abstract Flowable> getHistory();
-
-
- @Query("SELECT * FROM " + STREAM_TABLE
- + " INNER JOIN " + STREAM_HISTORY_TABLE
- + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
- + " ORDER BY " + STREAM_ID + " ASC")
- public abstract Flowable> getHistorySortedById();
-
- @Query("SELECT * FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID
- + " = :streamId ORDER BY " + STREAM_ACCESS_DATE + " DESC LIMIT 1")
- @Nullable
- public abstract StreamHistoryEntity getLatestEntry(long streamId);
-
- @Query("DELETE FROM " + STREAM_HISTORY_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
- public abstract int deleteStreamHistory(long streamId);
-
- @RewriteQueriesToDropUnusedColumns
- @Query("SELECT * FROM " + STREAM_TABLE
-
- // Select the latest entry and watch count for each stream id on history table
- + " INNER JOIN "
- + "(SELECT " + JOIN_STREAM_ID + ", "
- + " MAX(" + STREAM_ACCESS_DATE + ") AS " + STREAM_LATEST_DATE + ", "
- + " SUM(" + STREAM_REPEAT_COUNT + ") AS " + STREAM_WATCH_COUNT
- + " FROM " + STREAM_HISTORY_TABLE + " GROUP BY " + JOIN_STREAM_ID + ")"
-
- + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
-
- + " LEFT JOIN "
- + "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", "
- + STREAM_PROGRESS_MILLIS
- + " FROM " + STREAM_STATE_TABLE + " )"
- + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS)
- public abstract Flowable> getStatistics();
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt
new file mode 100644
index 00000000000..916d4e5ed21
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/history/dao/StreamHistoryDAO.kt
@@ -0,0 +1,61 @@
+/*
+ * SPDX-FileCopyrightText: 2018-2022 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database.history.dao
+
+import androidx.room.Dao
+import androidx.room.Query
+import androidx.room.RewriteQueriesToDropUnusedColumns
+import io.reactivex.rxjava3.core.Flowable
+import org.schabi.newpipe.database.BasicDAO
+import org.schabi.newpipe.database.history.model.StreamHistoryEntity
+import org.schabi.newpipe.database.history.model.StreamHistoryEntry
+import org.schabi.newpipe.database.stream.StreamStatisticsEntry
+
+@Dao
+abstract class StreamHistoryDAO : BasicDAO {
+
+ @Query("SELECT * FROM stream_history")
+ abstract override fun getAll(): Flowable>
+
+ @Query("DELETE FROM stream_history")
+ abstract override fun deleteAll(): Int
+
+ override fun listByService(serviceId: Int): Flowable> {
+ throw UnsupportedOperationException()
+ }
+
+ @get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY access_date DESC")
+ abstract val history: Flowable>
+
+ @get:Query("SELECT * FROM streams INNER JOIN stream_history ON uid = stream_id ORDER BY uid ASC")
+ abstract val historySortedById: Flowable>
+
+ @Query("SELECT * FROM stream_history WHERE stream_id = :streamId ORDER BY access_date DESC LIMIT 1")
+ abstract fun getLatestEntry(streamId: Long): StreamHistoryEntity?
+
+ @Query("DELETE FROM stream_history WHERE stream_id = :streamId")
+ abstract fun deleteStreamHistory(streamId: Long): Int
+
+ // Select the latest entry and watch count for each stream id on history table
+ @RewriteQueriesToDropUnusedColumns
+ @Query(
+ """
+ SELECT * FROM streams
+
+ INNER JOIN (
+ SELECT stream_id, MAX(access_date) AS latestAccess, SUM(repeat_count) AS watchCount
+ FROM stream_history
+ GROUP BY stream_id
+ )
+ ON uid = stream_id
+
+ LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state )
+ ON uid = stream_id_alias
+ """
+ )
+ abstract fun getStatistics(): Flowable>
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.kt b/app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.kt
index 8cb9a25ca17..e6006a0694d 100644
--- a/app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.kt
+++ b/app/src/main/java/org/schabi/newpipe/database/history/model/SearchHistoryEntry.kt
@@ -1,3 +1,9 @@
+/*
+ * SPDX-FileCopyrightText: 2022 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
package org.schabi.newpipe.database.history.model
import androidx.room.ColumnInfo
@@ -11,23 +17,24 @@ import java.time.OffsetDateTime
tableName = SearchHistoryEntry.TABLE_NAME,
indices = [Index(value = [SearchHistoryEntry.SEARCH])]
)
-data class SearchHistoryEntry(
- @field:ColumnInfo(name = CREATION_DATE) var creationDate: OffsetDateTime?,
- @field:ColumnInfo(
- name = SERVICE_ID
- ) var serviceId: Int,
- @field:ColumnInfo(name = SEARCH) var search: String?
-) {
+data class SearchHistoryEntry @JvmOverloads constructor(
+ @ColumnInfo(name = CREATION_DATE)
+ var creationDate: OffsetDateTime?,
+
+ @ColumnInfo(name = SERVICE_ID)
+ val serviceId: Int,
+
+ @ColumnInfo(name = SEARCH)
+ val search: String?,
+
@ColumnInfo(name = ID)
@PrimaryKey(autoGenerate = true)
- var id: Long = 0
+ val id: Long = 0,
+) {
@Ignore
fun hasEqualValues(otherEntry: SearchHistoryEntry): Boolean {
- return (
- serviceId == otherEntry.serviceId &&
- search == otherEntry.search
- )
+ return serviceId == otherEntry.serviceId && search == otherEntry.search
}
companion object {
diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.java b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.java
deleted file mode 100644
index a9d69afe855..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.java
+++ /dev/null
@@ -1,81 +0,0 @@
-package org.schabi.newpipe.database.history.model;
-
-import androidx.annotation.NonNull;
-import androidx.room.ColumnInfo;
-import androidx.room.Entity;
-import androidx.room.ForeignKey;
-import androidx.room.Index;
-
-import org.schabi.newpipe.database.stream.model.StreamEntity;
-
-import java.time.OffsetDateTime;
-
-import static androidx.room.ForeignKey.CASCADE;
-import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.JOIN_STREAM_ID;
-import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_ACCESS_DATE;
-import static org.schabi.newpipe.database.history.model.StreamHistoryEntity.STREAM_HISTORY_TABLE;
-
-@Entity(tableName = STREAM_HISTORY_TABLE,
- primaryKeys = {JOIN_STREAM_ID, STREAM_ACCESS_DATE},
- // No need to index for timestamp as they will almost always be unique
- indices = {@Index(value = {JOIN_STREAM_ID})},
- foreignKeys = {
- @ForeignKey(entity = StreamEntity.class,
- parentColumns = StreamEntity.STREAM_ID,
- childColumns = JOIN_STREAM_ID,
- onDelete = CASCADE, onUpdate = CASCADE)
- })
-public class StreamHistoryEntity {
- public static final String STREAM_HISTORY_TABLE = "stream_history";
- public static final String JOIN_STREAM_ID = "stream_id";
- public static final String STREAM_ACCESS_DATE = "access_date";
- public static final String STREAM_REPEAT_COUNT = "repeat_count";
-
- @ColumnInfo(name = JOIN_STREAM_ID)
- private long streamUid;
-
- @NonNull
- @ColumnInfo(name = STREAM_ACCESS_DATE)
- private OffsetDateTime accessDate;
-
- @ColumnInfo(name = STREAM_REPEAT_COUNT)
- private long repeatCount;
-
- /**
- * @param streamUid the stream id this history item will refer to
- * @param accessDate the last time the stream was accessed
- * @param repeatCount the total number of views this stream received
- */
- public StreamHistoryEntity(final long streamUid,
- @NonNull final OffsetDateTime accessDate,
- final long repeatCount) {
- this.streamUid = streamUid;
- this.accessDate = accessDate;
- this.repeatCount = repeatCount;
- }
-
- public long getStreamUid() {
- return streamUid;
- }
-
- public void setStreamUid(final long streamUid) {
- this.streamUid = streamUid;
- }
-
- @NonNull
- public OffsetDateTime getAccessDate() {
- return accessDate;
- }
-
- public void setAccessDate(@NonNull final OffsetDateTime accessDate) {
- this.accessDate = accessDate;
- }
-
- public long getRepeatCount() {
- return repeatCount;
- }
-
- public void setRepeatCount(final long repeatCount) {
- this.repeatCount = repeatCount;
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.kt b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.kt
new file mode 100644
index 00000000000..db41e141c5d
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/history/model/StreamHistoryEntity.kt
@@ -0,0 +1,56 @@
+/*
+ * SPDX-FileCopyrightText: 2018-2022 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database.history.model
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.ForeignKey.Companion.CASCADE
+import androidx.room.Index
+import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.JOIN_STREAM_ID
+import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_ACCESS_DATE
+import org.schabi.newpipe.database.history.model.StreamHistoryEntity.Companion.STREAM_HISTORY_TABLE
+import org.schabi.newpipe.database.stream.model.StreamEntity
+import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
+import java.time.OffsetDateTime
+
+/**
+ * @param streamUid the stream id this history item will refer to
+ * @param accessDate the last time the stream was accessed
+ * @param repeatCount the total number of views this stream received
+ */
+@Entity(
+ tableName = STREAM_HISTORY_TABLE,
+ primaryKeys = [JOIN_STREAM_ID, STREAM_ACCESS_DATE],
+ indices = [Index(value = [JOIN_STREAM_ID])],
+ foreignKeys = [
+ ForeignKey(
+ entity = StreamEntity::class,
+ parentColumns = arrayOf(STREAM_ID),
+ childColumns = arrayOf(JOIN_STREAM_ID),
+ onDelete = CASCADE,
+ onUpdate = CASCADE
+ )
+ ]
+)
+data class StreamHistoryEntity(
+ @ColumnInfo(name = JOIN_STREAM_ID)
+ val streamUid: Long,
+
+ @ColumnInfo(name = STREAM_ACCESS_DATE)
+ var accessDate: OffsetDateTime,
+
+ @ColumnInfo(name = STREAM_REPEAT_COUNT)
+ var repeatCount: Long
+) {
+ companion object {
+ const val STREAM_HISTORY_TABLE: String = "stream_history"
+ const val STREAM_ACCESS_DATE: String = "access_date"
+ const val JOIN_STREAM_ID: String = "stream_id"
+ const val STREAM_REPEAT_COUNT: String = "repeat_count"
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.java
deleted file mode 100644
index 3be85e6e1cb..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.java
+++ /dev/null
@@ -1,29 +0,0 @@
-package org.schabi.newpipe.database.playlist;
-
-import androidx.room.ColumnInfo;
-
-/**
- * This class adds a field to {@link PlaylistMetadataEntry} that contains an integer representing
- * how many times a specific stream is already contained inside a local playlist. Used to be able
- * to grey out playlists which already contain the current stream in the playlist append dialog.
- * @see org.schabi.newpipe.local.playlist.LocalPlaylistManager#getPlaylistDuplicates(String)
- */
-public class PlaylistDuplicatesEntry extends PlaylistMetadataEntry {
- public static final String PLAYLIST_TIMES_STREAM_IS_CONTAINED = "timesStreamIsContained";
- @ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED)
- public final long timesStreamIsContained;
-
- @SuppressWarnings("checkstyle:ParameterNumber")
- public PlaylistDuplicatesEntry(final long uid,
- final String name,
- final String thumbnailUrl,
- final boolean isThumbnailPermanent,
- final long thumbnailStreamId,
- final long displayIndex,
- final long streamCount,
- final long timesStreamIsContained) {
- super(uid, name, thumbnailUrl, isThumbnailPermanent, thumbnailStreamId, displayIndex,
- streamCount);
- this.timesStreamIsContained = timesStreamIsContained;
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.kt
new file mode 100644
index 00000000000..84972a89e37
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistDuplicatesEntry.kt
@@ -0,0 +1,54 @@
+/*
+ * SPDX-FileCopyrightText: 2023-2024 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database.playlist
+
+import androidx.room.ColumnInfo
+import org.schabi.newpipe.database.playlist.model.PlaylistEntity
+
+/**
+ * This class adds a field to [PlaylistMetadataEntry] that contains an integer representing
+ * how many times a specific stream is already contained inside a local playlist. Used to be able
+ * to grey out playlists which already contain the current stream in the playlist append dialog.
+ * @see org.schabi.newpipe.local.playlist.LocalPlaylistManager.getPlaylistDuplicates
+ */
+data class PlaylistDuplicatesEntry(
+ @ColumnInfo(name = PlaylistEntity.PLAYLIST_ID)
+ override val uid: Long,
+
+ @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_URL)
+ override val thumbnailUrl: String?,
+
+ @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT)
+ override val isThumbnailPermanent: Boolean?,
+
+ @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID)
+ override val thumbnailStreamId: Long?,
+
+ @ColumnInfo(name = PlaylistEntity.PLAYLIST_DISPLAY_INDEX)
+ override var displayIndex: Long?,
+
+ @ColumnInfo(name = PLAYLIST_STREAM_COUNT)
+ override val streamCount: Long,
+
+ @ColumnInfo(name = PlaylistEntity.PLAYLIST_NAME)
+ override val orderingName: String?,
+
+ @ColumnInfo(name = PLAYLIST_TIMES_STREAM_IS_CONTAINED)
+ val timesStreamIsContained: Long
+) : PlaylistMetadataEntry(
+ uid = uid,
+ orderingName = orderingName,
+ thumbnailUrl = thumbnailUrl,
+ isThumbnailPermanent = isThumbnailPermanent,
+ thumbnailStreamId = thumbnailStreamId,
+ displayIndex = displayIndex,
+ streamCount = streamCount
+) {
+ companion object {
+ const val PLAYLIST_TIMES_STREAM_IS_CONTAINED: String = "timesStreamIsContained"
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java
deleted file mode 100644
index 91f4622e99a..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.java
+++ /dev/null
@@ -1,18 +0,0 @@
-package org.schabi.newpipe.database.playlist;
-
-import androidx.annotation.Nullable;
-
-import org.schabi.newpipe.database.LocalItem;
-
-public interface PlaylistLocalItem extends LocalItem {
- String getOrderingName();
-
- long getDisplayIndex();
-
- long getUid();
-
- void setDisplayIndex(long displayIndex);
-
- @Nullable
- String getThumbnailUrl();
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt
new file mode 100644
index 00000000000..4f2f79aa05b
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistLocalItem.kt
@@ -0,0 +1,16 @@
+/*
+ * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database.playlist
+
+import org.schabi.newpipe.database.LocalItem
+
+interface PlaylistLocalItem : LocalItem {
+ val orderingName: String?
+ val displayIndex: Long?
+ val uid: Long
+ val thumbnailUrl: String?
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java
deleted file mode 100644
index 8fbadb02052..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.java
+++ /dev/null
@@ -1,82 +0,0 @@
-package org.schabi.newpipe.database.playlist;
-
-import androidx.room.ColumnInfo;
-
-import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_DISPLAY_INDEX;
-import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID;
-import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME;
-import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT;
-import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID;
-import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL;
-
-import androidx.annotation.Nullable;
-
-public class PlaylistMetadataEntry implements PlaylistLocalItem {
- public static final String PLAYLIST_STREAM_COUNT = "streamCount";
-
- @ColumnInfo(name = PLAYLIST_ID)
- private final long uid;
- @ColumnInfo(name = PLAYLIST_NAME)
- public final String name;
- @ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT)
- private final boolean isThumbnailPermanent;
- @ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID)
- private final long thumbnailStreamId;
- @ColumnInfo(name = PLAYLIST_THUMBNAIL_URL)
- public final String thumbnailUrl;
- @ColumnInfo(name = PLAYLIST_DISPLAY_INDEX)
- private long displayIndex;
- @ColumnInfo(name = PLAYLIST_STREAM_COUNT)
- public final long streamCount;
-
- public PlaylistMetadataEntry(final long uid, final String name, final String thumbnailUrl,
- final boolean isThumbnailPermanent, final long thumbnailStreamId,
- final long displayIndex, final long streamCount) {
- this.uid = uid;
- this.name = name;
- this.thumbnailUrl = thumbnailUrl;
- this.isThumbnailPermanent = isThumbnailPermanent;
- this.thumbnailStreamId = thumbnailStreamId;
- this.displayIndex = displayIndex;
- this.streamCount = streamCount;
- }
-
- @Override
- public LocalItemType getLocalItemType() {
- return LocalItemType.PLAYLIST_LOCAL_ITEM;
- }
-
- @Override
- public String getOrderingName() {
- return name;
- }
-
- public boolean isThumbnailPermanent() {
- return isThumbnailPermanent;
- }
-
- public long getThumbnailStreamId() {
- return thumbnailStreamId;
- }
-
- @Override
- public long getDisplayIndex() {
- return displayIndex;
- }
-
- @Override
- public long getUid() {
- return uid;
- }
-
- @Override
- public void setDisplayIndex(final long displayIndex) {
- this.displayIndex = displayIndex;
- }
-
- @Nullable
- @Override
- public String getThumbnailUrl() {
- return thumbnailUrl;
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.kt
new file mode 100644
index 00000000000..9b62c1380f9
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistMetadataEntry.kt
@@ -0,0 +1,42 @@
+/*
+ * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database.playlist
+
+import androidx.room.ColumnInfo
+import org.schabi.newpipe.database.LocalItem.LocalItemType
+import org.schabi.newpipe.database.playlist.model.PlaylistEntity
+
+open class PlaylistMetadataEntry(
+ @ColumnInfo(name = PlaylistEntity.PLAYLIST_ID)
+ override val uid: Long,
+
+ @ColumnInfo(name = PlaylistEntity.PLAYLIST_NAME)
+ override val orderingName: String?,
+
+ @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_URL)
+ override val thumbnailUrl: String?,
+
+ @ColumnInfo(name = PlaylistEntity.PLAYLIST_DISPLAY_INDEX)
+ override var displayIndex: Long?,
+
+ @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT)
+ open val isThumbnailPermanent: Boolean?,
+
+ @ColumnInfo(name = PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID)
+ open val thumbnailStreamId: Long?,
+
+ @ColumnInfo(name = PLAYLIST_STREAM_COUNT)
+ open val streamCount: Long
+) : PlaylistLocalItem {
+
+ override val localItemType: LocalItemType
+ get() = LocalItemType.PLAYLIST_LOCAL_ITEM
+
+ companion object {
+ const val PLAYLIST_STREAM_COUNT: String = "streamCount"
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt
index 1d74c6d31dc..90fdee2d339 100644
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/PlaylistStreamEntry.kt
@@ -1,3 +1,9 @@
+/*
+ * SPDX-FileCopyrightText: 2020-2023 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
package org.schabi.newpipe.database.playlist
import androidx.room.ColumnInfo
@@ -23,18 +29,21 @@ data class PlaylistStreamEntry(
val joinIndex: Int
) : LocalItem {
+ override val localItemType: LocalItem.LocalItemType
+ get() = LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM
+
@Throws(IllegalArgumentException::class)
fun toStreamInfoItem(): StreamInfoItem {
- val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType)
- item.duration = streamEntity.duration
- item.uploaderName = streamEntity.uploader
- item.uploaderUrl = streamEntity.uploaderUrl
- item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
-
- return item
- }
-
- override fun getLocalItemType(): LocalItem.LocalItemType {
- return LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM
+ return StreamInfoItem(
+ streamEntity.serviceId,
+ streamEntity.url,
+ streamEntity.title,
+ streamEntity.streamType
+ ).apply {
+ duration = streamEntity.duration
+ uploaderName = streamEntity.uploader
+ uploaderUrl = streamEntity.uploaderUrl
+ thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
+ }
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java
deleted file mode 100644
index d8071e0af3a..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.java
+++ /dev/null
@@ -1,53 +0,0 @@
-package org.schabi.newpipe.database.playlist.dao;
-
-import androidx.room.Dao;
-import androidx.room.Query;
-import androidx.room.Transaction;
-
-import org.schabi.newpipe.database.BasicDAO;
-import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
-
-import java.util.List;
-
-import io.reactivex.rxjava3.core.Flowable;
-
-import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID;
-import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE;
-
-@Dao
-public interface PlaylistDAO extends BasicDAO {
- @Override
- @Query("SELECT * FROM " + PLAYLIST_TABLE)
- Flowable> getAll();
-
- @Override
- @Query("DELETE FROM " + PLAYLIST_TABLE)
- int deleteAll();
-
- @Override
- default Flowable> listByService(final int serviceId) {
- throw new UnsupportedOperationException();
- }
-
- @Query("SELECT * FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId")
- Flowable> getPlaylist(long playlistId);
-
- @Query("DELETE FROM " + PLAYLIST_TABLE + " WHERE " + PLAYLIST_ID + " = :playlistId")
- int deletePlaylist(long playlistId);
-
- @Query("SELECT COUNT(*) FROM " + PLAYLIST_TABLE)
- Flowable getCount();
-
- @Transaction
- default long upsertPlaylist(final PlaylistEntity playlist) {
- final long playlistId = playlist.getUid();
-
- if (playlistId == -1) {
- // This situation is probably impossible.
- return insert(playlist);
- } else {
- update(playlist);
- return playlistId;
- }
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt
new file mode 100644
index 00000000000..9c2dd89a8c9
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistDAO.kt
@@ -0,0 +1,48 @@
+/*
+ * SPDX-FileCopyrightText: 2018-2022 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database.playlist.dao
+
+import androidx.room.Dao
+import androidx.room.Query
+import androidx.room.Transaction
+import io.reactivex.rxjava3.core.Flowable
+import org.schabi.newpipe.database.BasicDAO
+import org.schabi.newpipe.database.playlist.model.PlaylistEntity
+
+@Dao
+interface PlaylistDAO : BasicDAO {
+
+ @Query("SELECT * FROM playlists")
+ override fun getAll(): Flowable>
+
+ @Query("DELETE FROM playlists")
+ override fun deleteAll(): Int
+
+ override fun listByService(serviceId: Int): Flowable> {
+ throw UnsupportedOperationException()
+ }
+
+ @Query("SELECT * FROM playlists WHERE uid = :playlistId")
+ fun getPlaylist(playlistId: Long): Flowable>
+
+ @Query("DELETE FROM playlists WHERE uid = :playlistId")
+ fun deletePlaylist(playlistId: Long): Int
+
+ @get:Query("SELECT COUNT(*) FROM playlists")
+ val count: Flowable
+
+ @Transaction
+ fun upsertPlaylist(playlist: PlaylistEntity): Long {
+ if (playlist.uid == -1L) {
+ // This situation is probably impossible.
+ return insert(playlist)
+ } else {
+ update(playlist)
+ return playlist.uid
+ }
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java
deleted file mode 100644
index ef77d5ade73..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.java
+++ /dev/null
@@ -1,68 +0,0 @@
-package org.schabi.newpipe.database.playlist.dao;
-
-import androidx.room.Dao;
-import androidx.room.Query;
-import androidx.room.Transaction;
-
-import org.schabi.newpipe.database.BasicDAO;
-import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity;
-
-import java.util.List;
-
-import io.reactivex.rxjava3.core.Flowable;
-
-import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_DISPLAY_INDEX;
-import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_ID;
-import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID;
-import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE;
-import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL;
-
-@Dao
-public interface PlaylistRemoteDAO extends BasicDAO {
- @Override
- @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE)
- Flowable> getAll();
-
- @Override
- @Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE)
- int deleteAll();
-
- @Override
- @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE
- + " WHERE " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
- Flowable> listByService(int serviceId);
-
- @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
- + REMOTE_PLAYLIST_ID + " = :playlistId")
- Flowable getPlaylist(long playlistId);
-
- @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE + " WHERE "
- + REMOTE_PLAYLIST_URL + " = :url AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
- Flowable> getPlaylist(long serviceId, String url);
-
- @Query("SELECT * FROM " + REMOTE_PLAYLIST_TABLE
- + " ORDER BY " + REMOTE_PLAYLIST_DISPLAY_INDEX)
- Flowable> getPlaylists();
-
- @Query("SELECT " + REMOTE_PLAYLIST_ID + " FROM " + REMOTE_PLAYLIST_TABLE
- + " WHERE " + REMOTE_PLAYLIST_URL + " = :url "
- + "AND " + REMOTE_PLAYLIST_SERVICE_ID + " = :serviceId")
- Long getPlaylistIdInternal(long serviceId, String url);
-
- @Transaction
- default long upsert(final PlaylistRemoteEntity playlist) {
- final Long playlistId = getPlaylistIdInternal(playlist.getServiceId(), playlist.getUrl());
-
- if (playlistId == null) {
- return insert(playlist);
- } else {
- playlist.setUid(playlistId);
- update(playlist);
- return playlistId;
- }
- }
-
- @Query("DELETE FROM " + REMOTE_PLAYLIST_TABLE
- + " WHERE " + REMOTE_PLAYLIST_ID + " = :playlistId")
- int deletePlaylist(long playlistId);
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt
new file mode 100644
index 00000000000..36a80bc9143
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistRemoteDAO.kt
@@ -0,0 +1,55 @@
+/*
+ * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database.playlist.dao
+
+import androidx.room.Dao
+import androidx.room.Query
+import androidx.room.Transaction
+import io.reactivex.rxjava3.core.Flowable
+import org.schabi.newpipe.database.BasicDAO
+import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity
+
+@Dao
+interface PlaylistRemoteDAO : BasicDAO {
+
+ @Query("SELECT * FROM remote_playlists")
+ override fun getAll(): Flowable>
+
+ @Query("DELETE FROM remote_playlists")
+ override fun deleteAll(): Int
+
+ @Query("SELECT * FROM remote_playlists WHERE service_id = :serviceId")
+ override fun listByService(serviceId: Int): Flowable>
+
+ @Query("SELECT * FROM remote_playlists WHERE uid = :playlistId")
+ fun getPlaylist(playlistId: Long): Flowable
+
+ @Query("SELECT * FROM remote_playlists WHERE url = :url AND service_id = :serviceId")
+ fun getPlaylist(serviceId: Long, url: String?): Flowable>
+
+ @get:Query("SELECT * FROM remote_playlists ORDER BY display_index")
+ val playlists: Flowable>
+
+ @Query("SELECT uid FROM remote_playlists WHERE url = :url AND service_id = :serviceId")
+ fun getPlaylistIdInternal(serviceId: Long, url: String?): Long?
+
+ @Transaction
+ fun upsert(playlist: PlaylistRemoteEntity): Long {
+ val playlistId = getPlaylistIdInternal(playlist.serviceId.toLong(), playlist.url)
+
+ if (playlistId == null) {
+ return insert(playlist)
+ } else {
+ playlist.uid = playlistId
+ update(playlist)
+ return playlistId
+ }
+ }
+
+ @Query("DELETE FROM remote_playlists WHERE uid = :playlistId")
+ fun deletePlaylist(playlistId: Long): Int
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java
deleted file mode 100644
index 6b77166eae9..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.java
+++ /dev/null
@@ -1,159 +0,0 @@
-package org.schabi.newpipe.database.playlist.dao;
-
-import androidx.room.Dao;
-import androidx.room.Query;
-import androidx.room.RewriteQueriesToDropUnusedColumns;
-import androidx.room.Transaction;
-
-import org.schabi.newpipe.database.BasicDAO;
-import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
-import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
-import org.schabi.newpipe.database.playlist.PlaylistStreamEntry;
-import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
-import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity;
-
-import java.util.List;
-
-import io.reactivex.rxjava3.core.Flowable;
-
-import static org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry.PLAYLIST_TIMES_STREAM_IS_CONTAINED;
-import static org.schabi.newpipe.database.playlist.PlaylistMetadataEntry.PLAYLIST_STREAM_COUNT;
-import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_DISPLAY_INDEX;
-import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.DEFAULT_THUMBNAIL;
-import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_ID;
-import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_NAME;
-import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE;
-import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_PERMANENT;
-import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_STREAM_ID;
-import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_THUMBNAIL_URL;
-import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_INDEX;
-import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_PLAYLIST_ID;
-import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_STREAM_ID;
-import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE;
-import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_ID;
-import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_TABLE;
-import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_THUMBNAIL_URL;
-import static org.schabi.newpipe.database.stream.model.StreamEntity.STREAM_URL;
-import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID_ALIAS;
-import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS;
-import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
-
-@Dao
-public interface PlaylistStreamDAO extends BasicDAO {
- @Override
- @Query("SELECT * FROM " + PLAYLIST_STREAM_JOIN_TABLE)
- Flowable> getAll();
-
- @Override
- @Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE)
- int deleteAll();
-
- @Override
- default Flowable> listByService(final int serviceId) {
- throw new UnsupportedOperationException();
- }
-
- @Query("DELETE FROM " + PLAYLIST_STREAM_JOIN_TABLE
- + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
- void deleteBatch(long playlistId);
-
- @Query("SELECT COALESCE(MAX(" + JOIN_INDEX + "), -1)"
- + " FROM " + PLAYLIST_STREAM_JOIN_TABLE
- + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId")
- Flowable getMaximumIndexOf(long playlistId);
-
- @Query("SELECT CASE WHEN COUNT(*) != 0 then " + STREAM_ID
- + " ELSE " + PlaylistEntity.DEFAULT_THUMBNAIL_ID + " END"
- + " FROM " + STREAM_TABLE
- + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
- + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
- + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId "
- + " LIMIT 1"
- )
- Flowable getAutomaticThumbnailStreamId(long playlistId);
-
- @RewriteQueriesToDropUnusedColumns
- @Transaction
- @Query("SELECT * FROM " + STREAM_TABLE + " INNER JOIN "
- // get ids of streams of the given playlist
- + "(SELECT " + JOIN_STREAM_ID + "," + JOIN_INDEX
- + " FROM " + PLAYLIST_STREAM_JOIN_TABLE
- + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId)"
-
- // then merge with the stream metadata
- + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
-
- + " LEFT JOIN "
- + "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", "
- + STREAM_PROGRESS_MILLIS
- + " FROM " + STREAM_STATE_TABLE + " )"
- + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS
-
- + " ORDER BY " + JOIN_INDEX + " ASC")
- Flowable> getOrderedStreamsOf(long playlistId);
-
- @Transaction
- @Query("SELECT " + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", "
- + PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", "
- + PLAYLIST_DISPLAY_INDEX + ", "
-
- + " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = "
- + PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'"
- + " ELSE (SELECT " + STREAM_THUMBNAIL_URL
- + " FROM " + STREAM_TABLE
- + " WHERE " + STREAM_TABLE + "." + STREAM_ID + " = " + PLAYLIST_THUMBNAIL_STREAM_ID
- + " ) END AS " + PLAYLIST_THUMBNAIL_URL + ", "
-
- + "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT
- + " FROM " + PLAYLIST_TABLE
- + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
- + " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID
- + " GROUP BY " + PLAYLIST_ID
- + " ORDER BY " + PLAYLIST_DISPLAY_INDEX)
- Flowable> getPlaylistMetadata();
-
- @RewriteQueriesToDropUnusedColumns
- @Transaction
- @Query("SELECT *, MIN(" + JOIN_INDEX + ")"
- + " FROM " + STREAM_TABLE + " INNER JOIN"
- + " (SELECT " + JOIN_STREAM_ID + "," + JOIN_INDEX
- + " FROM " + PLAYLIST_STREAM_JOIN_TABLE
- + " WHERE " + JOIN_PLAYLIST_ID + " = :playlistId)"
- + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID
- + " LEFT JOIN "
- + "(SELECT " + JOIN_STREAM_ID + " AS " + JOIN_STREAM_ID_ALIAS + ", "
- + STREAM_PROGRESS_MILLIS
- + " FROM " + STREAM_STATE_TABLE + " )"
- + " ON " + STREAM_ID + " = " + JOIN_STREAM_ID_ALIAS
- + " GROUP BY " + STREAM_ID
- + " ORDER BY MIN(" + JOIN_INDEX + ") ASC")
- Flowable> getStreamsWithoutDuplicates(long playlistId);
-
- @Transaction
- @Query("SELECT " + PLAYLIST_TABLE + "." + PLAYLIST_ID + ", " + PLAYLIST_NAME + ", "
- + PLAYLIST_THUMBNAIL_PERMANENT + ", " + PLAYLIST_THUMBNAIL_STREAM_ID + ", "
- + PLAYLIST_DISPLAY_INDEX + ", "
-
- + " CASE WHEN " + PLAYLIST_THUMBNAIL_STREAM_ID + " = "
- + PlaylistEntity.DEFAULT_THUMBNAIL_ID + " THEN " + "'" + DEFAULT_THUMBNAIL + "'"
- + " ELSE (SELECT " + STREAM_THUMBNAIL_URL
- + " FROM " + STREAM_TABLE
- + " WHERE " + STREAM_TABLE + "." + STREAM_ID + " = " + PLAYLIST_THUMBNAIL_STREAM_ID
- + " ) END AS " + PLAYLIST_THUMBNAIL_URL + ", "
-
- + "COALESCE(COUNT(" + JOIN_PLAYLIST_ID + "), 0) AS " + PLAYLIST_STREAM_COUNT + ", "
- + "COALESCE(SUM(" + STREAM_URL + " = :streamUrl), 0) AS "
- + PLAYLIST_TIMES_STREAM_IS_CONTAINED
-
- + " FROM " + PLAYLIST_TABLE
- + " LEFT JOIN " + PLAYLIST_STREAM_JOIN_TABLE
- + " ON " + PLAYLIST_TABLE + "." + PLAYLIST_ID + " = " + JOIN_PLAYLIST_ID
-
- + " LEFT JOIN " + STREAM_TABLE
- + " ON " + STREAM_TABLE + "." + STREAM_ID + " = " + JOIN_STREAM_ID
- + " AND :streamUrl = :streamUrl"
-
- + " GROUP BY " + JOIN_PLAYLIST_ID
- + " ORDER BY " + PLAYLIST_DISPLAY_INDEX + ", " + PLAYLIST_NAME)
- Flowable> getPlaylistDuplicatesMetadata(String streamUrl);
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt
new file mode 100644
index 00000000000..8bf26d754fa
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/dao/PlaylistStreamDAO.kt
@@ -0,0 +1,126 @@
+/*
+ * SPDX-FileCopyrightText: 2018-2024 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database.playlist.dao
+
+import androidx.room.Dao
+import androidx.room.Query
+import androidx.room.RewriteQueriesToDropUnusedColumns
+import androidx.room.Transaction
+import io.reactivex.rxjava3.core.Flowable
+import org.schabi.newpipe.database.BasicDAO
+import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry
+import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry
+import org.schabi.newpipe.database.playlist.PlaylistStreamEntry
+import org.schabi.newpipe.database.playlist.model.PlaylistEntity.Companion.DEFAULT_THUMBNAIL_ID
+import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity
+
+@Dao
+interface PlaylistStreamDAO : BasicDAO {
+
+ @Query("SELECT * FROM playlist_stream_join")
+ override fun getAll(): Flowable>
+
+ @Query("DELETE FROM playlist_stream_join")
+ override fun deleteAll(): Int
+
+ override fun listByService(serviceId: Int): Flowable> {
+ throw UnsupportedOperationException()
+ }
+
+ @Query("DELETE FROM playlist_stream_join WHERE playlist_id = :playlistId")
+ fun deleteBatch(playlistId: Long)
+
+ @Query("SELECT COALESCE(MAX(join_index), -1) FROM playlist_stream_join WHERE playlist_id = :playlistId")
+ fun getMaximumIndexOf(playlistId: Long): Flowable
+
+ @Query(
+ """
+ SELECT CASE WHEN COUNT(*) != 0 then stream_id ELSE $DEFAULT_THUMBNAIL_ID END
+ FROM streams
+
+ LEFT JOIN playlist_stream_join
+ ON uid = stream_id
+
+ WHERE playlist_id = :playlistId LIMIT 1
+ """
+ )
+ fun getAutomaticThumbnailStreamId(playlistId: Long): Flowable
+
+ // get ids of streams of the given playlist then merge with the stream metadata
+ @RewriteQueriesToDropUnusedColumns
+ @Transaction
+ @Query(
+ """
+ SELECT * FROM streams
+
+ INNER JOIN (SELECT stream_id, join_index FROM playlist_stream_join WHERE playlist_id = :playlistId)
+ ON uid = stream_id
+
+ LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state )
+ ON uid = stream_id_alias
+
+ ORDER BY join_index ASC
+ """
+ )
+ fun getOrderedStreamsOf(playlistId: Long): Flowable>
+
+ @Transaction
+ @Query(
+ """
+ SELECT uid, name, is_thumbnail_permanent, thumbnail_stream_id, display_index,
+ (SELECT thumbnail_url FROM streams WHERE streams.uid = thumbnail_stream_id) AS thumbnail_url,
+
+ COALESCE(COUNT(playlist_id), 0) AS streamCount FROM playlists
+
+ LEFT JOIN playlist_stream_join
+ ON playlists.uid = playlist_id
+
+ GROUP BY uid
+ ORDER BY display_index
+ """
+ )
+ fun getPlaylistMetadata(): Flowable>
+
+ @RewriteQueriesToDropUnusedColumns
+ @Transaction
+ @Query(
+ """
+ SELECT *, MIN(join_index) FROM streams
+
+ INNER JOIN (SELECT stream_id, join_index FROM playlist_stream_join WHERE playlist_id = :playlistId)
+ ON uid = stream_id
+
+ LEFT JOIN (SELECT stream_id AS stream_id_alias, progress_time FROM stream_state )
+ ON uid = stream_id_alias
+
+ GROUP BY uid
+ ORDER BY MIN(join_index) ASC
+ """
+ )
+ fun getStreamsWithoutDuplicates(playlistId: Long): Flowable>
+
+ @Transaction
+ @Query(
+ """
+ SELECT playlists.uid, name, is_thumbnail_permanent, thumbnail_stream_id, display_index,
+ (SELECT thumbnail_url FROM streams WHERE streams.uid = thumbnail_stream_id) AS thumbnail_url,
+
+ COALESCE(COUNT(playlist_id), 0) AS streamCount,
+ COALESCE(SUM(url = :streamUrl), 0) AS timesStreamIsContained FROM playlists
+
+ LEFT JOIN playlist_stream_join
+ ON playlists.uid = playlist_id
+
+ LEFT JOIN streams
+ ON streams.uid = stream_id AND :streamUrl = :streamUrl
+
+ GROUP BY playlist_id
+ ORDER BY display_index, name
+ """
+ )
+ fun getPlaylistDuplicatesMetadata(streamUrl: String): Flowable>
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java
deleted file mode 100644
index e0c1a06b79b..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.java
+++ /dev/null
@@ -1,100 +0,0 @@
-package org.schabi.newpipe.database.playlist.model;
-
-import androidx.room.ColumnInfo;
-import androidx.room.Entity;
-import androidx.room.Ignore;
-import androidx.room.PrimaryKey;
-
-import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.PLAYLIST_TABLE;
-
-import org.schabi.newpipe.R;
-import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry;
-
-@Entity(tableName = PLAYLIST_TABLE)
-public class PlaylistEntity {
-
- public static final String DEFAULT_THUMBNAIL = "drawable://"
- + R.drawable.placeholder_thumbnail_playlist;
- public static final long DEFAULT_THUMBNAIL_ID = -1;
-
- public static final String PLAYLIST_TABLE = "playlists";
- public static final String PLAYLIST_ID = "uid";
- public static final String PLAYLIST_NAME = "name";
- public static final String PLAYLIST_THUMBNAIL_URL = "thumbnail_url";
- public static final String PLAYLIST_DISPLAY_INDEX = "display_index";
- public static final String PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent";
- public static final String PLAYLIST_THUMBNAIL_STREAM_ID = "thumbnail_stream_id";
-
- @PrimaryKey(autoGenerate = true)
- @ColumnInfo(name = PLAYLIST_ID)
- private long uid = 0;
-
- @ColumnInfo(name = PLAYLIST_NAME)
- private String name;
-
- @ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT)
- private boolean isThumbnailPermanent;
-
- @ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID)
- private long thumbnailStreamId;
-
- @ColumnInfo(name = PLAYLIST_DISPLAY_INDEX)
- private long displayIndex;
-
- public PlaylistEntity(final String name, final boolean isThumbnailPermanent,
- final long thumbnailStreamId, final long displayIndex) {
- this.name = name;
- this.isThumbnailPermanent = isThumbnailPermanent;
- this.thumbnailStreamId = thumbnailStreamId;
- this.displayIndex = displayIndex;
- }
-
- @Ignore
- public PlaylistEntity(final PlaylistMetadataEntry item) {
- this.uid = item.getUid();
- this.name = item.name;
- this.isThumbnailPermanent = item.isThumbnailPermanent();
- this.thumbnailStreamId = item.getThumbnailStreamId();
- this.displayIndex = item.getDisplayIndex();
- }
-
- public long getUid() {
- return uid;
- }
-
- public void setUid(final long uid) {
- this.uid = uid;
- }
-
- public String getName() {
- return name;
- }
-
- public void setName(final String name) {
- this.name = name;
- }
-
- public long getThumbnailStreamId() {
- return thumbnailStreamId;
- }
-
- public void setThumbnailStreamId(final long thumbnailStreamId) {
- this.thumbnailStreamId = thumbnailStreamId;
- }
-
- public boolean getIsThumbnailPermanent() {
- return isThumbnailPermanent;
- }
-
- public void setIsThumbnailPermanent(final boolean isThumbnailSet) {
- this.isThumbnailPermanent = isThumbnailSet;
- }
-
- public long getDisplayIndex() {
- return displayIndex;
- }
-
- public void setDisplayIndex(final long displayIndex) {
- this.displayIndex = displayIndex;
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.kt
new file mode 100644
index 00000000000..4ea4eb3a78b
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistEntity.kt
@@ -0,0 +1,54 @@
+/*
+ * SPDX-FileCopyrightText: 2018-2024 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database.playlist.model
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.Ignore
+import androidx.room.PrimaryKey
+import org.schabi.newpipe.database.playlist.PlaylistMetadataEntry
+
+@Entity(tableName = PlaylistEntity.Companion.PLAYLIST_TABLE)
+data class PlaylistEntity @JvmOverloads constructor(
+ @PrimaryKey(autoGenerate = true)
+ @ColumnInfo(name = PLAYLIST_ID)
+ var uid: Long = 0,
+
+ @ColumnInfo(name = PLAYLIST_NAME)
+ var name: String?,
+
+ @ColumnInfo(name = PLAYLIST_THUMBNAIL_PERMANENT)
+ var isThumbnailPermanent: Boolean,
+
+ @ColumnInfo(name = PLAYLIST_THUMBNAIL_STREAM_ID)
+ var thumbnailStreamId: Long,
+
+ @ColumnInfo(name = PLAYLIST_DISPLAY_INDEX)
+ var displayIndex: Long
+) {
+
+ @Ignore
+ constructor(item: PlaylistMetadataEntry) : this(
+ uid = item.uid,
+ name = item.orderingName,
+ isThumbnailPermanent = item.isThumbnailPermanent!!,
+ thumbnailStreamId = item.thumbnailStreamId!!,
+ displayIndex = item.displayIndex!!,
+ )
+
+ companion object {
+ const val DEFAULT_THUMBNAIL_ID = -1L
+
+ const val PLAYLIST_TABLE = "playlists"
+ const val PLAYLIST_ID = "uid"
+ const val PLAYLIST_NAME = "name"
+ const val PLAYLIST_THUMBNAIL_URL = "thumbnail_url"
+ const val PLAYLIST_DISPLAY_INDEX = "display_index"
+ const val PLAYLIST_THUMBNAIL_PERMANENT = "is_thumbnail_permanent"
+ const val PLAYLIST_THUMBNAIL_STREAM_ID = "thumbnail_stream_id"
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java
deleted file mode 100644
index 0b0e3605ed3..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.java
+++ /dev/null
@@ -1,191 +0,0 @@
-package org.schabi.newpipe.database.playlist.model;
-
-import android.text.TextUtils;
-
-import androidx.annotation.Nullable;
-import androidx.room.ColumnInfo;
-import androidx.room.Entity;
-import androidx.room.Ignore;
-import androidx.room.Index;
-import androidx.room.PrimaryKey;
-
-import org.schabi.newpipe.database.playlist.PlaylistLocalItem;
-import org.schabi.newpipe.extractor.playlist.PlaylistInfo;
-import org.schabi.newpipe.util.Constants;
-import org.schabi.newpipe.util.image.ImageStrategy;
-
-import static org.schabi.newpipe.database.LocalItem.LocalItemType.PLAYLIST_REMOTE_ITEM;
-import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_NAME;
-import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_SERVICE_ID;
-import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_TABLE;
-import static org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.REMOTE_PLAYLIST_URL;
-
-@Entity(tableName = REMOTE_PLAYLIST_TABLE,
- indices = {
- @Index(value = {REMOTE_PLAYLIST_SERVICE_ID, REMOTE_PLAYLIST_URL}, unique = true)
- })
-public class PlaylistRemoteEntity implements PlaylistLocalItem {
- public static final String REMOTE_PLAYLIST_TABLE = "remote_playlists";
- public static final String REMOTE_PLAYLIST_ID = "uid";
- public static final String REMOTE_PLAYLIST_SERVICE_ID = "service_id";
- public static final String REMOTE_PLAYLIST_NAME = "name";
- public static final String REMOTE_PLAYLIST_URL = "url";
- public static final String REMOTE_PLAYLIST_THUMBNAIL_URL = "thumbnail_url";
- public static final String REMOTE_PLAYLIST_UPLOADER_NAME = "uploader";
- public static final String REMOTE_PLAYLIST_DISPLAY_INDEX = "display_index";
- public static final String REMOTE_PLAYLIST_STREAM_COUNT = "stream_count";
-
- @PrimaryKey(autoGenerate = true)
- @ColumnInfo(name = REMOTE_PLAYLIST_ID)
- private long uid = 0;
-
- @ColumnInfo(name = REMOTE_PLAYLIST_SERVICE_ID)
- private int serviceId = Constants.NO_SERVICE_ID;
-
- @ColumnInfo(name = REMOTE_PLAYLIST_NAME)
- private String name;
-
- @ColumnInfo(name = REMOTE_PLAYLIST_URL)
- private String url;
-
- @ColumnInfo(name = REMOTE_PLAYLIST_THUMBNAIL_URL)
- private String thumbnailUrl;
-
- @ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME)
- private String uploader;
-
- @ColumnInfo(name = REMOTE_PLAYLIST_DISPLAY_INDEX)
- private long displayIndex = -1; // Make sure the new item is on the top
-
- @ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT)
- private Long streamCount;
-
- public PlaylistRemoteEntity(final int serviceId, final String name, final String url,
- final String thumbnailUrl, final String uploader,
- final Long streamCount) {
- this.serviceId = serviceId;
- this.name = name;
- this.url = url;
- this.thumbnailUrl = thumbnailUrl;
- this.uploader = uploader;
- this.streamCount = streamCount;
- }
-
- @Ignore
- public PlaylistRemoteEntity(final int serviceId, final String name, final String url,
- final String thumbnailUrl, final String uploader,
- final long displayIndex, final Long streamCount) {
- this.serviceId = serviceId;
- this.name = name;
- this.url = url;
- this.thumbnailUrl = thumbnailUrl;
- this.uploader = uploader;
- this.displayIndex = displayIndex;
- this.streamCount = streamCount;
- }
-
- @Ignore
- public PlaylistRemoteEntity(final PlaylistInfo info) {
- this(info.getServiceId(), info.getName(), info.getUrl(),
- // use uploader avatar when no thumbnail is available
- ImageStrategy.imageListToDbUrl(info.getThumbnails().isEmpty()
- ? info.getUploaderAvatars() : info.getThumbnails()),
- info.getUploaderName(), info.getStreamCount());
- }
-
- @Ignore
- public boolean isIdenticalTo(final PlaylistInfo info) {
- /*
- * Returns boolean comparing the online playlist and the local copy.
- * (False if info changed such as playlist name or track count)
- */
- return getServiceId() == info.getServiceId()
- && getStreamCount() == info.getStreamCount()
- && TextUtils.equals(getName(), info.getName())
- && TextUtils.equals(getUrl(), info.getUrl())
- // we want to update the local playlist data even when either the remote thumbnail
- // URL changes, or the preferred image quality setting is changed by the user
- && TextUtils.equals(getThumbnailUrl(),
- ImageStrategy.imageListToDbUrl(info.getThumbnails()))
- && TextUtils.equals(getUploader(), info.getUploaderName());
- }
-
- @Override
- public long getUid() {
- return uid;
- }
-
- public void setUid(final long uid) {
- this.uid = uid;
- }
-
- public int getServiceId() {
- return serviceId;
- }
-
- public void setServiceId(final int serviceId) {
- this.serviceId = serviceId;
- }
-
- public String getName() {
- return name;
- }
-
- public void setName(final String name) {
- this.name = name;
- }
-
- @Nullable
- @Override
- public String getThumbnailUrl() {
- return thumbnailUrl;
- }
-
- public void setThumbnailUrl(final String thumbnailUrl) {
- this.thumbnailUrl = thumbnailUrl;
- }
-
- public String getUrl() {
- return url;
- }
-
- public void setUrl(final String url) {
- this.url = url;
- }
-
- public String getUploader() {
- return uploader;
- }
-
- public void setUploader(final String uploader) {
- this.uploader = uploader;
- }
-
- @Override
- public long getDisplayIndex() {
- return displayIndex;
- }
-
- @Override
- public void setDisplayIndex(final long displayIndex) {
- this.displayIndex = displayIndex;
- }
-
- public Long getStreamCount() {
- return streamCount;
- }
-
- public void setStreamCount(final Long streamCount) {
- this.streamCount = streamCount;
- }
-
- @Override
- public LocalItemType getLocalItemType() {
- return PLAYLIST_REMOTE_ITEM;
- }
-
- @Override
- public String getOrderingName() {
- return name;
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.kt
new file mode 100644
index 00000000000..82162e1e4ad
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistRemoteEntity.kt
@@ -0,0 +1,104 @@
+/*
+ * SPDX-FileCopyrightText: 2018-2025 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database.playlist.model
+
+import android.text.TextUtils
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.Ignore
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import org.schabi.newpipe.database.LocalItem.LocalItemType
+import org.schabi.newpipe.database.playlist.PlaylistLocalItem
+import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_SERVICE_ID
+import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_TABLE
+import org.schabi.newpipe.database.playlist.model.PlaylistRemoteEntity.Companion.REMOTE_PLAYLIST_URL
+import org.schabi.newpipe.extractor.playlist.PlaylistInfo
+import org.schabi.newpipe.util.NO_SERVICE_ID
+import org.schabi.newpipe.util.image.ImageStrategy
+
+@Entity(
+ tableName = REMOTE_PLAYLIST_TABLE,
+ indices = [
+ Index(
+ value = [REMOTE_PLAYLIST_SERVICE_ID, REMOTE_PLAYLIST_URL],
+ unique = true
+ )
+ ]
+)
+data class PlaylistRemoteEntity(
+ @PrimaryKey(autoGenerate = true)
+ @ColumnInfo(name = REMOTE_PLAYLIST_ID)
+ override var uid: Long = 0,
+
+ @ColumnInfo(name = REMOTE_PLAYLIST_SERVICE_ID)
+ val serviceId: Int = NO_SERVICE_ID,
+
+ @ColumnInfo(name = REMOTE_PLAYLIST_NAME)
+ override val orderingName: String?,
+
+ @ColumnInfo(name = REMOTE_PLAYLIST_URL)
+ val url: String?,
+
+ @ColumnInfo(name = REMOTE_PLAYLIST_THUMBNAIL_URL)
+ override val thumbnailUrl: String?,
+
+ @ColumnInfo(name = REMOTE_PLAYLIST_UPLOADER_NAME)
+ val uploader: String?,
+
+ @ColumnInfo(name = REMOTE_PLAYLIST_DISPLAY_INDEX)
+ override var displayIndex: Long = -1, // Make sure the new item is on the top
+
+ @ColumnInfo(name = REMOTE_PLAYLIST_STREAM_COUNT)
+ val streamCount: Long?
+) : PlaylistLocalItem {
+
+ constructor(playlistInfo: PlaylistInfo) : this(
+ serviceId = playlistInfo.serviceId,
+ orderingName = playlistInfo.name,
+ url = playlistInfo.url,
+ thumbnailUrl = ImageStrategy.imageListToDbUrl(
+ if (playlistInfo.thumbnails.isEmpty()) {
+ playlistInfo.uploaderAvatars
+ } else {
+ playlistInfo.thumbnails
+ }
+ ),
+ uploader = playlistInfo.uploaderName,
+ streamCount = playlistInfo.streamCount
+ )
+
+ override val localItemType: LocalItemType
+ get() = LocalItemType.PLAYLIST_REMOTE_ITEM
+
+ /**
+ * Returns boolean comparing the online playlist and the local copy.
+ * (False if info changed such as playlist name or track count)
+ */
+ @Ignore
+ fun isIdenticalTo(info: PlaylistInfo): Boolean {
+ return this.serviceId == info.serviceId && this.streamCount == info.streamCount &&
+ TextUtils.equals(this.orderingName, info.name) &&
+ TextUtils.equals(this.url, info.url) &&
+ // we want to update the local playlist data even when either the remote thumbnail
+ // URL changes, or the preferred image quality setting is changed by the user
+ TextUtils.equals(thumbnailUrl, ImageStrategy.imageListToDbUrl(info.thumbnails)) &&
+ TextUtils.equals(this.uploader, info.uploaderName)
+ }
+
+ companion object {
+ const val REMOTE_PLAYLIST_TABLE = "remote_playlists"
+ const val REMOTE_PLAYLIST_ID = "uid"
+ const val REMOTE_PLAYLIST_SERVICE_ID = "service_id"
+ const val REMOTE_PLAYLIST_NAME = "name"
+ const val REMOTE_PLAYLIST_URL = "url"
+ const val REMOTE_PLAYLIST_THUMBNAIL_URL = "thumbnail_url"
+ const val REMOTE_PLAYLIST_UPLOADER_NAME = "uploader"
+ const val REMOTE_PLAYLIST_DISPLAY_INDEX = "display_index"
+ const val REMOTE_PLAYLIST_STREAM_COUNT = "stream_count"
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.java b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.java
deleted file mode 100644
index f3208b6d517..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.java
+++ /dev/null
@@ -1,76 +0,0 @@
-package org.schabi.newpipe.database.playlist.model;
-
-import androidx.room.ColumnInfo;
-import androidx.room.Entity;
-import androidx.room.ForeignKey;
-import androidx.room.Index;
-
-import org.schabi.newpipe.database.stream.model.StreamEntity;
-
-import static androidx.room.ForeignKey.CASCADE;
-import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_INDEX;
-import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_PLAYLIST_ID;
-import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.JOIN_STREAM_ID;
-import static org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.PLAYLIST_STREAM_JOIN_TABLE;
-
-@Entity(tableName = PLAYLIST_STREAM_JOIN_TABLE,
- primaryKeys = {JOIN_PLAYLIST_ID, JOIN_INDEX},
- indices = {
- @Index(value = {JOIN_PLAYLIST_ID, JOIN_INDEX}, unique = true),
- @Index(value = {JOIN_STREAM_ID})
- },
- foreignKeys = {
- @ForeignKey(entity = PlaylistEntity.class,
- parentColumns = PlaylistEntity.PLAYLIST_ID,
- childColumns = JOIN_PLAYLIST_ID,
- onDelete = CASCADE, onUpdate = CASCADE, deferred = true),
- @ForeignKey(entity = StreamEntity.class,
- parentColumns = StreamEntity.STREAM_ID,
- childColumns = JOIN_STREAM_ID,
- onDelete = CASCADE, onUpdate = CASCADE, deferred = true)
- })
-public class PlaylistStreamEntity {
- public static final String PLAYLIST_STREAM_JOIN_TABLE = "playlist_stream_join";
- public static final String JOIN_PLAYLIST_ID = "playlist_id";
- public static final String JOIN_STREAM_ID = "stream_id";
- public static final String JOIN_INDEX = "join_index";
-
- @ColumnInfo(name = JOIN_PLAYLIST_ID)
- private long playlistUid;
-
- @ColumnInfo(name = JOIN_STREAM_ID)
- private long streamUid;
-
- @ColumnInfo(name = JOIN_INDEX)
- private int index;
-
- public PlaylistStreamEntity(final long playlistUid, final long streamUid, final int index) {
- this.playlistUid = playlistUid;
- this.streamUid = streamUid;
- this.index = index;
- }
-
- public long getPlaylistUid() {
- return playlistUid;
- }
-
- public void setPlaylistUid(final long playlistUid) {
- this.playlistUid = playlistUid;
- }
-
- public long getStreamUid() {
- return streamUid;
- }
-
- public void setStreamUid(final long streamUid) {
- this.streamUid = streamUid;
- }
-
- public int getIndex() {
- return index;
- }
-
- public void setIndex(final int index) {
- this.index = index;
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.kt b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.kt
new file mode 100644
index 00000000000..6ab1b6ac48f
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/playlist/model/PlaylistStreamEntity.kt
@@ -0,0 +1,68 @@
+/*
+ * SPDX-FileCopyrightText: 2018-2020 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database.playlist.model
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.ForeignKey.Companion.CASCADE
+import androidx.room.Index
+import org.schabi.newpipe.database.LocalItem
+import org.schabi.newpipe.database.playlist.model.PlaylistEntity.Companion.PLAYLIST_ID
+import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.JOIN_INDEX
+import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.JOIN_PLAYLIST_ID
+import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.JOIN_STREAM_ID
+import org.schabi.newpipe.database.playlist.model.PlaylistStreamEntity.Companion.PLAYLIST_STREAM_JOIN_TABLE
+import org.schabi.newpipe.database.stream.model.StreamEntity
+
+@Entity(
+ tableName = PLAYLIST_STREAM_JOIN_TABLE,
+ primaryKeys = [JOIN_PLAYLIST_ID, JOIN_INDEX],
+ indices = [
+ Index(value = [JOIN_PLAYLIST_ID, JOIN_INDEX], unique = true),
+ Index(value = [JOIN_STREAM_ID])
+ ],
+ foreignKeys = [
+ ForeignKey(
+ entity = PlaylistEntity::class,
+ parentColumns = arrayOf(PLAYLIST_ID),
+ childColumns = arrayOf(JOIN_PLAYLIST_ID),
+ onDelete = CASCADE,
+ onUpdate = CASCADE,
+ deferred = true
+ ),
+ ForeignKey(
+ entity = StreamEntity::class,
+ parentColumns = arrayOf(StreamEntity.STREAM_ID),
+ childColumns = arrayOf(JOIN_STREAM_ID),
+ onDelete = CASCADE,
+ onUpdate = CASCADE,
+ deferred = true
+ )
+ ]
+)
+data class PlaylistStreamEntity(
+ @ColumnInfo(name = JOIN_PLAYLIST_ID)
+ val playlistUid: Long,
+
+ @ColumnInfo(name = JOIN_STREAM_ID)
+ val streamUid: Long,
+
+ @ColumnInfo(name = JOIN_INDEX)
+ val index: Int
+) : LocalItem {
+
+ override val localItemType: LocalItem.LocalItemType
+ get() = LocalItem.LocalItemType.PLAYLIST_STREAM_ITEM
+
+ companion object {
+ const val PLAYLIST_STREAM_JOIN_TABLE = "playlist_stream_join"
+ const val JOIN_PLAYLIST_ID = "playlist_id"
+ const val JOIN_STREAM_ID = "stream_id"
+ const val JOIN_INDEX = "join_index"
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt
index 1f3654e7ae4..3fa281e457a 100644
--- a/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt
+++ b/app/src/main/java/org/schabi/newpipe/database/stream/StreamStatisticsEntry.kt
@@ -1,16 +1,23 @@
+/*
+ * SPDX-FileCopyrightText: 2020-2023 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
package org.schabi.newpipe.database.stream
import androidx.room.ColumnInfo
import androidx.room.Embedded
+import androidx.room.Ignore
import org.schabi.newpipe.database.LocalItem
import org.schabi.newpipe.database.history.model.StreamHistoryEntity
import org.schabi.newpipe.database.stream.model.StreamEntity
-import org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_PROGRESS_MILLIS
+import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.STREAM_PROGRESS_MILLIS
import org.schabi.newpipe.extractor.stream.StreamInfoItem
import org.schabi.newpipe.util.image.ImageStrategy
import java.time.OffsetDateTime
-class StreamStatisticsEntry(
+data class StreamStatisticsEntry(
@Embedded
val streamEntity: StreamEntity,
@@ -26,18 +33,23 @@ class StreamStatisticsEntry(
@ColumnInfo(name = STREAM_WATCH_COUNT)
val watchCount: Long
) : LocalItem {
- fun toStreamInfoItem(): StreamInfoItem {
- val item = StreamInfoItem(streamEntity.serviceId, streamEntity.url, streamEntity.title, streamEntity.streamType)
- item.duration = streamEntity.duration
- item.uploaderName = streamEntity.uploader
- item.uploaderUrl = streamEntity.uploaderUrl
- item.thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
- return item
- }
+ override val localItemType: LocalItem.LocalItemType
+ get() = LocalItem.LocalItemType.STATISTIC_STREAM_ITEM
- override fun getLocalItemType(): LocalItem.LocalItemType {
- return LocalItem.LocalItemType.STATISTIC_STREAM_ITEM
+ @Ignore
+ fun toStreamInfoItem(): StreamInfoItem {
+ return StreamInfoItem(
+ streamEntity.serviceId,
+ streamEntity.url,
+ streamEntity.title,
+ streamEntity.streamType
+ ).apply {
+ duration = streamEntity.duration
+ uploaderName = streamEntity.uploader
+ uploaderUrl = streamEntity.uploaderUrl
+ thumbnails = ImageStrategy.dbUrlToImageList(streamEntity.thumbnailUrl)
+ }
}
companion object {
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
deleted file mode 100644
index 06371248d62..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.java
+++ /dev/null
@@ -1,48 +0,0 @@
-package org.schabi.newpipe.database.stream.dao;
-
-import androidx.room.Dao;
-import androidx.room.Insert;
-import androidx.room.OnConflictStrategy;
-import androidx.room.Query;
-import androidx.room.Transaction;
-
-import org.schabi.newpipe.database.BasicDAO;
-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;
-
-@Dao
-public interface StreamStateDAO extends BasicDAO {
- @Override
- @Query("SELECT * FROM " + STREAM_STATE_TABLE)
- Flowable> getAll();
-
- @Override
- @Query("DELETE FROM " + STREAM_STATE_TABLE)
- int deleteAll();
-
- @Override
- default Flowable> listByService(final int serviceId) {
- throw new UnsupportedOperationException();
- }
-
- @Query("SELECT * FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
- Flowable> getState(long streamId);
-
- @Query("DELETE FROM " + STREAM_STATE_TABLE + " WHERE " + JOIN_STREAM_ID + " = :streamId")
- int deleteState(long streamId);
-
- @Insert(onConflict = OnConflictStrategy.IGNORE)
- void silentInsertInternal(StreamStateEntity streamState);
-
- @Transaction
- default long upsert(final StreamStateEntity stream) {
- silentInsertInternal(stream);
- return update(stream);
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt
new file mode 100644
index 00000000000..f3c44f1f264
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/stream/dao/StreamStateDAO.kt
@@ -0,0 +1,45 @@
+/*
+ * SPDX-FileCopyrightText: 2018-2021 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database.stream.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Transaction
+import io.reactivex.rxjava3.core.Flowable
+import org.schabi.newpipe.database.BasicDAO
+import org.schabi.newpipe.database.stream.model.StreamStateEntity
+
+@Dao
+interface StreamStateDAO : BasicDAO {
+
+ @Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE)
+ override fun getAll(): Flowable>
+
+ @Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE)
+ override fun deleteAll(): Int
+
+ override fun listByService(serviceId: Int): Flowable> {
+ throw UnsupportedOperationException()
+ }
+
+ @Query("SELECT * FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
+ fun getState(streamId: Long): Flowable>
+
+ @Query("DELETE FROM " + StreamStateEntity.STREAM_STATE_TABLE + " WHERE " + StreamStateEntity.JOIN_STREAM_ID + " = :streamId")
+ fun deleteState(streamId: Long): Int
+
+ @Insert(onConflict = OnConflictStrategy.Companion.IGNORE)
+ fun silentInsertInternal(streamState: StreamStateEntity)
+
+ @Transaction
+ fun upsert(stream: StreamStateEntity): Long {
+ silentInsertInternal(stream)
+ return update(stream).toLong()
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java
deleted file mode 100644
index 627acea45a4..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.java
+++ /dev/null
@@ -1,112 +0,0 @@
-package org.schabi.newpipe.database.stream.model;
-
-import androidx.annotation.Nullable;
-import androidx.room.ColumnInfo;
-import androidx.room.Entity;
-import androidx.room.ForeignKey;
-
-import java.util.Objects;
-
-import static androidx.room.ForeignKey.CASCADE;
-import static org.schabi.newpipe.database.stream.model.StreamStateEntity.JOIN_STREAM_ID;
-import static org.schabi.newpipe.database.stream.model.StreamStateEntity.STREAM_STATE_TABLE;
-
-@Entity(tableName = STREAM_STATE_TABLE,
- primaryKeys = {JOIN_STREAM_ID},
- foreignKeys = {
- @ForeignKey(entity = StreamEntity.class,
- parentColumns = StreamEntity.STREAM_ID,
- childColumns = JOIN_STREAM_ID,
- onDelete = CASCADE, onUpdate = CASCADE)
- })
-public class StreamStateEntity {
- public static final String STREAM_STATE_TABLE = "stream_state";
- public static final String JOIN_STREAM_ID = "stream_id";
- // This additional field is required for the SQL query because 'stream_id' is used
- // for some other joins already
- public static final String JOIN_STREAM_ID_ALIAS = "stream_id_alias";
- public static final String STREAM_PROGRESS_MILLIS = "progress_time";
-
- /**
- * Playback state will not be saved, if playback time is less than this threshold (5000ms = 5s).
- */
- public static final long PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS = 5000;
-
- /**
- * Stream will be considered finished if the playback time left exceeds this threshold
- * (60000ms = 60s).
- * @see #isFinished(long)
- * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreams()
- * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreamsForGroup(long)
- */
- public static final long PLAYBACK_FINISHED_END_MILLISECONDS = 60000;
-
- @ColumnInfo(name = JOIN_STREAM_ID)
- private long streamUid;
-
- @ColumnInfo(name = STREAM_PROGRESS_MILLIS)
- private long progressMillis;
-
- public StreamStateEntity(final long streamUid, final long progressMillis) {
- this.streamUid = streamUid;
- this.progressMillis = progressMillis;
- }
-
- public long getStreamUid() {
- return streamUid;
- }
-
- public void setStreamUid(final long streamUid) {
- this.streamUid = streamUid;
- }
-
- public long getProgressMillis() {
- return progressMillis;
- }
-
- public void setProgressMillis(final long progressMillis) {
- this.progressMillis = progressMillis;
- }
-
- /**
- * The state will be considered valid, and thus be saved, if the progress is more than {@link
- * #PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS} or at least 1/4 of the video length.
- * @param durationInSeconds the duration of the stream connected with this state, in seconds
- * @return whether this stream state entity should be saved or not
- */
- public boolean isValid(final long durationInSeconds) {
- return progressMillis > PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS
- || progressMillis > durationInSeconds * 1000 / 4;
- }
-
- /**
- * The video will be considered as finished, if the time left is less than {@link
- * #PLAYBACK_FINISHED_END_MILLISECONDS} and the progress is at least 3/4 of the video length.
- * The state will be saved anyway, so that it can be shown under stream info items, but the
- * player will not resume if a state is considered as finished. Finished streams are also the
- * ones that can be filtered out in the feed fragment.
- * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreams()
- * @see org.schabi.newpipe.database.feed.dao.FeedDAO#getLiveOrNotPlayedStreamsForGroup(long)
- * @param durationInSeconds the duration of the stream connected with this state, in seconds
- * @return whether the stream is finished or not
- */
- public boolean isFinished(final long durationInSeconds) {
- return progressMillis >= durationInSeconds * 1000 - PLAYBACK_FINISHED_END_MILLISECONDS
- && progressMillis >= durationInSeconds * 1000 * 3 / 4;
- }
-
- @Override
- public boolean equals(@Nullable final Object obj) {
- if (obj instanceof StreamStateEntity) {
- return ((StreamStateEntity) obj).streamUid == streamUid
- && ((StreamStateEntity) obj).progressMillis == progressMillis;
- } else {
- return false;
- }
- }
-
- @Override
- public int hashCode() {
- return Objects.hash(streamUid, progressMillis);
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.kt b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.kt
new file mode 100644
index 00000000000..759a2dcec31
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/stream/model/StreamStateEntity.kt
@@ -0,0 +1,85 @@
+/*
+ * SPDX-FileCopyrightText: 2018-2023 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database.stream.model
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+import androidx.room.ForeignKey.Companion.CASCADE
+import org.schabi.newpipe.database.stream.model.StreamEntity.Companion.STREAM_ID
+import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.JOIN_STREAM_ID
+import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.PLAYBACK_FINISHED_END_MILLISECONDS
+import org.schabi.newpipe.database.stream.model.StreamStateEntity.Companion.STREAM_STATE_TABLE
+
+@Entity(
+ tableName = STREAM_STATE_TABLE,
+ primaryKeys = [JOIN_STREAM_ID],
+ foreignKeys = [
+ ForeignKey(
+ entity = StreamEntity::class,
+ parentColumns = arrayOf(STREAM_ID),
+ childColumns = arrayOf(JOIN_STREAM_ID),
+ onDelete = CASCADE,
+ onUpdate = CASCADE
+ )
+ ]
+)
+data class StreamStateEntity(
+ @ColumnInfo(name = JOIN_STREAM_ID)
+ val streamUid: Long,
+
+ @ColumnInfo(name = STREAM_PROGRESS_MILLIS)
+ val progressMillis: Long
+) {
+ /**
+ * The state will be considered valid, and thus be saved, if the progress is more than
+ * [PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS] or at least 1/4 of the video length.
+ * @param durationInSeconds the duration of the stream connected with this state, in seconds
+ * @return whether this stream state entity should be saved or not
+ */
+ fun isValid(durationInSeconds: Long): Boolean {
+ return progressMillis > PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS ||
+ progressMillis > durationInSeconds * 1000 / 4
+ }
+
+ /**
+ * The video will be considered as finished, if the time left is less than
+ * [PLAYBACK_FINISHED_END_MILLISECONDS] and the progress is at least 3/4 of the video length.
+ * The state will be saved anyway, so that it can be shown under stream info items, but the
+ * player will not resume if a state is considered as finished. Finished streams are also the
+ * ones that can be filtered out in the feed fragment.
+ * @param durationInSeconds the duration of the stream connected with this state, in seconds
+ * @return whether the stream is finished or not
+ */
+ fun isFinished(durationInSeconds: Long): Boolean {
+ return progressMillis >= durationInSeconds * 1000 - PLAYBACK_FINISHED_END_MILLISECONDS &&
+ progressMillis >= durationInSeconds * 1000 * 3 / 4
+ }
+
+ companion object {
+ const val STREAM_STATE_TABLE = "stream_state"
+ const val JOIN_STREAM_ID = "stream_id"
+
+ // This additional field is required for the SQL query because 'stream_id' is used
+ // for some other joins already
+ const val JOIN_STREAM_ID_ALIAS = "stream_id_alias"
+ const val STREAM_PROGRESS_MILLIS = "progress_time"
+
+ /**
+ * Playback state will not be saved, if playback time is less than this threshold
+ * (5000ms = 5s).
+ */
+ const val PLAYBACK_SAVE_THRESHOLD_START_MILLISECONDS = 5000L
+
+ /**
+ * Stream will be considered finished if the playback time left exceeds this threshold
+ * (60000ms = 60s).
+ * @see org.schabi.newpipe.database.stream.model.StreamStateEntity.isFinished
+ */
+ const val PLAYBACK_FINISHED_END_MILLISECONDS = 60000L
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.java b/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.java
deleted file mode 100644
index 07e0eb7d358..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.java
+++ /dev/null
@@ -1,14 +0,0 @@
-package org.schabi.newpipe.database.subscription;
-
-import androidx.annotation.IntDef;
-import java.lang.annotation.Retention;
-import java.lang.annotation.RetentionPolicy;
-
-@IntDef({NotificationMode.DISABLED, NotificationMode.ENABLED})
-@Retention(RetentionPolicy.SOURCE)
-public @interface NotificationMode {
-
- int DISABLED = 0;
- int ENABLED = 1;
- //other values reserved for the future
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.kt
new file mode 100644
index 00000000000..f9bb18c0cf7
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/subscription/NotificationMode.kt
@@ -0,0 +1,18 @@
+/*
+ * SPDX-FileCopyrightText: 2021 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database.subscription
+
+import androidx.annotation.IntDef
+
+@IntDef(NotificationMode.Companion.DISABLED, NotificationMode.Companion.ENABLED)
+@Retention(AnnotationRetention.SOURCE)
+annotation class NotificationMode {
+ companion object {
+ const val DISABLED = 0
+ const val ENABLED = 1 // other values reserved for the future
+ }
+}
diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt
index 47b6f4dd9aa..e6fdcbf70ec 100644
--- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt
+++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionDAO.kt
@@ -99,7 +99,7 @@ abstract class SubscriptionDAO : BasicDAO {
if (uidFromInsert != -1L) {
entity.uid = uidFromInsert
} else {
- val subscriptionIdFromDb = getSubscriptionIdInternal(entity.serviceId, entity.url)
+ val subscriptionIdFromDb = getSubscriptionIdInternal(entity.serviceId, entity.url!!)
?: throw IllegalStateException("Subscription cannot be null just after insertion.")
entity.uid = subscriptionIdFromDb
diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java
deleted file mode 100644
index df5a3067af0..00000000000
--- a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.java
+++ /dev/null
@@ -1,200 +0,0 @@
-package org.schabi.newpipe.database.subscription;
-
-import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.room.ColumnInfo;
-import androidx.room.Entity;
-import androidx.room.Ignore;
-import androidx.room.Index;
-import androidx.room.PrimaryKey;
-
-import org.schabi.newpipe.extractor.channel.ChannelInfo;
-import org.schabi.newpipe.extractor.channel.ChannelInfoItem;
-import org.schabi.newpipe.util.Constants;
-import org.schabi.newpipe.util.image.ImageStrategy;
-
-import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_SERVICE_ID;
-import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_TABLE;
-import static org.schabi.newpipe.database.subscription.SubscriptionEntity.SUBSCRIPTION_URL;
-
-@Entity(tableName = SUBSCRIPTION_TABLE,
- indices = {@Index(value = {SUBSCRIPTION_SERVICE_ID, SUBSCRIPTION_URL}, unique = true)})
-public class SubscriptionEntity {
- public static final String SUBSCRIPTION_UID = "uid";
- public static final String SUBSCRIPTION_TABLE = "subscriptions";
- public static final String SUBSCRIPTION_SERVICE_ID = "service_id";
- public static final String SUBSCRIPTION_URL = "url";
- public static final String SUBSCRIPTION_NAME = "name";
- public static final String SUBSCRIPTION_AVATAR_URL = "avatar_url";
- public static final String SUBSCRIPTION_SUBSCRIBER_COUNT = "subscriber_count";
- public static final String SUBSCRIPTION_DESCRIPTION = "description";
- public static final String SUBSCRIPTION_NOTIFICATION_MODE = "notification_mode";
-
- @PrimaryKey(autoGenerate = true)
- private long uid = 0;
-
- @ColumnInfo(name = SUBSCRIPTION_SERVICE_ID)
- private int serviceId = Constants.NO_SERVICE_ID;
-
- @ColumnInfo(name = SUBSCRIPTION_URL)
- private String url;
-
- @ColumnInfo(name = SUBSCRIPTION_NAME)
- private String name;
-
- @ColumnInfo(name = SUBSCRIPTION_AVATAR_URL)
- private String avatarUrl;
-
- @ColumnInfo(name = SUBSCRIPTION_SUBSCRIBER_COUNT)
- private Long subscriberCount;
-
- @ColumnInfo(name = SUBSCRIPTION_DESCRIPTION)
- private String description;
-
- @ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE)
- private int notificationMode;
-
- @Ignore
- public static SubscriptionEntity from(@NonNull final ChannelInfo info) {
- final SubscriptionEntity result = new SubscriptionEntity();
- result.setServiceId(info.getServiceId());
- result.setUrl(info.getUrl());
- result.setData(info.getName(), ImageStrategy.imageListToDbUrl(info.getAvatars()),
- info.getDescription(), info.getSubscriberCount());
- return result;
- }
-
- public long getUid() {
- return uid;
- }
-
- public void setUid(final long uid) {
- this.uid = uid;
- }
-
- public int getServiceId() {
- return serviceId;
- }
-
- public void setServiceId(final int serviceId) {
- this.serviceId = serviceId;
- }
-
- public String getUrl() {
- return url;
- }
-
- public void setUrl(final String url) {
- this.url = url;
- }
-
- public String getName() {
- return name;
- }
-
- public void setName(final String name) {
- this.name = name;
- }
-
- @Nullable
- public String getAvatarUrl() {
- return avatarUrl;
- }
-
- public void setAvatarUrl(@Nullable final String avatarUrl) {
- this.avatarUrl = avatarUrl;
- }
-
- public Long getSubscriberCount() {
- return subscriberCount;
- }
-
- public void setSubscriberCount(final Long subscriberCount) {
- this.subscriberCount = subscriberCount;
- }
-
- public String getDescription() {
- return description;
- }
-
- public void setDescription(final String description) {
- this.description = description;
- }
-
- @NotificationMode
- public int getNotificationMode() {
- return notificationMode;
- }
-
- public void setNotificationMode(@NotificationMode final int notificationMode) {
- this.notificationMode = notificationMode;
- }
-
- @Ignore
- public void setData(final String n, final String au, final String d, final Long sc) {
- this.setName(n);
- this.setAvatarUrl(au);
- this.setDescription(d);
- this.setSubscriberCount(sc);
- }
-
- @Ignore
- public ChannelInfoItem toChannelInfoItem() {
- final ChannelInfoItem item = new ChannelInfoItem(getServiceId(), getUrl(), getName());
- item.setThumbnails(ImageStrategy.dbUrlToImageList(getAvatarUrl()));
- item.setSubscriberCount(getSubscriberCount());
- item.setDescription(getDescription());
- return item;
- }
-
-
- // TODO: Remove these generated methods by migrating this class to a data class from Kotlin.
- @Override
- @SuppressWarnings("EqualsReplaceableByObjectsCall")
- public boolean equals(final Object o) {
- if (this == o) {
- return true;
- }
- if (o == null || getClass() != o.getClass()) {
- return false;
- }
-
- final SubscriptionEntity that = (SubscriptionEntity) o;
-
- if (uid != that.uid) {
- return false;
- }
- if (serviceId != that.serviceId) {
- return false;
- }
- if (!url.equals(that.url)) {
- return false;
- }
- if (name != null ? !name.equals(that.name) : that.name != null) {
- return false;
- }
- if (avatarUrl != null ? !avatarUrl.equals(that.avatarUrl) : that.avatarUrl != null) {
- return false;
- }
- if (subscriberCount != null
- ? !subscriberCount.equals(that.subscriberCount)
- : that.subscriberCount != null) {
- return false;
- }
- return description != null
- ? description.equals(that.description)
- : that.description == null;
- }
-
- @Override
- public int hashCode() {
- int result = (int) (uid ^ (uid >>> 32));
- result = 31 * result + serviceId;
- result = 31 * result + url.hashCode();
- result = 31 * result + (name != null ? name.hashCode() : 0);
- result = 31 * result + (avatarUrl != null ? avatarUrl.hashCode() : 0);
- result = 31 * result + (subscriberCount != null ? subscriberCount.hashCode() : 0);
- result = 31 * result + (description != null ? description.hashCode() : 0);
- return result;
- }
-}
diff --git a/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt
new file mode 100644
index 00000000000..7df9830e4fc
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/database/subscription/SubscriptionEntity.kt
@@ -0,0 +1,87 @@
+/*
+ * SPDX-FileCopyrightText: 2017-2024 NewPipe contributors
+ * SPDX-FileCopyrightText: 2025 NewPipe e.V.
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+package org.schabi.newpipe.database.subscription
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.Ignore
+import androidx.room.Index
+import androidx.room.PrimaryKey
+import org.schabi.newpipe.extractor.channel.ChannelInfo
+import org.schabi.newpipe.extractor.channel.ChannelInfoItem
+import org.schabi.newpipe.util.NO_SERVICE_ID
+import org.schabi.newpipe.util.image.ImageStrategy
+
+@Entity(
+ tableName = SubscriptionEntity.Companion.SUBSCRIPTION_TABLE,
+ indices = [
+ Index(
+ value = [SubscriptionEntity.Companion.SUBSCRIPTION_SERVICE_ID, SubscriptionEntity.Companion.SUBSCRIPTION_URL],
+ unique = true
+ )
+ ]
+)
+data class SubscriptionEntity(
+ @PrimaryKey(autoGenerate = true)
+ var uid: Long = 0,
+
+ @ColumnInfo(name = SUBSCRIPTION_SERVICE_ID)
+ var serviceId: Int = NO_SERVICE_ID,
+
+ @ColumnInfo(name = SUBSCRIPTION_URL)
+ var url: String? = null,
+
+ @ColumnInfo(name = SUBSCRIPTION_NAME)
+ var name: String? = null,
+
+ @ColumnInfo(name = SUBSCRIPTION_AVATAR_URL)
+ var avatarUrl: String? = null,
+
+ @ColumnInfo(name = SUBSCRIPTION_SUBSCRIBER_COUNT)
+ var subscriberCount: Long? = null,
+
+ @ColumnInfo(name = SUBSCRIPTION_DESCRIPTION)
+ var description: String? = null,
+
+ @get:NotificationMode
+ @ColumnInfo(name = SUBSCRIPTION_NOTIFICATION_MODE)
+ var notificationMode: Int = 0
+) {
+ @Ignore
+ fun toChannelInfoItem(): ChannelInfoItem {
+ return ChannelInfoItem(this.serviceId, this.url, this.name).apply {
+ thumbnails = ImageStrategy.dbUrlToImageList(this@SubscriptionEntity.avatarUrl)
+ subscriberCount = this@SubscriptionEntity.subscriberCount ?: -1
+ description = this@SubscriptionEntity.description
+ }
+ }
+
+ companion object {
+ const val SUBSCRIPTION_UID: String = "uid"
+ const val SUBSCRIPTION_TABLE: String = "subscriptions"
+ const val SUBSCRIPTION_SERVICE_ID: String = "service_id"
+ const val SUBSCRIPTION_URL: String = "url"
+ const val SUBSCRIPTION_NAME: String = "name"
+ const val SUBSCRIPTION_AVATAR_URL: String = "avatar_url"
+ const val SUBSCRIPTION_SUBSCRIBER_COUNT: String = "subscriber_count"
+ const val SUBSCRIPTION_DESCRIPTION: String = "description"
+ const val SUBSCRIPTION_NOTIFICATION_MODE: String = "notification_mode"
+
+ @JvmStatic
+ @Ignore
+ fun from(info: ChannelInfo): SubscriptionEntity {
+ return SubscriptionEntity(
+ serviceId = info.serviceId,
+ url = info.url,
+ name = info.name,
+ avatarUrl = ImageStrategy.imageListToDbUrl(info.avatars),
+ description = info.description,
+ subscriberCount = info.subscriberCount
+ )
+ }
+ }
+}
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 003aa5893af..741bda24633 100644
--- a/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/download/DownloadDialog.java
@@ -389,8 +389,7 @@ private void fetchStreamsSize() {
}
}, throwable -> ErrorUtil.showSnackbar(context,
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
- "Downloading video stream size",
- currentInfo.getServiceId()))));
+ "Downloading video stream size", currentInfo))));
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(getWrappedAudioStreams())
.subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
@@ -399,8 +398,7 @@ private void fetchStreamsSize() {
}
}, throwable -> ErrorUtil.showSnackbar(context,
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
- "Downloading audio stream size",
- currentInfo.getServiceId()))));
+ "Downloading audio stream size", currentInfo))));
disposables.add(StreamInfoWrapper.fetchMoreInfoForWrapper(wrappedSubtitleStreams)
.subscribe(result -> {
if (dialogBinding.videoAudioGroup.getCheckedRadioButtonId()
@@ -409,8 +407,7 @@ private void fetchStreamsSize() {
}
}, throwable -> ErrorUtil.showSnackbar(context,
new ErrorInfo(throwable, UserAction.DOWNLOAD_OPEN_DIALOG,
- "Downloading subtitle stream size",
- currentInfo.getServiceId()))));
+ "Downloading subtitle stream size", currentInfo))));
}
private void setupAudioTrackSpinner() {
@@ -1136,7 +1133,7 @@ private void continueSelectedDownload(@NonNull final StoredFileHelper storage) {
}
DownloadManagerService.startMission(context, urls, storage, kind, threads,
- currentInfo.getUrl(), psName, psArgs, nearLength, new ArrayList<>(recoveryInfo));
+ currentInfo, psName, psArgs, nearLength, new ArrayList<>(recoveryInfo));
Toast.makeText(context, getString(R.string.download_has_started),
Toast.LENGTH_SHORT).show();
diff --git a/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java b/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java
index 4d99663643d..90d8f479783 100644
--- a/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java
+++ b/app/src/main/java/org/schabi/newpipe/error/AcraReportSender.java
@@ -36,8 +36,8 @@ public void send(@NonNull final Context context, @NonNull final CrashReportData
ErrorUtil.openActivity(context, new ErrorInfo(
new String[]{report.getString(ReportField.STACK_TRACE)},
UserAction.UI_ERROR,
- ErrorInfo.SERVICE_NONE,
"ACRA report",
+ null,
R.string.app_ui_crash));
}
}
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 a07b9b0b5d9..160dcca4df4 100644
--- a/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java
+++ b/app/src/main/java/org/schabi/newpipe/error/ErrorActivity.java
@@ -115,7 +115,7 @@ protected void onCreate(final Bundle savedInstanceState) {
// normal bugreport
buildInfo(errorInfo);
- activityErrorBinding.errorMessageView.setText(errorInfo.getMessageStringId());
+ activityErrorBinding.errorMessageView.setText(errorInfo.getMessage(this));
activityErrorBinding.errorView.setText(formErrorText(errorInfo.getStackTraces()));
// print stack trace once again for debugging:
diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt
index 6d8c1bd638c..609fbb3361a 100644
--- a/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt
+++ b/app/src/main/java/org/schabi/newpipe/error/ErrorInfo.kt
@@ -1,115 +1,304 @@
package org.schabi.newpipe.error
+import android.content.Context
import android.os.Parcelable
import androidx.annotation.StringRes
+import androidx.core.content.ContextCompat
import com.google.android.exoplayer2.ExoPlaybackException
-import kotlinx.parcelize.IgnoredOnParcel
+import com.google.android.exoplayer2.upstream.HttpDataSource
+import com.google.android.exoplayer2.upstream.Loader
import kotlinx.parcelize.Parcelize
import org.schabi.newpipe.R
import org.schabi.newpipe.extractor.Info
+import org.schabi.newpipe.extractor.ServiceList
+import org.schabi.newpipe.extractor.ServiceList.YouTube
import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
+import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
import org.schabi.newpipe.extractor.exceptions.ExtractionException
+import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException
+import org.schabi.newpipe.extractor.exceptions.PaidContentException
+import org.schabi.newpipe.extractor.exceptions.PrivateContentException
+import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
+import org.schabi.newpipe.extractor.exceptions.SignInConfirmNotBotException
+import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException
+import org.schabi.newpipe.extractor.exceptions.UnsupportedContentInCountryException
+import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException
import org.schabi.newpipe.ktx.isNetworkRelated
-import org.schabi.newpipe.util.ServiceHelper
+import org.schabi.newpipe.player.mediasource.FailedMediaSource
+import org.schabi.newpipe.player.resolver.PlaybackResolver
+import java.net.UnknownHostException
+/**
+ * An error has occurred in the app. This class contains plain old parcelable data that can be used
+ * to report the error and to show it to the user along with correct action buttons.
+ */
@Parcelize
-class ErrorInfo(
+class ErrorInfo private constructor(
val stackTraces: Array,
val userAction: UserAction,
- val serviceName: String,
val request: String,
- val messageStringId: Int
+ val serviceId: Int?,
+ private val message: ErrorMessage,
+ /**
+ * If `true`, a report button will be shown for this error. Otherwise the error is not something
+ * that can really be reported (e.g. a network issue, or content not being available at all).
+ */
+ val isReportable: Boolean,
+ /**
+ * If `true`, the process causing this error can be retried, otherwise not.
+ */
+ val isRetryable: Boolean,
+ /**
+ * If present, indicates that the exception was a ReCaptchaException, and this is the URL
+ * provided by the service that can be used to solve the ReCaptcha challenge.
+ */
+ val recaptchaUrl: String?,
+ /**
+ * If present, this resource can alternatively be opened in browser (useful if NewPipe is
+ * badly broken).
+ */
+ val openInBrowserUrl: String?,
) : Parcelable {
- // no need to store throwable, all data for report is in other variables
- // also, the throwable might not be serializable, see TeamNewPipe/NewPipe#7302
- @IgnoredOnParcel
- var throwable: Throwable? = null
-
- private constructor(
+ @JvmOverloads
+ constructor(
throwable: Throwable,
userAction: UserAction,
- serviceName: String,
- request: String
+ request: String,
+ serviceId: Int? = null,
+ openInBrowserUrl: String? = null,
) : this(
throwableToStringList(throwable),
userAction,
- serviceName,
request,
- getMessageStringId(throwable, userAction)
- ) {
- this.throwable = throwable
- }
+ serviceId,
+ getMessage(throwable, userAction, serviceId),
+ isReportable(throwable),
+ isRetryable(throwable),
+ (throwable as? ReCaptchaException)?.url,
+ openInBrowserUrl,
+ )
- private constructor(
- throwable: List,
+ @JvmOverloads
+ constructor(
+ throwables: List,
userAction: UserAction,
- serviceName: String,
- request: String
+ request: String,
+ serviceId: Int? = null,
+ openInBrowserUrl: String? = null,
) : this(
- throwableListToStringList(throwable),
+ throwableListToStringList(throwables),
userAction,
- serviceName,
request,
- getMessageStringId(throwable.firstOrNull(), userAction)
- ) {
- this.throwable = throwable.firstOrNull()
+ serviceId,
+ getMessage(throwables.firstOrNull(), userAction, serviceId),
+ throwables.any(::isReportable),
+ throwables.isEmpty() || throwables.any(::isRetryable),
+ throwables.firstNotNullOfOrNull { it as? ReCaptchaException }?.url,
+ openInBrowserUrl,
+ )
+
+ // constructor to manually build ErrorInfo when no throwable is available
+ constructor(
+ stackTraces: Array,
+ userAction: UserAction,
+ request: String,
+ serviceId: Int?,
+ @StringRes message: Int
+ ) :
+ this(
+ stackTraces, userAction, request, serviceId, ErrorMessage(message),
+ true, false, null, null
+ )
+
+ // constructor with only one throwable to extract service id and openInBrowserUrl from an Info
+ constructor(
+ throwable: Throwable,
+ userAction: UserAction,
+ request: String,
+ info: Info?,
+ ) :
+ this(throwable, userAction, request, info?.serviceId, info?.url)
+
+ // constructor with multiple throwables to extract service id and openInBrowserUrl from an Info
+ constructor(
+ throwables: List,
+ userAction: UserAction,
+ request: String,
+ info: Info?,
+ ) :
+ this(throwables, userAction, request, info?.serviceId, info?.url)
+
+ fun getServiceName(): String {
+ return getServiceName(serviceId)
}
- // constructors with single throwable
- constructor(throwable: Throwable, userAction: UserAction, request: String) :
- this(throwable, userAction, SERVICE_NONE, request)
- constructor(throwable: Throwable, userAction: UserAction, request: String, serviceId: Int) :
- this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request)
- constructor(throwable: Throwable, userAction: UserAction, request: String, info: Info?) :
- this(throwable, userAction, getInfoServiceName(info), request)
-
- // constructors with list of throwables
- constructor(throwable: List, userAction: UserAction, request: String) :
- this(throwable, userAction, SERVICE_NONE, request)
- constructor(throwable: List, userAction: UserAction, request: String, serviceId: Int) :
- this(throwable, userAction, ServiceHelper.getNameOfServiceById(serviceId), request)
- constructor(throwable: List, userAction: UserAction, request: String, info: Info?) :
- this(throwable, userAction, getInfoServiceName(info), request)
+ fun getMessage(context: Context): String {
+ return message.getString(context)
+ }
companion object {
- const val SERVICE_NONE = "none"
+ @Parcelize
+ class ErrorMessage(
+ @StringRes
+ private val stringRes: Int,
+ private vararg val formatArgs: String,
+ ) : Parcelable {
+ fun getString(context: Context): String {
+ return if (formatArgs.isEmpty()) {
+ // use ContextCompat.getString() just in case context is not AppCompatActivity
+ ContextCompat.getString(context, stringRes)
+ } else {
+ // ContextCompat.getString() with formatArgs does not exist, so we just
+ // replicate its source code but with formatArgs
+ ContextCompat.getContextForLanguage(context).getString(stringRes, *formatArgs)
+ }
+ }
+ }
+
+ const val SERVICE_NONE = ""
+
+ private fun getServiceName(serviceId: Int?) =
+ // not using getNameOfServiceById since we want to accept a nullable serviceId and we
+ // want to default to SERVICE_NONE
+ ServiceList.all()?.firstOrNull { it.serviceId == serviceId }?.serviceInfo?.name
+ ?: SERVICE_NONE
fun throwableToStringList(throwable: Throwable) = arrayOf(throwable.stackTraceToString())
fun throwableListToStringList(throwableList: List) =
throwableList.map { it.stackTraceToString() }.toTypedArray()
- private fun getInfoServiceName(info: Info?) =
- if (info == null) SERVICE_NONE else ServiceHelper.getNameOfServiceById(info.serviceId)
-
- @StringRes
- private fun getMessageStringId(
+ fun getMessage(
throwable: Throwable?,
- action: UserAction
- ): Int {
+ action: UserAction?,
+ serviceId: Int?,
+ ): ErrorMessage {
return when {
- throwable is AccountTerminatedException -> R.string.account_terminated
- throwable is ContentNotAvailableException -> R.string.content_not_available
- throwable != null && throwable.isNetworkRelated -> R.string.network_error
- throwable is ContentNotSupportedException -> R.string.content_not_supported
- throwable is ExtractionException -> R.string.parsing_error
+ // player exceptions
+ // some may be IOException, so do these checks before isNetworkRelated!
throwable is ExoPlaybackException -> {
- when (throwable.type) {
- ExoPlaybackException.TYPE_SOURCE -> R.string.player_stream_failure
- ExoPlaybackException.TYPE_UNEXPECTED -> R.string.player_recoverable_failure
- else -> R.string.player_unrecoverable_failure
+ val cause = throwable.cause
+ when {
+ cause is HttpDataSource.InvalidResponseCodeException -> {
+ if (cause.responseCode == 403) {
+ if (serviceId == YouTube.serviceId) {
+ ErrorMessage(R.string.youtube_player_http_403)
+ } else {
+ ErrorMessage(R.string.player_http_403)
+ }
+ } else {
+ ErrorMessage(R.string.player_http_invalid_status, cause.responseCode.toString())
+ }
+ }
+ cause is Loader.UnexpectedLoaderException && cause.cause is ExtractionException ->
+ getMessage(throwable, action, serviceId)
+ throwable.type == ExoPlaybackException.TYPE_SOURCE ->
+ ErrorMessage(R.string.player_stream_failure)
+ throwable.type == ExoPlaybackException.TYPE_UNEXPECTED ->
+ ErrorMessage(R.string.player_recoverable_failure)
+ else ->
+ ErrorMessage(R.string.player_unrecoverable_failure)
}
}
- action == UserAction.UI_ERROR -> R.string.app_ui_crash
- action == UserAction.REQUESTED_COMMENTS -> R.string.error_unable_to_load_comments
- action == UserAction.SUBSCRIPTION_CHANGE -> R.string.subscription_change_failed
- action == UserAction.SUBSCRIPTION_UPDATE -> R.string.subscription_update_failed
- action == UserAction.LOAD_IMAGE -> R.string.could_not_load_thumbnails
- action == UserAction.DOWNLOAD_OPEN_DIALOG -> R.string.could_not_setup_download_menu
- else -> R.string.general_error
+ throwable is FailedMediaSource.FailedMediaSourceException ->
+ getMessage(throwable.cause, action, serviceId)
+ throwable is PlaybackResolver.ResolverException ->
+ ErrorMessage(R.string.player_stream_failure)
+
+ // content not available exceptions
+ throwable is AccountTerminatedException ->
+ throwable.message
+ ?.takeIf { reason -> !reason.isEmpty() }
+ ?.let { reason ->
+ ErrorMessage(
+ R.string.account_terminated_service_provides_reason,
+ getServiceName(serviceId),
+ reason
+ )
+ }
+ ?: ErrorMessage(R.string.account_terminated)
+ throwable is AgeRestrictedContentException ->
+ ErrorMessage(R.string.restricted_video_no_stream)
+ throwable is GeographicRestrictionException ->
+ ErrorMessage(R.string.georestricted_content)
+ throwable is PaidContentException ->
+ ErrorMessage(R.string.paid_content)
+ throwable is PrivateContentException ->
+ ErrorMessage(R.string.private_content)
+ throwable is SoundCloudGoPlusContentException ->
+ ErrorMessage(R.string.soundcloud_go_plus_content)
+ throwable is UnsupportedContentInCountryException ->
+ ErrorMessage(R.string.unsupported_content_in_country)
+ throwable is YoutubeMusicPremiumContentException ->
+ ErrorMessage(R.string.youtube_music_premium_content)
+ throwable is SignInConfirmNotBotException ->
+ ErrorMessage(R.string.sign_in_confirm_not_bot_error, getServiceName(serviceId))
+ throwable is ContentNotAvailableException ->
+ ErrorMessage(R.string.content_not_available)
+
+ // other extractor exceptions
+ throwable is ContentNotSupportedException ->
+ ErrorMessage(R.string.content_not_supported)
+ // ReCaptchas will be handled in a special way anyway
+ throwable is ReCaptchaException ->
+ ErrorMessage(R.string.recaptcha_request_toast)
+ // test this at the end as many exceptions could be a subclass of IOException
+ throwable != null && throwable.isNetworkRelated ->
+ ErrorMessage(R.string.network_error)
+ // an extraction exception unrelated to the network
+ // is likely an issue with parsing the website
+ throwable is ExtractionException ->
+ ErrorMessage(R.string.parsing_error)
+
+ // user actions (in case the exception is null or unrecognizable)
+ action == UserAction.UI_ERROR ->
+ ErrorMessage(R.string.app_ui_crash)
+ action == UserAction.REQUESTED_COMMENTS ->
+ ErrorMessage(R.string.error_unable_to_load_comments)
+ action == UserAction.SUBSCRIPTION_CHANGE ->
+ ErrorMessage(R.string.subscription_change_failed)
+ action == UserAction.SUBSCRIPTION_UPDATE ->
+ ErrorMessage(R.string.subscription_update_failed)
+ action == UserAction.LOAD_IMAGE ->
+ ErrorMessage(R.string.could_not_load_thumbnails)
+ action == UserAction.DOWNLOAD_OPEN_DIALOG ->
+ ErrorMessage(R.string.could_not_setup_download_menu)
+ else ->
+ ErrorMessage(R.string.error_snackbar_message)
+ }
+ }
+
+ fun isReportable(throwable: Throwable?): Boolean {
+ return when (throwable) {
+ // we don't have an exception, so this is a manually built error, which likely
+ // indicates that it's important and is thus reportable
+ null -> true
+ // the service explicitly said that content is not available (e.g. age restrictions,
+ // video deleted, etc.), there is no use in letting users report it
+ is ContentNotAvailableException -> false
+ // we know the content is not supported, no need to let the user report it
+ is ContentNotSupportedException -> false
+ // happens often when there is no internet connection; we don't use
+ // `throwable.isNetworkRelated` since any `IOException` would make that function
+ // return true, but not all `IOException`s are network related
+ is UnknownHostException -> false
+ // by default, this is an unexpected exception, which the user could report
+ else -> true
+ }
+ }
+
+ fun isRetryable(throwable: Throwable?): Boolean {
+ return when (throwable) {
+ // we know the content is not available, retrying won't help
+ is ContentNotAvailableException -> false
+ // we know the content is not supported, retrying won't help
+ is ContentNotSupportedException -> false
+ // by default (including if throwable is null), enable retrying (though the retry
+ // button will be shown only if a way to perform the retry is implemented)
+ else -> true
}
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt
index 14ec4114836..4ec5f58c35c 100644
--- a/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt
+++ b/app/src/main/java/org/schabi/newpipe/error/ErrorPanelHelper.kt
@@ -2,7 +2,6 @@ package org.schabi.newpipe.error
import android.content.Context
import android.content.Intent
-import android.util.Log
import android.view.View
import android.widget.Button
import android.widget.TextView
@@ -14,21 +13,7 @@ import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.R
-import org.schabi.newpipe.extractor.exceptions.AccountTerminatedException
-import org.schabi.newpipe.extractor.exceptions.AgeRestrictedContentException
-import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
-import org.schabi.newpipe.extractor.exceptions.ContentNotSupportedException
-import org.schabi.newpipe.extractor.exceptions.GeographicRestrictionException
-import org.schabi.newpipe.extractor.exceptions.PaidContentException
-import org.schabi.newpipe.extractor.exceptions.PrivateContentException
-import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
-import org.schabi.newpipe.extractor.exceptions.SoundCloudGoPlusContentException
-import org.schabi.newpipe.extractor.exceptions.YoutubeMusicPremiumContentException
-import org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty
import org.schabi.newpipe.ktx.animate
-import org.schabi.newpipe.ktx.isInterruptedCaused
-import org.schabi.newpipe.ktx.isNetworkRelated
-import org.schabi.newpipe.util.ServiceHelper
import org.schabi.newpipe.util.external_communication.ShareUtils
import java.util.concurrent.TimeUnit
@@ -78,64 +63,32 @@ class ErrorPanelHelper(
}
fun showError(errorInfo: ErrorInfo) {
-
- if (errorInfo.throwable != null && errorInfo.throwable!!.isInterruptedCaused) {
- if (DEBUG) {
- Log.w(TAG, "onError() isInterruptedCaused! = [$errorInfo.throwable]")
- }
- return
- }
-
ensureDefaultVisibility()
+ errorTextView.text = errorInfo.getMessage(context)
- if (errorInfo.throwable is ReCaptchaException) {
- errorTextView.setText(R.string.recaptcha_request_toast)
-
- showAndSetErrorButtonAction(
- R.string.recaptcha_solve
- ) {
+ if (errorInfo.recaptchaUrl != null) {
+ showAndSetErrorButtonAction(R.string.recaptcha_solve) {
// Starting ReCaptcha Challenge Activity
val intent = Intent(context, ReCaptchaActivity::class.java)
- intent.putExtra(
- ReCaptchaActivity.RECAPTCHA_URL_EXTRA,
- (errorInfo.throwable as ReCaptchaException).url
- )
+ intent.putExtra(ReCaptchaActivity.RECAPTCHA_URL_EXTRA, errorInfo.recaptchaUrl)
fragment.startActivityForResult(intent, ReCaptchaActivity.RECAPTCHA_REQUEST)
errorActionButton.setOnClickListener(null)
}
-
- errorRetryButton.isVisible = retryShouldBeShown
- showAndSetOpenInBrowserButtonAction(errorInfo)
- } else if (errorInfo.throwable is AccountTerminatedException) {
- errorTextView.setText(R.string.account_terminated)
-
- if (!isNullOrEmpty((errorInfo.throwable as AccountTerminatedException).message)) {
- errorServiceInfoTextView.text = context.resources.getString(
- R.string.service_provides_reason,
- ServiceHelper.getSelectedService(context)?.serviceInfo?.name ?: ""
- )
- errorServiceInfoTextView.isVisible = true
-
- errorServiceExplanationTextView.text =
- (errorInfo.throwable as AccountTerminatedException).message
- errorServiceExplanationTextView.isVisible = true
- }
- } else {
- showAndSetErrorButtonAction(
- R.string.error_snackbar_action
- ) {
+ } else if (errorInfo.isReportable) {
+ showAndSetErrorButtonAction(R.string.error_snackbar_action) {
ErrorUtil.openActivity(context, errorInfo)
}
+ }
- errorTextView.setText(getExceptionDescription(errorInfo.throwable))
+ if (errorInfo.isRetryable) {
+ errorRetryButton.isVisible = retryShouldBeShown
+ }
- if (errorInfo.throwable !is ContentNotAvailableException &&
- errorInfo.throwable !is ContentNotSupportedException
- ) {
- // show retry button only for content which is not unavailable or unsupported
- errorRetryButton.isVisible = retryShouldBeShown
+ if (errorInfo.openInBrowserUrl != null) {
+ errorOpenInBrowserButton.isVisible = true
+ errorOpenInBrowserButton.setOnClickListener {
+ ShareUtils.openUrlInBrowser(context, errorInfo.openInBrowserUrl)
}
- showAndSetOpenInBrowserButtonAction(errorInfo)
}
setRootVisible()
@@ -153,15 +106,6 @@ class ErrorPanelHelper(
errorActionButton.setOnClickListener(listener)
}
- fun showAndSetOpenInBrowserButtonAction(
- errorInfo: ErrorInfo
- ) {
- errorOpenInBrowserButton.isVisible = true
- errorOpenInBrowserButton.setOnClickListener {
- ShareUtils.openUrlInBrowser(context, errorInfo.request)
- }
- }
-
fun showTextError(errorString: String) {
ensureDefaultVisibility()
@@ -192,27 +136,5 @@ class ErrorPanelHelper(
companion object {
val TAG: String = ErrorPanelHelper::class.simpleName!!
val DEBUG: Boolean = MainActivity.DEBUG
-
- @StringRes
- fun getExceptionDescription(throwable: Throwable?): Int {
- return when (throwable) {
- is AgeRestrictedContentException -> R.string.restricted_video_no_stream
- is GeographicRestrictionException -> R.string.georestricted_content
- is PaidContentException -> R.string.paid_content
- is PrivateContentException -> R.string.private_content
- is SoundCloudGoPlusContentException -> R.string.soundcloud_go_plus_content
- is YoutubeMusicPremiumContentException -> R.string.youtube_music_premium_content
- is ContentNotAvailableException -> R.string.content_not_available
- is ContentNotSupportedException -> R.string.content_not_supported
- else -> {
- // show retry button only for content which is not unavailable or unsupported
- if (throwable != null && throwable.isNetworkRelated) {
- R.string.network_error
- } else {
- R.string.error_snackbar_message
- }
- }
- }
- }
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt b/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt
index 93dd8e522f3..b358a5fd273 100644
--- a/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt
+++ b/app/src/main/java/org/schabi/newpipe/error/ErrorUtil.kt
@@ -10,6 +10,7 @@ import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
+import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.preference.PreferenceManager
import com.google.android.material.snackbar.Snackbar
@@ -121,7 +122,7 @@ class ErrorUtil {
)
.setSmallIcon(R.drawable.ic_bug_report)
.setContentTitle(context.getString(R.string.error_report_notification_title))
- .setContentText(context.getString(errorInfo.messageStringId))
+ .setContentText(errorInfo.getMessage(context))
.setAutoCancel(true)
.setContentIntent(
PendingIntentCompat.getActivity(
@@ -136,9 +137,11 @@ class ErrorUtil {
NotificationManagerCompat.from(context)
.notify(ERROR_REPORT_NOTIFICATION_ID, notificationBuilder.build())
- // since the notification is silent, also show a toast, otherwise the user is confused
- Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT)
- .show()
+ ContextCompat.getMainExecutor(context).execute {
+ // since the notification is silent, also show a toast, otherwise the user is confused
+ Toast.makeText(context, R.string.error_report_notification_toast, Toast.LENGTH_SHORT)
+ .show()
+ }
}
private fun getErrorActivityIntent(context: Context, errorInfo: ErrorInfo): Intent {
@@ -153,10 +156,10 @@ class ErrorUtil {
// fallback to showing a notification if no root view is available
createNotification(context, errorInfo)
} else {
- Snackbar.make(rootView, R.string.error_snackbar_message, Snackbar.LENGTH_LONG)
+ Snackbar.make(rootView, errorInfo.getMessage(context), Snackbar.LENGTH_LONG)
.setActionTextColor(Color.YELLOW)
.setAction(context.getString(R.string.error_snackbar_action).uppercase()) {
- openActivity(context, errorInfo)
+ context.startActivity(getErrorActivityIntent(context, errorInfo))
}.show()
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/error/UserAction.java b/app/src/main/java/org/schabi/newpipe/error/UserAction.java
index afb880a292a..d3af9d32e1a 100644
--- a/app/src/main/java/org/schabi/newpipe/error/UserAction.java
+++ b/app/src/main/java/org/schabi/newpipe/error/UserAction.java
@@ -33,7 +33,9 @@ public enum UserAction {
SHARE_TO_NEWPIPE("share to newpipe"),
CHECK_FOR_NEW_APP_VERSION("check for new app version"),
OPEN_INFO_ITEM_DIALOG("open info item dialog"),
- GETTING_MAIN_SCREEN_TAB("getting main screen tab");
+ GETTING_MAIN_SCREEN_TAB("getting main screen tab"),
+ PLAY_ON_POPUP("play on popup"),
+ SUBSCRIPTIONS("loading subscriptions");
private final String message;
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 083d1fe0508..9bffa149c9c 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
@@ -93,6 +93,7 @@
import org.schabi.newpipe.local.history.HistoryRecordManager;
import org.schabi.newpipe.local.playlist.LocalPlaylistFragment;
import org.schabi.newpipe.player.Player;
+import org.schabi.newpipe.player.PlayerIntentType;
import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.PlayerType;
import org.schabi.newpipe.player.event.OnKeyDownListener;
@@ -205,6 +206,8 @@ public final class VideoDetailFragment
int lastStableBottomSheetState = BottomSheetBehavior.STATE_EXPANDED;
@State
protected boolean autoPlayEnabled = true;
+ @State
+ protected int originalOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED;
@Nullable
private StreamInfo currentInfo = null;
@@ -876,7 +879,7 @@ private void runWorker(final boolean forceLoad, final boolean addToBackStack) {
}
}
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_STREAM,
- url == null ? "no url" : url, serviceId)));
+ url == null ? "no url" : url, serviceId, url)));
}
/*//////////////////////////////////////////////////////////////////////////
@@ -1166,8 +1169,12 @@ private void openMainPlayer() {
final PlayQueue queue = setupPlayQueueForIntent(false);
tryAddVideoPlayerView();
- final Intent playerIntent = NavigationHelper.getPlayerIntent(requireContext(),
- PlayerService.class, queue, true, autoPlayEnabled);
+ final Context context = requireContext();
+ final Intent playerIntent =
+ NavigationHelper.getPlayerIntent(context, PlayerService.class, queue,
+ PlayerIntentType.AllOthers)
+ .putExtra(Player.PLAY_WHEN_READY, autoPlayEnabled)
+ .putExtra(Player.RESUME_PLAYBACK, true);
ContextCompat.startForegroundService(activity, playerIntent);
}
@@ -1225,7 +1232,13 @@ private void startOnExternalPlayer(@NonNull final Context context,
disposables.add(recordManager.onViewed(info).onErrorComplete()
.subscribe(
ignored -> { /* successful */ },
- error -> Log.e(TAG, "Register view failure: ", error)
+ error -> showSnackBarError(
+ new ErrorInfo(
+ error,
+ UserAction.PLAY_STREAM,
+ "Got an error when modifying history on viewed"
+ )
+ )
));
}
@@ -1411,10 +1424,8 @@ public void onReceive(final Context context, final Intent intent) {
bottomSheetBehavior.setState(BottomSheetBehavior.STATE_COLLAPSED);
}
// Rebound to the service if it was closed via notification or mini player
- if (!playerHolder.isBound()) {
- playerHolder.startService(
- false, VideoDetailFragment.this);
- }
+ playerHolder.setListener(VideoDetailFragment.this);
+ playerHolder.tryBindIfNeeded(context);
break;
}
}
@@ -1423,7 +1434,8 @@ public void onReceive(final Context context, final Intent intent) {
intentFilter.addAction(ACTION_SHOW_MAIN_PLAYER);
intentFilter.addAction(ACTION_HIDE_MAIN_PLAYER);
intentFilter.addAction(ACTION_PLAYER_STARTED);
- activity.registerReceiver(broadcastReceiver, intentFilter);
+ ContextCompat.registerReceiver(activity, broadcastReceiver, intentFilter,
+ ContextCompat.RECEIVER_EXPORTED);
}
@@ -1592,8 +1604,8 @@ public void handleResult(@NonNull final StreamInfo info) {
}
if (!info.getErrors().isEmpty()) {
- showSnackBarError(new ErrorInfo(info.getErrors(),
- UserAction.REQUESTED_STREAM, info.getUrl(), info));
+ showSnackBarError(new ErrorInfo(info.getErrors(), UserAction.REQUESTED_STREAM,
+ "Some info not extracted: " + info.getUrl(), info));
}
}
@@ -1896,22 +1908,29 @@ public void onFullscreenStateChanged(final boolean fullscreen) {
@Override
public void onScreenRotationButtonClicked() {
- // In tablet user experience will be better if screen will not be rotated
- // from landscape to portrait every time.
- // Just turn on fullscreen mode in landscape orientation
- // or portrait & unlocked global orientation
- final boolean isLandscape = DeviceUtils.isLandscape(requireContext());
- if (DeviceUtils.isTablet(activity)
- && (!globalScreenOrientationLocked(activity) || isLandscape)) {
- player.UIs().get(MainPlayerUi.class).ifPresent(MainPlayerUi::toggleFullscreen);
+ final Optional playerUi = player != null
+ ? player.UIs().get(MainPlayerUi.class)
+ : Optional.empty();
+ if (playerUi.isEmpty()) {
return;
}
- final int newOrientation = isLandscape
- ? ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
- : ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE;
+ // On tablets and TVs, just toggle fullscreen UI without orientation change.
+ if (DeviceUtils.isTablet(activity) || DeviceUtils.isTv(activity)) {
+ playerUi.get().toggleFullscreen();
+ return;
+ }
- activity.setRequestedOrientation(newOrientation);
+ if (playerUi.get().isFullscreen()) {
+ // EXITING FULLSCREEN
+ playerUi.get().toggleFullscreen();
+ activity.setRequestedOrientation(originalOrientation);
+ } else {
+ // ENTERING FULLSCREEN
+ originalOrientation = activity.getRequestedOrientation();
+ playerUi.get().toggleFullscreen();
+ activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE);
+ }
}
/*
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 7f594734a75..848dfe6f55e 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
@@ -153,7 +153,7 @@ public void startLoading(final boolean forceLoad) {
handleResult(result);
}, throwable ->
showError(new ErrorInfo(throwable, errorUserAction,
- "Start loading: " + url, serviceId)));
+ "Start loading: " + url, serviceId, url)));
}
/**
@@ -184,7 +184,7 @@ protected void loadMoreItems() {
handleNextItems(infoItemsPage);
}, (@NonNull Throwable throwable) ->
dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(throwable,
- errorUserAction, "Loading more items: " + url, serviceId)));
+ errorUserAction, "Loading more items: " + url, serviceId, url)));
}
private void forbidDownwardFocusScroll() {
@@ -210,7 +210,7 @@ public void handleNextItems(final ListExtractor.InfoItemsPage result) {
if (!result.getErrors().isEmpty()) {
dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(result.getErrors(), errorUserAction,
- "Get next items of: " + url, serviceId));
+ "Get next items of: " + url, serviceId, url));
}
}
@@ -250,7 +250,7 @@ public void handleResult(@NonNull final L result) {
if (!errors.isEmpty()) {
dynamicallyShowErrorPanelOrSnackbar(new ErrorInfo(result.getErrors(),
- errorUserAction, "Start loading: " + url, serviceId));
+ errorUserAction, "Start loading: " + url, serviceId, url));
}
}
}
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 764f03e4be4..d75d14b4ada 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
@@ -361,10 +361,10 @@ private Consumer> getSubscribeUpdateMonitor(final Chann
final SubscriptionEntity channel = new SubscriptionEntity();
channel.setServiceId(info.getServiceId());
channel.setUrl(info.getUrl());
- channel.setData(info.getName(),
- ImageStrategy.imageListToDbUrl(info.getAvatars()),
- info.getDescription(),
- info.getSubscriberCount());
+ channel.setName(info.getName());
+ channel.setAvatarUrl(ImageStrategy.imageListToDbUrl(info.getAvatars()));
+ channel.setDescription(info.getDescription());
+ channel.setSubscriberCount(info.getSubscriberCount());
channelSubscription = null;
updateNotifyButton(null);
subscribeButtonMonitor = monitorSubscribeButton(mapOnSubscribe(channel));
@@ -577,7 +577,7 @@ private void runWorker(final boolean forceLoad) {
isLoading.set(false);
handleResult(result);
}, throwable -> showError(new ErrorInfo(throwable, UserAction.REQUESTED_CHANNEL,
- url == null ? "No URL" : url, serviceId)));
+ url == null ? "No URL" : url, serviceId, url)));
}
@Override
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 cea06b94288..8cb5f649734 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
@@ -54,6 +54,7 @@
import org.schabi.newpipe.extractor.NewPipe;
import org.schabi.newpipe.extractor.Page;
import org.schabi.newpipe.extractor.StreamingService;
+import org.schabi.newpipe.extractor.exceptions.ParsingException;
import org.schabi.newpipe.extractor.search.SearchExtractor;
import org.schabi.newpipe.extractor.search.SearchInfo;
import org.schabi.newpipe.extractor.services.peertube.linkHandler.PeertubeSearchQueryHandlerFactory;
@@ -934,7 +935,21 @@ private void onItemError(final Throwable exception) {
infoListAdapter.clearStreamItemList();
showEmptyState();
} else {
- showError(new ErrorInfo(exception, UserAction.SEARCHED, searchString, serviceId));
+ showError(new ErrorInfo(exception, UserAction.SEARCHED, searchString, serviceId,
+ getOpenInBrowserUrlForErrors()));
+ }
+ }
+
+ @Nullable
+ private String getOpenInBrowserUrlForErrors() {
+ if (TextUtils.isEmpty(searchString)) {
+ return null;
+ }
+ try {
+ return service.getSearchQHFactory().getUrl(searchString,
+ Arrays.asList(contentFilter), sortFilter);
+ } catch (final NullPointerException | ParsingException ignored) {
+ return null;
}
}
@@ -1022,7 +1037,7 @@ public void handleResult(@NonNull final SearchInfo result) {
&& !(exceptions.size() == 1
&& exceptions.get(0) instanceof SearchExtractor.NothingFoundException)) {
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
- searchString, serviceId));
+ searchString, serviceId, getOpenInBrowserUrlForErrors()));
}
searchSuggestion = result.getSearchSuggestion();
@@ -1095,13 +1110,14 @@ public void handleNextItems(final ListExtractor.InfoItemsPage> result) {
// whose results are handled here, but let's check it anyway
if (nextPage == null) {
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
- "\"" + searchString + "\" → nextPage == null", serviceId));
+ "\"" + searchString + "\" → nextPage == null", serviceId,
+ getOpenInBrowserUrlForErrors()));
} else {
showSnackBarError(new ErrorInfo(result.getErrors(), UserAction.SEARCHED,
"\"" + searchString + "\" → pageUrl: " + nextPage.getUrl() + ", "
+ "pageIds: " + nextPage.getIds() + ", "
+ "pageCookies: " + nextPage.getCookies(),
- serviceId));
+ serviceId, getOpenInBrowserUrlForErrors()));
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java
index 856ba22f19c..6a330be0fe4 100644
--- a/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java
+++ b/app/src/main/java/org/schabi/newpipe/fragments/list/search/SuggestionListAdapter.java
@@ -76,7 +76,8 @@ private void updateFrom(final SuggestionItem item) {
}
}
- private static class SuggestionItemCallback extends DiffUtil.ItemCallback {
+ private static final class SuggestionItemCallback
+ extends DiffUtil.ItemCallback {
@Override
public boolean areItemsTheSame(@NonNull final SuggestionItem oldItem,
@NonNull final SuggestionItem newItem) {
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 c7ac9556f85..a2bf4a1ff42 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
@@ -13,6 +13,9 @@
import org.schabi.newpipe.R;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.download.DownloadDialog;
+import org.schabi.newpipe.error.ErrorInfo;
+import org.schabi.newpipe.error.ErrorUtil;
+import org.schabi.newpipe.error.UserAction;
import org.schabi.newpipe.local.dialog.PlaylistAppendDialog;
import org.schabi.newpipe.local.dialog.PlaylistDialog;
import org.schabi.newpipe.local.history.HistoryRecordManager;
@@ -132,6 +135,16 @@ public enum StreamDialogDefaultEntry {
MARK_AS_WATCHED(R.string.mark_as_watched, (fragment, item) ->
new HistoryRecordManager(fragment.getContext())
.markAsWatched(item)
+ .doOnError(error -> {
+ ErrorUtil.showSnackbar(
+ fragment.requireContext(),
+ new ErrorInfo(
+ error,
+ UserAction.OPEN_INFO_ITEM_DIALOG,
+ "Got an error when trying to mark as watched"
+ )
+ );
+ })
.onErrorComplete()
.observeOn(AndroidSchedulers.mainThread())
.subscribe()
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 a5e1594d1b5..1f3772dd5f5 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
@@ -1,6 +1,7 @@
package org.schabi.newpipe.local.bookmark;
import static org.schabi.newpipe.local.bookmark.MergedPlaylistManager.getMergedOrderedPlaylists;
+import static org.schabi.newpipe.util.ThemeHelper.shouldUseGridLayout;
import android.content.DialogInterface;
import android.os.Bundle;
@@ -140,7 +141,7 @@ public void selected(final LocalItem selectedItem) {
if (selectedItem instanceof PlaylistMetadataEntry) {
final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem);
NavigationHelper.openLocalPlaylistFragment(fragmentManager, entry.getUid(),
- entry.name);
+ entry.getOrderingName());
} else if (selectedItem instanceof PlaylistRemoteEntity) {
final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem);
@@ -148,7 +149,7 @@ public void selected(final LocalItem selectedItem) {
fragmentManager,
entry.getServiceId(),
entry.getUrl(),
- entry.getName());
+ entry.getOrderingName());
}
}
@@ -378,11 +379,11 @@ public void saveImmediate() {
if (item instanceof PlaylistMetadataEntry
&& ((PlaylistMetadataEntry) item).getDisplayIndex() != i) {
- ((PlaylistMetadataEntry) item).setDisplayIndex(i);
+ ((PlaylistMetadataEntry) item).setDisplayIndex((long) i);
localItemsUpdate.add((PlaylistMetadataEntry) item);
} else if (item instanceof PlaylistRemoteEntity
&& ((PlaylistRemoteEntity) item).getDisplayIndex() != i) {
- ((PlaylistRemoteEntity) item).setDisplayIndex(i);
+ ((PlaylistRemoteEntity) item).setDisplayIndex((long) i);
remoteItemsUpdate.add((PlaylistRemoteEntity) item);
}
}
@@ -417,10 +418,11 @@ public void saveImmediate() {
}
private ItemTouchHelper.SimpleCallback getItemTouchCallback() {
- // if adding grid layout, also include ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT
- // with an `if (shouldUseGridLayout()) ...`
- return new ItemTouchHelper.SimpleCallback(ItemTouchHelper.UP | ItemTouchHelper.DOWN,
- ItemTouchHelper.ACTION_STATE_IDLE) {
+ int directions = ItemTouchHelper.UP | ItemTouchHelper.DOWN;
+ if (shouldUseGridLayout(requireContext())) {
+ directions |= ItemTouchHelper.LEFT | ItemTouchHelper.RIGHT;
+ }
+ return new ItemTouchHelper.SimpleCallback(directions, ItemTouchHelper.ACTION_STATE_IDLE) {
@Override
public int interpolateOutOfBoundsScroll(@NonNull final RecyclerView recyclerView,
final int viewSize,
@@ -487,7 +489,7 @@ public void onSwiped(@NonNull final RecyclerView.ViewHolder viewHolder,
///////////////////////////////////////////////////////////////////////////
private void showRemoteDeleteDialog(final PlaylistRemoteEntity item) {
- showDeleteDialog(item.getName(), item);
+ showDeleteDialog(item.getOrderingName(), item);
}
private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
@@ -508,7 +510,7 @@ private void showLocalDialog(final PlaylistMetadataEntry selectedItem) {
if (items.get(index).equals(rename)) {
showRenameDialog(selectedItem);
} else if (items.get(index).equals(delete)) {
- showDeleteDialog(selectedItem.name, selectedItem);
+ showDeleteDialog(selectedItem.getOrderingName(), selectedItem);
} else if (isThumbnailPermanent && items.get(index).equals(unsetThumbnail)) {
final long thumbnailStreamId = localPlaylistManager
.getAutomaticPlaylistThumbnailStreamId(selectedItem.getUid());
@@ -529,7 +531,7 @@ private void showRenameDialog(final PlaylistMetadataEntry selectedItem) {
DialogEditTextBinding.inflate(getLayoutInflater());
dialogBinding.dialogEditText.setHint(R.string.name);
dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT);
- dialogBinding.dialogEditText.setText(selectedItem.name);
+ dialogBinding.dialogEditText.setText(selectedItem.getOrderingName());
new AlertDialog.Builder(activity)
.setView(dialogBinding.getRoot())
diff --git a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java
index 478ef8039fe..48d17d1d8c2 100644
--- a/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/local/dialog/PlaylistAppendDialog.java
@@ -1,5 +1,7 @@
package org.schabi.newpipe.local.dialog;
+import static org.schabi.newpipe.database.playlist.model.PlaylistEntity.DEFAULT_THUMBNAIL_ID;
+
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
@@ -14,7 +16,6 @@
import org.schabi.newpipe.NewPipeDatabase;
import org.schabi.newpipe.R;
-import org.schabi.newpipe.database.playlist.model.PlaylistEntity;
import org.schabi.newpipe.database.playlist.PlaylistDuplicatesEntry;
import org.schabi.newpipe.database.stream.model.StreamEntity;
import org.schabi.newpipe.local.LocalItemListAdapter;
@@ -138,7 +139,7 @@ private void onPlaylistsReceived(@NonNull final List pl
private boolean anyPlaylistContainsDuplicates(final List playlists) {
return playlists.stream()
- .anyMatch(playlist -> playlist.timesStreamIsContained > 0);
+ .anyMatch(playlist -> playlist.getTimesStreamIsContained() > 0);
}
private void onPlaylistSelected(@NonNull final LocalPlaylistManager manager,
@@ -146,9 +147,9 @@ private void onPlaylistSelected(@NonNull final LocalPlaylistManager manager,
@NonNull final List streams) {
final String toastText;
- if (playlist.timesStreamIsContained > 0) {
+ if (playlist.getTimesStreamIsContained() > 0) {
toastText = getString(R.string.playlist_add_stream_success_duplicate,
- playlist.timesStreamIsContained);
+ playlist.getTimesStreamIsContained());
} else {
toastText = getString(R.string.playlist_add_stream_success);
}
@@ -160,8 +161,9 @@ private void onPlaylistSelected(@NonNull final LocalPlaylistManager manager,
.subscribe(ignored -> {
successToast.show();
- if (playlist.thumbnailUrl != null
- && playlist.thumbnailUrl.equals(PlaylistEntity.DEFAULT_THUMBNAIL)) {
+ if (playlist.getThumbnailStreamId() != null
+ && playlist.getThumbnailStreamId() == DEFAULT_THUMBNAIL_ID
+ ) {
playlistDisposables.add(manager
.changePlaylistThumbnail(playlist.getUid(), streams.get(0).getUid(),
false)
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt
index ed65d4048e8..aacc6757ec1 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedDatabaseManager.kt
@@ -177,7 +177,7 @@ class FeedDatabaseManager(context: Context) {
.observeOn(AndroidSchedulers.mainThread())
}
- fun oldestSubscriptionUpdate(groupId: Long): Flowable> {
+ fun oldestSubscriptionUpdate(groupId: Long): Flowable> {
return when (groupId) {
FeedGroupEntity.GROUP_ALL_ID -> feedTable.oldestSubscriptionUpdateFromAll()
else -> feedTable.oldestSubscriptionUpdate(groupId)
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt
index 91f98f5d298..bbad7f689d1 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/FeedFragment.kt
@@ -507,7 +507,7 @@ class FeedFragment : BaseStateFragment() {
.setTitle(R.string.feed_load_error)
.setPositiveButton(R.string.unsubscribe) { _, _ ->
SubscriptionManager(requireContext())
- .deleteSubscription(subscriptionEntity.serviceId, subscriptionEntity.url)
+ .deleteSubscription(subscriptionEntity.serviceId, subscriptionEntity.url!!)
.subscribe()
handleItemsErrors(nextItemsErrors)
}
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 728570b17e0..f916db2b5b4 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
@@ -65,7 +65,7 @@ class FeedViewModel(
feedDatabaseManager.oldestSubscriptionUpdate(groupId),
Function6 { t1: FeedEventManager.Event, t2: Boolean, t3: Boolean, t4: Boolean,
- t5: Long, t6: List ->
+ t5: Long, t6: List ->
return@Function6 CombineResultEventHolder(t1, t2, t3, t4, t5, t6.firstOrNull())
}
)
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt
index a40bf35dc52..6fe311fb053 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/notifications/NotificationWorker.kt
@@ -1,6 +1,8 @@
package org.schabi.newpipe.local.feed.notifications
import android.content.Context
+import android.content.pm.ServiceInfo
+import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.work.Constraints
@@ -83,7 +85,9 @@ class NotificationWorker(
.setPriority(NotificationCompat.PRIORITY_LOW)
.setContentTitle(applicationContext.getString(R.string.feed_notification_loading))
.build()
- setForegroundAsync(ForegroundInfo(FeedLoadService.NOTIFICATION_ID, notification))
+ // ServiceInfo constants are not used below Android Q, so 0 is set here
+ val serviceType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC else 0
+ setForegroundAsync(ForegroundInfo(FeedLoadService.NOTIFICATION_ID, notification, serviceType))
}
companion object {
diff --git a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt
index f960040de6b..4aa825ca85d 100644
--- a/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/feed/service/FeedLoadService.kt
@@ -31,6 +31,7 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.app.PendingIntentCompat
import androidx.core.app.ServiceCompat
+import androidx.core.content.ContextCompat
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.core.Flowable
import io.reactivex.rxjava3.disposables.Disposable
@@ -200,7 +201,7 @@ class FeedLoadService : Service() {
}
}
}
- registerReceiver(broadcastReceiver, IntentFilter(ACTION_CANCEL))
+ ContextCompat.registerReceiver(this, broadcastReceiver, IntentFilter(ACTION_CANCEL), ContextCompat.RECEIVER_NOT_EXPORTED)
}
// /////////////////////////////////////////////////////////////////////////
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java
index 336f5cfe30b..528275d75d6 100644
--- a/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/LocalPlaylistItemHolder.java
@@ -35,15 +35,15 @@ public void updateFromItem(final LocalItem localItem,
}
final PlaylistMetadataEntry item = (PlaylistMetadataEntry) localItem;
- itemTitleView.setText(item.name);
+ itemTitleView.setText(item.getOrderingName());
itemStreamCountView.setText(Localization.localizeStreamCountMini(
- itemStreamCountView.getContext(), item.streamCount));
+ itemStreamCountView.getContext(), item.getStreamCount()));
itemUploaderView.setVisibility(View.INVISIBLE);
- PicassoHelper.loadPlaylistThumbnail(item.thumbnailUrl).into(itemThumbnailView);
+ PicassoHelper.loadPlaylistThumbnail(item.getThumbnailUrl()).into(itemThumbnailView);
if (item instanceof PlaylistDuplicatesEntry
- && ((PlaylistDuplicatesEntry) item).timesStreamIsContained > 0) {
+ && ((PlaylistDuplicatesEntry) item).getTimesStreamIsContained() > 0) {
itemView.setAlpha(GRAYED_OUT_ALPHA);
} else {
itemView.setAlpha(1.0f);
diff --git a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java
index 7657320634c..3a339aec878 100644
--- a/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java
+++ b/app/src/main/java/org/schabi/newpipe/local/holder/RemotePlaylistItemHolder.java
@@ -34,7 +34,7 @@ public void updateFromItem(final LocalItem localItem,
}
final PlaylistRemoteEntity item = (PlaylistRemoteEntity) localItem;
- itemTitleView.setText(item.getName());
+ itemTitleView.setText(item.getOrderingName());
itemStreamCountView.setText(Localization.localizeStreamCountMini(
itemStreamCountView.getContext(), item.getStreamCount()));
// Here is where the uploader name is set in the bookmarked playlists library
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 f5562549cf5..1efc0a84ce4 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
@@ -111,7 +111,7 @@ public class LocalPlaylistFragment extends BaseLocalListFragment() {
@Override
public void selected(final LocalItem selectedItem) {
- if (selectedItem instanceof PlaylistStreamEntry) {
- final StreamEntity item =
- ((PlaylistStreamEntry) selectedItem).getStreamEntity();
+ if (selectedItem instanceof PlaylistStreamEntry entry) {
+ final StreamEntity item = entry.getStreamEntity();
NavigationHelper.openVideoDetailFragment(requireContext(), getFM(),
item.getServiceId(), item.getUrl(), item.getTitle(), null, false);
}
@@ -496,6 +495,7 @@ public void removeWatchedStreams(final boolean removePartiallyWatched) {
itemListAdapter.clearStreamItemList();
itemListAdapter.addItems(itemsToKeep);
debounceSaver.setHasChangesToSave();
+ saveImmediate();
if (thumbnailVideoRemoved) {
updateThumbnailUrl();
@@ -560,8 +560,7 @@ private void createRenameDialog() {
return;
}
- final DialogEditTextBinding dialogBinding =
- DialogEditTextBinding.inflate(getLayoutInflater());
+ final var dialogBinding = DialogEditTextBinding.inflate(getLayoutInflater());
dialogBinding.dialogEditText.setHint(R.string.name);
dialogBinding.dialogEditText.setInputType(InputType.TYPE_CLASS_TEXT);
dialogBinding.dialogEditText.setSelection(dialogBinding.dialogEditText.getText().length());
@@ -667,6 +666,7 @@ private void removeDuplicatesInPlaylist() {
itemListAdapter.addItems(itemsToKeep);
setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
debounceSaver.setHasChangesToSave();
+ saveImmediate();
hideLoading();
isRewritingPlaylist = false;
@@ -686,6 +686,7 @@ private void deleteItem(final PlaylistStreamEntry item) {
setStreamCountAndOverallDuration(itemListAdapter.getItemsList());
debounceSaver.setHasChangesToSave();
+ saveImmediate();
}
/**
@@ -708,8 +709,8 @@ public void saveImmediate() {
final List items = itemListAdapter.getItemsList();
final List streamIds = new ArrayList<>(items.size());
for (final LocalItem item : items) {
- if (item instanceof PlaylistStreamEntry) {
- streamIds.add(((PlaylistStreamEntry) item).getStreamId());
+ if (item instanceof PlaylistStreamEntry entry) {
+ streamIds.add(entry.getStreamId());
}
}
@@ -767,6 +768,7 @@ public boolean onMove(@NonNull final RecyclerView recyclerView,
final boolean isSwapped = itemListAdapter.swapItems(sourceIndex, targetIndex);
if (isSwapped) {
debounceSaver.setHasChangesToSave();
+ saveImmediate();
}
return isSwapped;
}
diff --git a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java
index dd9307675de..1480735fb9d 100644
--- a/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java
+++ b/app/src/main/java/org/schabi/newpipe/local/playlist/LocalPlaylistManager.java
@@ -148,7 +148,7 @@ public long getPlaylistThumbnailStreamId(final long playlistId) {
public boolean getIsPlaylistThumbnailPermanent(final long playlistId) {
return playlistTable.getPlaylist(playlistId).blockingFirst().get(0)
- .getIsThumbnailPermanent();
+ .isThumbnailPermanent();
}
public long getAutomaticPlaylistThumbnailStreamId(final long playlistId) {
@@ -174,7 +174,7 @@ private Maybe modifyPlaylist(final long playlistId,
}
if (thumbnailStreamId != THUMBNAIL_ID_LEAVE_UNCHANGED) {
playlist.setThumbnailStreamId(thumbnailStreamId);
- playlist.setIsThumbnailPermanent(isPermanent);
+ playlist.setThumbnailPermanent(isPermanent);
}
return playlistTable.update(playlist);
}).subscribeOn(Schedulers.io());
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java
index 03dd4a1cdda..0067e11543e 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/ImportConfirmationDialog.java
@@ -10,26 +10,23 @@
import androidx.fragment.app.DialogFragment;
import androidx.fragment.app.Fragment;
-import com.evernote.android.state.State;
import com.livefront.bridge.Bridge;
import org.schabi.newpipe.R;
public class ImportConfirmationDialog extends DialogFragment {
- @State
protected Intent resultServiceIntent;
+ private static final String EXTRA_RESULT_SERVICE_INTENT = "extra_result_service_intent";
public static void show(@NonNull final Fragment fragment,
@NonNull final Intent resultServiceIntent) {
final ImportConfirmationDialog confirmationDialog = new ImportConfirmationDialog();
- confirmationDialog.setResultServiceIntent(resultServiceIntent);
+ final Bundle args = new Bundle();
+ args.putParcelable(EXTRA_RESULT_SERVICE_INTENT, resultServiceIntent);
+ confirmationDialog.setArguments(args);
confirmationDialog.show(fragment.getParentFragmentManager(), null);
}
- public void setResultServiceIntent(final Intent resultServiceIntent) {
- this.resultServiceIntent = resultServiceIntent;
- }
-
@NonNull
@Override
public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
@@ -38,9 +35,7 @@ public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
.setCancelable(true)
.setNegativeButton(R.string.cancel, null)
.setPositiveButton(R.string.ok, (dialogInterface, i) -> {
- if (resultServiceIntent != null && getContext() != null) {
- getContext().startService(resultServiceIntent);
- }
+ requireContext().startService(resultServiceIntent);
dismiss();
})
.create();
@@ -50,11 +45,7 @@ public Dialog onCreateDialog(@Nullable final Bundle savedInstanceState) {
public void onCreate(@Nullable final Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- if (resultServiceIntent == null) {
- throw new IllegalStateException("Result intent is null");
- }
-
- Bridge.restoreInstanceState(this, savedInstanceState);
+ resultServiceIntent = requireArguments().getParcelable(EXTRA_RESULT_SERVICE_INTENT);
}
@Override
diff --git a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt
index 474add4f41b..c0783e8123e 100644
--- a/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt
+++ b/app/src/main/java/org/schabi/newpipe/local/subscription/SubscriptionManager.kt
@@ -26,7 +26,7 @@ class SubscriptionManager(context: Context) {
private val feedDatabaseManager = FeedDatabaseManager(context)
fun subscriptionTable(): SubscriptionDAO = subscriptionTable
- fun subscriptions() = subscriptionTable.all
+ fun subscriptions() = subscriptionTable.getAll()
fun getSubscriptions(
currentGroupId: Long = FeedGroupEntity.GROUP_ALL_ID,
@@ -44,7 +44,7 @@ class SubscriptionManager(context: Context) {
}
}
showOnlyUngrouped -> subscriptionTable.getSubscriptionsOnlyUngrouped(currentGroupId)
- else -> subscriptionTable.all
+ else -> subscriptionTable.getAll()
}
}
@@ -71,12 +71,12 @@ class SubscriptionManager(context: Context) {
subscriptionTable.getSubscription(info.serviceId, info.url)
.flatMapCompletable {
Completable.fromRunnable {
- it.setData(
- info.name,
- ImageStrategy.imageListToDbUrl(info.avatars),
- info.description,
- info.subscriberCount
- )
+ it.apply {
+ name = info.name
+ avatarUrl = ImageStrategy.imageListToDbUrl(info.avatars)
+ description = info.description
+ subscriberCount = info.subscriberCount
+ }
subscriptionTable.update(it)
}
}
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 77a70afa9dc..16a8990a61e 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
@@ -89,8 +89,8 @@ public void onCreate(final Bundle savedInstanceState) {
if (supportedSources.isEmpty() && currentServiceId != Constants.NO_SERVICE_ID) {
ErrorUtil.showSnackbar(activity,
new ErrorInfo(new String[]{}, UserAction.SUBSCRIPTION_IMPORT_EXPORT,
- ServiceHelper.getNameOfServiceById(currentServiceId),
"Service does not support importing subscriptions",
+ currentServiceId,
R.string.general_error));
activity.finish();
}
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 98da962e7e8..42f6cbf36a8 100644
--- a/app/src/main/java/org/schabi/newpipe/player/Player.java
+++ b/app/src/main/java/org/schabi/newpipe/player/Player.java
@@ -24,12 +24,12 @@
import static com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_SKIP;
import static com.google.android.exoplayer2.Player.DiscontinuityReason;
import static com.google.android.exoplayer2.Player.Listener;
+import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF;
import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
import static com.google.android.exoplayer2.Player.RepeatMode;
import static org.schabi.newpipe.extractor.ServiceList.YouTube;
import static org.schabi.newpipe.extractor.utils.Utils.isNullOrEmpty;
-import static org.schabi.newpipe.player.helper.PlayerHelper.nextRepeatMode;
import static org.schabi.newpipe.player.helper.PlayerHelper.retrievePlaybackParametersFromPrefs;
import static org.schabi.newpipe.player.helper.PlayerHelper.retrieveSeekDurationFromPreferences;
import static org.schabi.newpipe.player.helper.PlayerHelper.savePlaybackParametersToPrefs;
@@ -60,6 +60,8 @@
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import androidx.core.content.ContextCompat;
+import androidx.core.content.IntentCompat;
import androidx.core.math.MathUtils;
import androidx.preference.PreferenceManager;
@@ -108,6 +110,7 @@
import org.schabi.newpipe.player.playback.PlaybackListener;
import org.schabi.newpipe.player.playqueue.PlayQueue;
import org.schabi.newpipe.player.playqueue.PlayQueueItem;
+import org.schabi.newpipe.player.playqueue.SinglePlayQueue;
import org.schabi.newpipe.player.resolver.AudioPlaybackResolver;
import org.schabi.newpipe.player.resolver.VideoPlaybackResolver;
import org.schabi.newpipe.player.resolver.VideoPlaybackResolver.SourceType;
@@ -117,6 +120,7 @@
import org.schabi.newpipe.player.ui.PopupPlayerUi;
import org.schabi.newpipe.player.ui.VideoPlayerUi;
import org.schabi.newpipe.util.DependentPreferenceHelper;
+import org.schabi.newpipe.util.ExtractorHelper;
import org.schabi.newpipe.util.ListHelper;
import org.schabi.newpipe.util.NavigationHelper;
import org.schabi.newpipe.util.SerializedCache;
@@ -124,14 +128,17 @@
import org.schabi.newpipe.util.image.PicassoHelper;
import java.util.List;
+import java.util.Objects;
import java.util.Optional;
import java.util.stream.IntStream;
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.Observable;
+import io.reactivex.rxjava3.core.Single;
import io.reactivex.rxjava3.disposables.CompositeDisposable;
import io.reactivex.rxjava3.disposables.Disposable;
import io.reactivex.rxjava3.disposables.SerialDisposable;
+import io.reactivex.rxjava3.schedulers.Schedulers;
public final class Player implements PlaybackListener, Listener {
public static final boolean DEBUG = MainActivity.DEBUG;
@@ -153,15 +160,13 @@ public final class Player implements PlaybackListener, Listener {
// Intent
//////////////////////////////////////////////////////////////////////////*/
- public static final String REPEAT_MODE = "repeat_mode";
public static final String PLAYBACK_QUALITY = "playback_quality";
public static final String PLAY_QUEUE_KEY = "play_queue_key";
- public static final String ENQUEUE = "enqueue";
- public static final String ENQUEUE_NEXT = "enqueue_next";
public static final String RESUME_PLAYBACK = "resume_playback";
public static final String PLAY_WHEN_READY = "play_when_ready";
public static final String PLAYER_TYPE = "player_type";
- public static final String IS_MUTED = "is_muted";
+ public static final String PLAYER_INTENT_TYPE = "player_intent_type";
+ public static final String PLAYER_INTENT_DATA = "player_intent_data";
/*//////////////////////////////////////////////////////////////////////////
// Time constants
@@ -246,6 +251,8 @@ public final class Player implements PlaybackListener, Listener {
private final SerialDisposable progressUpdateDisposable = new SerialDisposable();
@NonNull
private final CompositeDisposable databaseUpdateDisposable = new CompositeDisposable();
+ @NonNull
+ private final CompositeDisposable streamItemDisposable = new CompositeDisposable();
// This is the only listener we need for thumbnail loading, since there is always at most only
// one thumbnail being loaded at a time. This field is also here to maintain a strong reference,
@@ -346,49 +353,121 @@ public int getOverrideResolutionIndex(final List sortedVideos,
@SuppressWarnings("MethodLength")
public void handleIntent(@NonNull final Intent intent) {
- // fail fast if no play queue was provided
- final String queueCache = intent.getStringExtra(PLAY_QUEUE_KEY);
- if (queueCache == null) {
+ final var playerIntentType = IntentCompat.getSerializableExtra(intent, PLAYER_INTENT_TYPE,
+ PlayerIntentType.class);
+ if (playerIntentType == null) {
return;
}
- final PlayQueue newQueue = SerializedCache.getInstance().take(queueCache, PlayQueue.class);
- if (newQueue == null) {
- return;
+ // TODO: this should be in the second switch below, but I’m not sure whether I
+ // can move the initUIs stuff without breaking the setup for edge cases somehow.
+ // when playing from a timestamp, keep the current player as-is.
+ if (playerIntentType != PlayerIntentType.TimestampChange) {
+ playerType = IntentCompat.getSerializableExtra(intent, PLAYER_TYPE, PlayerType.class);
}
-
- final PlayerType oldPlayerType = playerType;
- playerType = PlayerType.retrieveFromIntent(intent);
initUIsForCurrentPlayerType();
- // We need to setup audioOnly before super(), see "sourceOf"
isAudioOnly = audioPlayerSelected();
if (intent.hasExtra(PLAYBACK_QUALITY)) {
videoResolver.setPlaybackQuality(intent.getStringExtra(PLAYBACK_QUALITY));
}
- // Resolve enqueue intents
- if (intent.getBooleanExtra(ENQUEUE, false) && playQueue != null) {
- playQueue.append(newQueue.getStreams());
- return;
+ final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true);
- // Resolve enqueue next intents
- } else if (intent.getBooleanExtra(ENQUEUE_NEXT, false) && playQueue != null) {
- final int currentIndex = playQueue.getIndex();
- playQueue.append(newQueue.getStreams());
- playQueue.move(playQueue.size() - 1, currentIndex + 1);
- return;
+ switch (playerIntentType) {
+ case Enqueue -> {
+ if (playQueue != null) {
+ final PlayQueue newQueue = getPlayQueueFromCache(intent);
+ if (newQueue == null) {
+ return;
+ }
+ playQueue.append(newQueue.getStreams());
+ return;
+ }
+
+ // TODO: This falls through to the old logic, there was no playQueue
+ // yet so we should start the player and add the new video
+ break;
+ }
+ case EnqueueNext -> {
+ if (playQueue != null) {
+ final PlayQueue newQueue = getPlayQueueFromCache(intent);
+ if (newQueue == null) {
+ return;
+ }
+ final PlayQueueItem newItem = newQueue.getStreams().get(0);
+ playQueue.enqueueNext(newItem, false);
+ return;
+ }
+
+ // TODO: This falls through to the old logic, there was no playQueue
+ // yet so we should start the player and add the new video
+ break;
+ }
+ case TimestampChange -> {
+ final var data = Objects.requireNonNull(IntentCompat.getParcelableExtra(intent,
+ PLAYER_INTENT_DATA, TimestampChangeData.class));
+ final Single single =
+ ExtractorHelper.getStreamInfo(data.getServiceId(), data.getUrl(), false);
+ streamItemDisposable.add(single.subscribeOn(Schedulers.io())
+ .observeOn(AndroidSchedulers.mainThread())
+ .subscribe(info -> {
+ final @Nullable PlayQueue oldPlayQueue = playQueue;
+ info.setStartPosition(data.getSeconds());
+ final PlayQueueItem playQueueItem = new PlayQueueItem(info);
+
+ // If the stream is already playing,
+ // we can just seek to the appropriate timestamp
+ if (oldPlayQueue != null
+ && playQueueItem.isSameItem(oldPlayQueue.getItem())) {
+ // Player can have state = IDLE when playback is stopped or failed
+ // and we should retry in this case
+ if (simpleExoPlayer.getPlaybackState()
+ == com.google.android.exoplayer2.Player.STATE_IDLE) {
+ simpleExoPlayer.prepare();
+ }
+ simpleExoPlayer.seekTo(oldPlayQueue.getIndex(),
+ data.getSeconds() * 1000L);
+ simpleExoPlayer.setPlayWhenReady(playWhenReady);
+
+ } else {
+ final PlayQueue newPlayQueue;
+
+ // If there is no queue yet, just add our item
+ if (oldPlayQueue == null) {
+ newPlayQueue = new SinglePlayQueue(playQueueItem);
+
+ // else we add the timestamped stream behind the current video
+ // and start playing it.
+ } else {
+ oldPlayQueue.enqueueNext(playQueueItem, true);
+ oldPlayQueue.offsetIndex(1);
+ newPlayQueue = oldPlayQueue;
+ }
+ initPlayback(newPlayQueue, playWhenReady);
+ }
+
+ }, throwable -> {
+ // This will only show a snackbar if the passed context has a root view:
+ // otherwise it will resort to showing a notification, so we are safe
+ // here.
+ final var info = new ErrorInfo(throwable, UserAction.PLAY_ON_POPUP,
+ data.getUrl(), null, data.getUrl());
+ ErrorUtil.createNotification(context, info);
+ }));
+ return;
+ }
+ case AllOthers -> {
+ // fallthrough; TODO: put other intent data in separate cases
+ }
}
- final PlaybackParameters savedParameters = retrievePlaybackParametersFromPrefs(this);
- final float playbackSpeed = savedParameters.speed;
- final float playbackPitch = savedParameters.pitch;
- final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString(
- R.string.playback_skip_silence_key), getPlaybackSkipSilence());
+ final PlayQueue newQueue = getPlayQueueFromCache(intent);
+ if (newQueue == null) {
+ return;
+ }
+ // branching parameters for below
final boolean samePlayQueue = playQueue != null && playQueue.equalStreamsAndIndex(newQueue);
- final int repeatMode = intent.getIntExtra(REPEAT_MODE, getRepeatMode());
- final boolean playWhenReady = intent.getBooleanExtra(PLAY_WHEN_READY, true);
- final boolean isMuted = intent.getBooleanExtra(IS_MUTED, isMuted());
/*
* TODO As seen in #7427 this does not work:
@@ -403,7 +482,7 @@ public void handleIntent(@NonNull final Intent intent) {
if (!exoPlayerIsNull()
&& newQueue.size() == 1 && newQueue.getItem() != null
&& playQueue != null && playQueue.size() == 1 && playQueue.getItem() != null
- && newQueue.getItem().getUrl().equals(playQueue.getItem().getUrl())
+ && newQueue.getItem().isSameItem(playQueue.getItem())
&& newQueue.getItem().getRecoveryPosition() != PlayQueueItem.RECOVERY_UNSET) {
// Player can have state = IDLE when playback is stopped or failed
// and we should retry in this case
@@ -429,7 +508,8 @@ public void handleIntent(@NonNull final Intent intent) {
} else if (intent.getBooleanExtra(RESUME_PLAYBACK, false)
&& DependentPreferenceHelper.getResumePlaybackEnabled(context)
- && !samePlayQueue
+ // !samePlayQueue
+ && (playQueue == null || !playQueue.equalStreamsAndIndex(newQueue))
&& !newQueue.isEmpty()
&& newQueue.getItem() != null
&& newQueue.getItem().getRecoveryPosition() == PlayQueueItem.RECOVERY_UNSET) {
@@ -445,30 +525,30 @@ public void handleIntent(@NonNull final Intent intent) {
newQueue.setRecovery(newQueue.getIndex(),
state.getProgressMillis());
}
- initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
- playbackSkipSilence, playWhenReady, isMuted);
+ initPlayback(newQueue, playWhenReady);
},
error -> {
if (DEBUG) {
Log.w(TAG, "Failed to start playback", error);
}
// In case any error we can start playback without history
- initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
- playbackSkipSilence, playWhenReady, isMuted);
+ initPlayback(newQueue, playWhenReady);
},
() -> {
// Completed but not found in history
- initPlayback(newQueue, repeatMode, playbackSpeed, playbackPitch,
- playbackSkipSilence, playWhenReady, isMuted);
+ initPlayback(newQueue, playWhenReady);
}
));
} else {
// Good to go...
// In a case of equal PlayQueues we can re-init old one but only when it is disposed
- initPlayback(samePlayQueue ? playQueue : newQueue, repeatMode, playbackSpeed,
- playbackPitch, playbackSkipSilence, playWhenReady, isMuted);
+ initPlayback(samePlayQueue ? playQueue : newQueue, playWhenReady);
}
+ }
+
+
+ public void handleIntentPost(final PlayerType oldPlayerType) {
if (oldPlayerType != playerType && playQueue != null) {
// If playerType changes from one to another we should reload the player
// (to disable/enable video stream or to set quality)
@@ -479,6 +559,19 @@ public void handleIntent(@NonNull final Intent intent) {
NavigationHelper.sendPlayerStartedEvent(context);
}
+ @Nullable
+ private static PlayQueue getPlayQueueFromCache(@NonNull final Intent intent) {
+ final String queueCache = intent.getStringExtra(PLAY_QUEUE_KEY);
+ if (queueCache == null) {
+ return null;
+ }
+ final PlayQueue newQueue = SerializedCache.getInstance().take(queueCache, PlayQueue.class);
+ if (newQueue == null) {
+ return null;
+ }
+ return newQueue;
+ }
+
private void initUIsForCurrentPlayerType() {
if ((UIs.get(MainPlayerUi.class).isPresent() && playerType == PlayerType.MAIN)
|| (UIs.get(PopupPlayerUi.class).isPresent() && playerType == PlayerType.POPUP)) {
@@ -512,16 +605,13 @@ private void initUIsForCurrentPlayerType() {
}
private void initPlayback(@NonNull final PlayQueue queue,
- @RepeatMode final int repeatMode,
- final float playbackSpeed,
- final float playbackPitch,
- final boolean playbackSkipSilence,
- final boolean playOnReady,
- final boolean isMuted) {
+ final boolean playOnReady) {
destroyPlayer();
initPlayer(playOnReady);
- setRepeatMode(repeatMode);
- setPlaybackParameters(playbackSpeed, playbackPitch, playbackSkipSilence);
+ final boolean playbackSkipSilence = getPrefs().getBoolean(getContext().getString(
+ R.string.playback_skip_silence_key), getPlaybackSkipSilence());
+ final PlaybackParameters savedParameters = retrievePlaybackParametersFromPrefs(this);
+ setPlaybackParameters(savedParameters.speed, savedParameters.pitch, playbackSkipSilence);
playQueue = queue;
playQueue.init();
@@ -529,7 +619,7 @@ private void initPlayback(@NonNull final PlayQueue queue,
UIs.call(PlayerUi::initPlayback);
- simpleExoPlayer.setVolume(isMuted ? 0 : 1);
+ simpleExoPlayer.setVolume(isMuted() ? 0 : 1);
notifyQueueUpdateToListeners();
}
@@ -611,6 +701,7 @@ public void destroy() {
databaseUpdateDisposable.clear();
progressUpdateDisposable.set(null);
+ streamItemDisposable.clear();
cancelLoadingCurrentThumbnail();
UIs.destroyAll(Object.class); // destroy every UI: obviously every UI extends Object
@@ -764,7 +855,8 @@ private void onBroadcastReceived(final Intent intent) {
private void registerBroadcastReceiver() {
// Try to unregister current first
unregisterBroadcastReceiver();
- context.registerReceiver(broadcastReceiver, intentFilter);
+ ContextCompat.registerReceiver(context, broadcastReceiver, intentFilter,
+ ContextCompat.RECEIVER_EXPORTED);
}
private void unregisterBroadcastReceiver() {
@@ -1176,16 +1268,25 @@ public int getRepeatMode() {
return exoPlayerIsNull() ? REPEAT_MODE_OFF : simpleExoPlayer.getRepeatMode();
}
- public void setRepeatMode(@RepeatMode final int repeatMode) {
+ public void cycleNextRepeatMode() {
if (!exoPlayerIsNull()) {
+ @RepeatMode final int repeatMode;
+ switch (simpleExoPlayer.getRepeatMode()) {
+ case REPEAT_MODE_OFF:
+ repeatMode = REPEAT_MODE_ONE;
+ break;
+ case REPEAT_MODE_ONE:
+ repeatMode = REPEAT_MODE_ALL;
+ break;
+ case REPEAT_MODE_ALL:
+ default:
+ repeatMode = REPEAT_MODE_OFF;
+ break;
+ }
simpleExoPlayer.setRepeatMode(repeatMode);
}
}
- public void cycleNextRepeatMode() {
- setRepeatMode(nextRepeatMode(getRepeatMode()));
- }
-
@Override
public void onRepeatModeChanged(@RepeatMode final int repeatMode) {
if (DEBUG) {
@@ -1288,7 +1389,8 @@ public void onEvents(@NonNull final com.google.android.exoplayer2.Player player,
UserAction.PLAY_STREAM,
"Loading failed for [" + currentMetadata.getTitle()
+ "]: " + currentMetadata.getStreamUrl(),
- currentMetadata.getServiceId());
+ currentMetadata.getServiceId(),
+ currentMetadata.getStreamUrl());
ErrorUtil.createNotification(context, errorInfo);
}
@@ -1504,7 +1606,7 @@ private void createErrorNotification(@NonNull final PlaybackException error) {
errorInfo = new ErrorInfo(error, UserAction.PLAY_STREAM,
"Player error[type=" + error.getErrorCodeName()
+ "] occurred while playing " + currentMetadata.getStreamUrl(),
- currentMetadata.getServiceId());
+ currentMetadata.getServiceId(), currentMetadata.getStreamUrl());
}
ErrorUtil.createNotification(context, errorInfo);
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerIntentType.kt b/app/src/main/java/org/schabi/newpipe/player/PlayerIntentType.kt
new file mode 100644
index 00000000000..ed0c19c9964
--- /dev/null
+++ b/app/src/main/java/org/schabi/newpipe/player/PlayerIntentType.kt
@@ -0,0 +1,24 @@
+package org.schabi.newpipe.player
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+// We model this as an enum class plus one struct for each enum value
+// so we can consume it from Java properly. After converting to Kotlin,
+// we could switch to a sealed enum class & a proper Kotlin `when` match.
+enum class PlayerIntentType {
+ Enqueue,
+ EnqueueNext,
+ TimestampChange,
+ AllOthers
+}
+
+/**
+ * A timestamp on the given was clicked and we should switch the playing stream to it.
+ */
+@Parcelize
+data class TimestampChangeData(
+ val serviceId: Int,
+ val url: String,
+ val seconds: Int
+) : Parcelable
diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java
index adc050e4bcb..dba30f9e863 100644
--- a/app/src/main/java/org/schabi/newpipe/player/PlayerService.java
+++ b/app/src/main/java/org/schabi/newpipe/player/PlayerService.java
@@ -40,6 +40,7 @@
import org.schabi.newpipe.player.mediabrowser.MediaBrowserPlaybackPreparer;
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
import org.schabi.newpipe.player.notification.NotificationPlayerUi;
+import org.schabi.newpipe.player.notification.NotificationUtil;
import org.schabi.newpipe.util.ThemeHelper;
import java.lang.ref.WeakReference;
@@ -156,23 +157,24 @@ public int onStartCommand(final Intent intent, final int flags, final int startI
}
}
- if (Intent.ACTION_MEDIA_BUTTON.equals(intent.getAction())
- && (player == null || player.getPlayQueue() == null)) {
- /*
- No need to process media button's actions if the player is not working, otherwise
- the player service would strangely start with nothing to play
- Stop the service in this case, which will be removed from the foreground and its
- notification cancelled in its destruction
- */
+ if (player == null) {
+ // No need to process media button's actions or other system intents if the player is
+ // not running. However, since the current intent might have been issued by the system
+ // with `startForegroundService()` (for unknown reasons), we need to ensure that we post
+ // a (dummy) foreground notification, otherwise we'd incur in
+ // "Context.startForegroundService() did not then call Service.startForeground()". Then
+ // we stop the service again.
+ Log.d(TAG, "onStartCommand() got a useless intent, closing the service");
+ NotificationUtil.startForegroundWithDummyNotification(this);
destroyPlayerAndStopService();
return START_NOT_STICKY;
}
- if (player != null) {
- player.handleIntent(intent);
- player.UIs().get(MediaSessionPlayerUi.class)
- .ifPresent(ui -> ui.handleMediaButtonIntent(intent));
- }
+ final PlayerType oldPlayerType = player.getPlayerType();
+ player.handleIntent(intent);
+ player.handleIntentPost(oldPlayerType);
+ player.UIs().get(MediaSessionPlayerUi.class)
+ .ifPresent(ui -> ui.handleMediaButtonIntent(intent));
return START_NOT_STICKY;
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/PlayerType.java b/app/src/main/java/org/schabi/newpipe/player/PlayerType.java
index 171a703953c..f74389d79c8 100644
--- a/app/src/main/java/org/schabi/newpipe/player/PlayerType.java
+++ b/app/src/main/java/org/schabi/newpipe/player/PlayerType.java
@@ -1,32 +1,7 @@
package org.schabi.newpipe.player;
-import static org.schabi.newpipe.player.Player.PLAYER_TYPE;
-
-import android.content.Intent;
-
public enum PlayerType {
MAIN,
AUDIO,
POPUP;
-
- /**
- * @return an integer representing this {@link PlayerType}, to be used to save it in intents
- * @see #retrieveFromIntent(Intent) Use retrieveFromIntent() to retrieve and convert player type
- * integers from an intent
- */
- public int valueForIntent() {
- return ordinal();
- }
-
- /**
- * @param intent the intent to retrieve a player type from
- * @return the player type integer retrieved from the intent, converted back into a {@link
- * PlayerType}, or {@link PlayerType#MAIN} if there is no player type extra in the
- * intent
- * @throws ArrayIndexOutOfBoundsException if the intent contains an invalid player type integer
- * @see #valueForIntent() Use valueForIntent() to obtain valid player type integers
- */
- public static PlayerType retrieveFromIntent(final Intent intent) {
- return values()[intent.getIntExtra(PLAYER_TYPE, MAIN.valueForIntent())];
- }
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java
index a05990816de..084336d5483 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/AudioReactor.java
@@ -154,9 +154,6 @@ public void onAudioSessionIdChanged(@NonNull final EventTime eventTime,
notifyAudioSessionUpdate(true, audioSessionId);
}
private void notifyAudioSessionUpdate(final boolean active, final int audioSessionId) {
- if (!PlayerHelper.isUsingDSP()) {
- return;
- }
final Intent intent = new Intent(active
? AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION
: AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION);
diff --git a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java
index 266d65f365d..0f9579352a5 100644
--- a/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java
+++ b/app/src/main/java/org/schabi/newpipe/player/helper/PlayerHelper.java
@@ -1,8 +1,5 @@
package org.schabi.newpipe.player.helper;
-import static com.google.android.exoplayer2.Player.REPEAT_MODE_ALL;
-import static com.google.android.exoplayer2.Player.REPEAT_MODE_OFF;
-import static com.google.android.exoplayer2.Player.REPEAT_MODE_ONE;
import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_ALWAYS;
import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_NEVER;
import static org.schabi.newpipe.player.helper.PlayerHelper.AutoplayType.AUTOPLAY_TYPE_WIFI;
@@ -25,7 +22,6 @@
import androidx.preference.PreferenceManager;
import com.google.android.exoplayer2.PlaybackParameters;
-import com.google.android.exoplayer2.Player.RepeatMode;
import com.google.android.exoplayer2.SeekParameters;
import com.google.android.exoplayer2.source.ProgressiveMediaSource;
import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection;
@@ -145,11 +141,11 @@ public static String resizeTypeOf(@NonNull final Context context,
@ResizeMode final int resizeMode) {
switch (resizeMode) {
case AspectRatioFrameLayout.RESIZE_MODE_FIT:
- return context.getResources().getString(R.string.resize_fit);
+ return context.getString(R.string.resize_fit);
case AspectRatioFrameLayout.RESIZE_MODE_FILL:
- return context.getResources().getString(R.string.resize_fill);
+ return context.getString(R.string.resize_fill);
case AspectRatioFrameLayout.RESIZE_MODE_ZOOM:
- return context.getResources().getString(R.string.resize_zoom);
+ return context.getString(R.string.resize_zoom);
case AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT:
case AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH:
default:
@@ -300,10 +296,6 @@ public static ExoTrackSelection.Factory getQualitySelector() {
AdaptiveTrackSelection.DEFAULT_BANDWIDTH_FRACTION);
}
- public static boolean isUsingDSP() {
- return true;
- }
-
@NonNull
public static CaptionStyleCompat getCaptionStyle(@NonNull final Context context) {
final CaptioningManager captioningManager = ContextCompat.getSystemService(context,
@@ -410,23 +402,9 @@ private static SinglePlayQueue getAutoQueuedSinglePlayQueue(
return singlePlayQueue;
}
-
// endregion
// region Utils used by player
- @RepeatMode
- public static int nextRepeatMode(@RepeatMode final int repeatMode) {
- switch (repeatMode) {
- case REPEAT_MODE_OFF:
- return REPEAT_MODE_ONE;
- case REPEAT_MODE_ONE:
- return REPEAT_MODE_ALL;
- case REPEAT_MODE_ALL:
- default:
- return REPEAT_MODE_OFF;
- }
- }
-
@ResizeMode
public static int retrieveResizeModeFromPrefs(final Player player) {
return player.getPrefs().getInt(player.getContext().getString(R.string.last_resize_mode),
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 97f2d671749..9edfc804a75 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
@@ -192,9 +192,11 @@ public void onServiceConnected(final ComponentName compName, final IBinder servi
startPlayerListener();
// ^ will call listener.onPlayerConnected() down the line if there is an active player
- // notify the main activity that binding the service has completed, so that it can
- // open the bottom mini-player
- NavigationHelper.sendPlayerStartedEvent(localBinder.getService());
+ if (playerService != null && playerService.getPlayer() != null) {
+ // notify the main activity that binding the service has completed and that there is
+ // a player, so that it can open the bottom mini-player
+ NavigationHelper.sendPlayerStartedEvent(localBinder.getService());
+ }
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt
index b7d57657da6..d221d704bd8 100644
--- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserImpl.kt
@@ -315,7 +315,7 @@ class MediaBrowserImpl(
}
private fun populateHistory(): Single> {
- val history = database.streamHistoryDAO().getHistory().firstOrError()
+ val history = database.streamHistoryDAO().history.firstOrError()
return history.map { items ->
items.map { this.createHistoryMediaItem(it) }
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt
index 2948eeaf8d1..072a8f3321d 100644
--- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/MediaBrowserPlaybackPreparer.kt
@@ -17,6 +17,7 @@ import io.reactivex.rxjava3.schedulers.Schedulers
import org.schabi.newpipe.MainActivity
import org.schabi.newpipe.NewPipeDatabase
import org.schabi.newpipe.R
+import org.schabi.newpipe.error.ErrorInfo
import org.schabi.newpipe.extractor.InfoItem.InfoType
import org.schabi.newpipe.extractor.exceptions.ContentNotAvailableException
import org.schabi.newpipe.extractor.linkhandler.ListLinkHandler
@@ -84,7 +85,7 @@ class MediaBrowserPlaybackPreparer(
},
{ throwable ->
Log.e(TAG, "Failed to start playback of media ID [$mediaId]", throwable)
- onPrepareError()
+ onPrepareError(throwable)
}
)
}
@@ -115,9 +116,9 @@ class MediaBrowserPlaybackPreparer(
)
}
- private fun onPrepareError() {
+ private fun onPrepareError(throwable: Throwable) {
setMediaSessionError.accept(
- ContextCompat.getString(context, R.string.error_snackbar_message),
+ ErrorInfo.getMessage(throwable, null, null).getString(context),
PlaybackStateCompat.ERROR_CODE_APP_ERROR
)
}
@@ -214,7 +215,7 @@ class MediaBrowserPlaybackPreparer(
}
val streamId = path[0].toLong()
- return database.streamHistoryDAO().getHistory()
+ return database.streamHistoryDAO().history
.firstOrError()
.map { items ->
val infoItems = items
diff --git a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/PackageValidator.kt b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/PackageValidator.kt
index 973b11b3762..05719b6d4e1 100644
--- a/app/src/main/java/org/schabi/newpipe/player/mediabrowser/PackageValidator.kt
+++ b/app/src/main/java/org/schabi/newpipe/player/mediabrowser/PackageValidator.kt
@@ -147,18 +147,15 @@ internal class PackageValidator(context: Context) {
private fun buildCallerInfo(callingPackage: String): CallerPackageInfo? {
val packageInfo = getPackageInfo(callingPackage) ?: return null
- val appName = packageInfo.applicationInfo.loadLabel(packageManager).toString()
- val uid = packageInfo.applicationInfo.uid
+ val appName = packageInfo.applicationInfo?.loadLabel(packageManager).toString()
+ val uid = packageInfo.applicationInfo?.uid ?: -1
val signature = getSignature(packageInfo)
- val requestedPermissions = packageInfo.requestedPermissions
- val permissionFlags = packageInfo.requestedPermissionsFlags
- val activePermissions = mutableSetOf()
- requestedPermissions?.forEachIndexed { index, permission ->
- if (permissionFlags[index] and REQUESTED_PERMISSION_GRANTED != 0) {
- activePermissions += permission
- }
- }
+ val requestedPermissions = packageInfo.requestedPermissions?.asSequence().orEmpty()
+ val permissionFlags = packageInfo.requestedPermissionsFlags?.asSequence().orEmpty()
+ val activePermissions = (requestedPermissions zip permissionFlags)
+ .filter { (permission, flag) -> flag and REQUESTED_PERMISSION_GRANTED != 0 }
+ .mapTo(mutableSetOf()) { (permission, flag) -> permission }
return CallerPackageInfo(appName, callingPackage, uid, signature, activePermissions.toSet())
}
@@ -189,12 +186,12 @@ internal class PackageValidator(context: Context) {
*/
@Suppress("deprecation")
private fun getSignature(packageInfo: PackageInfo): String? =
- if (packageInfo.signatures == null || packageInfo.signatures.size != 1) {
+ if (packageInfo.signatures == null || packageInfo.signatures!!.size != 1) {
// Security best practices dictate that an app should be signed with exactly one (1)
// signature. Because of this, if there are multiple signatures, reject it.
null
} else {
- val certificate = packageInfo.signatures[0].toByteArray()
+ val certificate = packageInfo.signatures!![0].toByteArray()
getSignatureSha256(certificate)
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java
index 30420b0c7da..9b9c47b0e85 100644
--- a/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java
+++ b/app/src/main/java/org/schabi/newpipe/player/notification/NotificationUtil.java
@@ -5,7 +5,9 @@
import static org.schabi.newpipe.player.notification.NotificationConstants.ACTION_CLOSE;
import android.annotation.SuppressLint;
+import android.app.Notification;
import android.app.PendingIntent;
+import android.content.Context;
import android.content.Intent;
import android.content.pm.ServiceInfo;
import android.graphics.Bitmap;
@@ -23,6 +25,8 @@
import org.schabi.newpipe.MainActivity;
import org.schabi.newpipe.R;
import org.schabi.newpipe.player.Player;
+import org.schabi.newpipe.player.PlayerIntentType;
+import org.schabi.newpipe.player.PlayerService;
import org.schabi.newpipe.player.mediasession.MediaSessionPlayerUi;
import org.schabi.newpipe.util.NavigationHelper;
@@ -89,12 +93,9 @@ private synchronized NotificationCompat.Builder createNotification() {
Log.d(TAG, "createNotification()");
}
notificationManager = NotificationManagerCompat.from(player.getContext());
- final NotificationCompat.Builder builder =
- new NotificationCompat.Builder(player.getContext(),
- player.getContext().getString(R.string.notification_channel_id));
- final MediaStyle mediaStyle = new MediaStyle();
// setup media style (compact notification slots and media session)
+ final MediaStyle mediaStyle = new MediaStyle();
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) {
// notification actions are ignored on Android 13+, and are replaced by code in
// MediaSessionPlayerUi
@@ -107,18 +108,9 @@ private synchronized NotificationCompat.Builder createNotification() {
.ifPresent(mediaStyle::setMediaSession);
// setup notification builder
- builder.setStyle(mediaStyle)
- .setPriority(NotificationCompat.PRIORITY_HIGH)
- .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
- .setCategory(NotificationCompat.CATEGORY_TRANSPORT)
- .setShowWhen(false)
- .setSmallIcon(R.drawable.ic_newpipe_triangle_white)
- .setColor(ContextCompat.getColor(player.getContext(),
- R.color.dark_background_color))
+ final var builder = setupNotificationBuilder(player.getContext(), mediaStyle)
.setColorized(player.getPrefs().getBoolean(
- player.getContext().getString(R.string.notification_colorize_key), true))
- .setDeleteIntent(PendingIntentCompat.getBroadcast(player.getContext(),
- NOTIFICATION_ID, new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT, false));
+ player.getContext().getString(R.string.notification_colorize_key), true));
// set the initial value for the video thumbnail, updatable with updateNotificationThumbnail
setLargeIcon(builder);
@@ -167,19 +159,17 @@ public boolean shouldUpdateBufferingSlot() {
&& notificationBuilder.mActions.get(2).actionIntent != null);
}
+ public static void startForegroundWithDummyNotification(final PlayerService service) {
+ final var builder = setupNotificationBuilder(service, new MediaStyle());
+ startForeground(service, builder.build());
+ }
public void createNotificationAndStartForeground() {
if (notificationBuilder == null) {
notificationBuilder = createNotification();
}
updateNotification();
-
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
- player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build(),
- ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK);
- } else {
- player.getService().startForeground(NOTIFICATION_ID, notificationBuilder.build());
- }
+ startForeground(player.getService(), notificationBuilder.build());
}
public void cancelNotificationAndStopForeground() {
@@ -193,6 +183,34 @@ public void cancelNotificationAndStopForeground() {
}
+ /////////////////////////////////////////////////////
+ // STATIC FUNCTIONS IN COMMON BETWEEN DUMMY AND REAL NOTIFICATION
+ /////////////////////////////////////////////////////
+
+ private static NotificationCompat.Builder setupNotificationBuilder(final Context context,
+ final MediaStyle style) {
+ return new NotificationCompat.Builder(context,
+ context.getString(R.string.notification_channel_id))
+ .setStyle(style)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ .setCategory(NotificationCompat.CATEGORY_TRANSPORT)
+ .setShowWhen(false)
+ .setSmallIcon(R.drawable.ic_newpipe_triangle_white)
+ .setColor(ContextCompat.getColor(context, R.color.dark_background_color))
+ .setDeleteIntent(PendingIntentCompat.getBroadcast(context,
+ NOTIFICATION_ID, new Intent(ACTION_CLOSE), FLAG_UPDATE_CURRENT, false));
+ }
+
+ private static void startForeground(final PlayerService service,
+ final Notification notification) {
+ // ServiceInfo constants are not used below Android Q, so 0 is set here
+ final int serviceType = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
+ ? ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK : 0;
+ ServiceCompat.startForeground(service, NOTIFICATION_ID, notification, serviceType);
+ }
+
+
/////////////////////////////////////////////////////
// ACTIONS
/////////////////////////////////////////////////////
@@ -256,7 +274,9 @@ private Intent getIntentForNotification() {
} else {
// We are playing in fragment. Don't open another activity just show fragment. That's it
final Intent intent = NavigationHelper.getPlayerIntent(
- player.getContext(), MainActivity.class, null, true);
+ player.getContext(), MainActivity.class, null,
+ PlayerIntentType.AllOthers);
+ intent.putExtra(Player.RESUME_PLAYBACK, true);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.setAction(Intent.ACTION_MAIN);
intent.addCategory(Intent.CATEGORY_LAUNCHER);
diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java
index cfa2ab3162c..2a1b9d28185 100644
--- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java
+++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueue.java
@@ -23,7 +23,7 @@
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
import io.reactivex.rxjava3.core.BackpressureStrategy;
import io.reactivex.rxjava3.core.Flowable;
-import io.reactivex.rxjava3.subjects.BehaviorSubject;
+import io.reactivex.rxjava3.subjects.PublishSubject;
/**
* PlayQueue is responsible for keeping track of a list of streams and the index of
@@ -46,7 +46,7 @@ public abstract class PlayQueue implements Serializable {
private List backup;
private List streams;
- private transient BehaviorSubject eventBroadcast;
+ private transient PublishSubject eventBroadcast;
private transient Flowable broadcastReceiver;
private transient boolean disposed = false;
@@ -71,7 +71,7 @@ public abstract class PlayQueue implements Serializable {
*
*/
public void init() {
- eventBroadcast = BehaviorSubject.create();
+ eventBroadcast = PublishSubject.create();
broadcastReceiver = eventBroadcast.toFlowable(BackpressureStrategy.BUFFER)
.observeOn(AndroidSchedulers.mainThread())
@@ -291,6 +291,22 @@ public synchronized void append(@NonNull final List items) {
broadcast(new AppendEvent(itemList.size()));
}
+ /**
+ * Add the given item after the current stream.
+ *
+ * @param item item to add.
+ * @param skipIfSame if set, skip adding if the next stream is the same stream.
+ */
+ public void enqueueNext(@NonNull final PlayQueueItem item, final boolean skipIfSame) {
+ final int currentIndex = getIndex();
+ // if the next item is the same item as the one we want to enqueue, skip if flag is true
+ if (skipIfSame && item.isSameItem(getItem(currentIndex + 1))) {
+ return;
+ }
+ append(List.of(item));
+ move(size() - 1, currentIndex + 1);
+ }
+
/**
* Removes the item at the given index from the play queue.
*
@@ -529,8 +545,7 @@ public boolean equalStreams(@Nullable final PlayQueue other) {
final PlayQueueItem stream = streams.get(i);
final PlayQueueItem otherStream = other.streams.get(i);
// Check is based on serviceId and URL
- if (stream.getServiceId() != otherStream.getServiceId()
- || !stream.getUrl().equals(otherStream.getUrl())) {
+ if (!stream.isSameItem(otherStream)) {
return false;
}
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java
index 759c512671c..d1d897c39c6 100644
--- a/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java
+++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/PlayQueueItem.java
@@ -38,7 +38,7 @@ public class PlayQueueItem implements Serializable {
private long recoveryPosition;
private Throwable error;
- PlayQueueItem(@NonNull final StreamInfo info) {
+ public PlayQueueItem(@NonNull final StreamInfo info) {
this(info.getName(), info.getUrl(), info.getServiceId(), info.getDuration(),
info.getThumbnails(), info.getUploaderName(),
info.getUploaderUrl(), info.getStreamType());
@@ -71,6 +71,22 @@ private PlayQueueItem(@Nullable final String name, @Nullable final String url,
this.recoveryPosition = RECOVERY_UNSET;
}
+ /** Whether these two items should be treated as the same stream
+ * for the sake of keeping the same player running when e.g. jumping between timestamps.
+ *
+ * @param other the {@link PlayQueueItem} to compare against.
+ * @return whether the two items are the same so the stream can be re-used.
+ */
+ public boolean isSameItem(@Nullable final PlayQueueItem other) {
+ if (other == null) {
+ return false;
+ }
+ // We assume that the same service & URL uniquely determines
+ // that we can keep the same stream running.
+ return serviceId == other.serviceId
+ && url.equals(other.url);
+ }
+
@NonNull
public String getTitle() {
return title;
diff --git a/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java b/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java
index 0eb0f235ac6..f13d7924d0f 100644
--- a/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java
+++ b/app/src/main/java/org/schabi/newpipe/player/playqueue/SinglePlayQueue.java
@@ -16,7 +16,9 @@ public SinglePlayQueue(final StreamInfoItem item) {
public SinglePlayQueue(final StreamInfo info) {
super(0, List.of(new PlayQueueItem(info)));
}
-
+ public SinglePlayQueue(final PlayQueueItem item) {
+ super(0, List.of(item));
+ }
public SinglePlayQueue(final StreamInfo info, final long startPosition) {
super(0, List.of(new PlayQueueItem(info)));
getItem().setRecoveryPosition(startPosition);
diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java
index d8efb30df7d..bfcc82984b7 100644
--- a/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java
+++ b/app/src/main/java/org/schabi/newpipe/player/ui/MainPlayerUi.java
@@ -289,8 +289,10 @@ protected void setupElementsVisibility() {
binding.topControls.setClickable(true);
binding.topControls.setFocusable(true);
- binding.titleTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE);
- binding.channelTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE);
+ binding.metadataView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE);
+
+ // Reset workaround changes from popup player
+ binding.audioTrackTextView.setMaxWidth(Integer.MAX_VALUE);
}
@Override
@@ -934,8 +936,7 @@ public void toggleFullscreen() {
}
fragmentListener.onFullscreenStateChanged(isFullscreen);
- binding.titleTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE);
- binding.channelTextView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE);
+ binding.metadataView.setVisibility(isFullscreen ? View.VISIBLE : View.GONE);
binding.playerCloseButton.setVisibility(isFullscreen ? View.GONE : View.VISIBLE);
setupScreenRotationButton();
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java
index 6c98ab0fa0f..24b734fe010 100644
--- a/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java
+++ b/app/src/main/java/org/schabi/newpipe/player/ui/PopupPlayerUi.java
@@ -40,6 +40,7 @@
import org.schabi.newpipe.player.gesture.BasePlayerGestureListener;
import org.schabi.newpipe.player.gesture.PopupPlayerGestureListener;
import org.schabi.newpipe.player.helper.PlayerHelper;
+import org.schabi.newpipe.util.DeviceUtils;
public final class PopupPlayerUi extends VideoPlayerUi {
private static final String TAG = PopupPlayerUi.class.getSimpleName();
@@ -174,6 +175,8 @@ protected void setupElementsVisibility() {
binding.topControls.setClickable(false);
binding.topControls.setFocusable(false);
binding.bottomControls.bringToFront();
+ // Workaround that UI elements are pushed off screen
+ binding.audioTrackTextView.setMaxWidth(DeviceUtils.dpToPx(48, context));
super.setupElementsVisibility();
}
diff --git a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java
index 7157d6af22f..b68d3d94dbd 100644
--- a/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java
+++ b/app/src/main/java/org/schabi/newpipe/player/ui/VideoPlayerUi.java
@@ -1554,6 +1554,11 @@ void onResizeClicked() {
@Override
public void onVideoSizeChanged(@NonNull final VideoSize videoSize) {
super.onVideoSizeChanged(videoSize);
+ // Starting with ExoPlayer 2.19.0, the VideoSize will report a width and height of 0
+ // if the renderer is disabled. In that case, we skip updating the aspect ratio.
+ if (videoSize.width == 0 || videoSize.height == 0) {
+ return;
+ }
binding.surfaceView.setAspectRatio(((float) videoSize.width) / videoSize.height);
}
//endregion
diff --git a/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java
index 321ad65dac8..baaa93e4445 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/BackupRestoreSettingsFragment.java
@@ -40,6 +40,8 @@
import java.util.Date;
import java.util.Locale;
import java.util.Objects;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
public class BackupRestoreSettingsFragment extends BasePreferenceFragment {
@@ -96,10 +98,9 @@ ZIP_MIME_TYPE, getImportExportDataUri()),
return true;
});
- final Preference resetSettings = findPreference(getString(R.string.reset_settings));
+ final Preference resetSettings = requirePreference(R.string.reset_settings);
// Resets all settings by deleting shared preference and restarting the app
// A dialogue will pop up to confirm if user intends to reset all settings
- assert resetSettings != null;
resetSettings.setOnPreferenceClickListener(preference -> {
// Show Alert Dialogue
final AlertDialog.Builder builder = new AlertDialog.Builder(getContext());
@@ -155,9 +156,9 @@ private void requestImportPathResult(final ActivityResult result) {
}
private void exportDatabase(final StoredFileHelper file, final Uri exportDataUri) {
- try {
+ try (ExecutorService executor = Executors.newSingleThreadExecutor()) {
//checkpoint before export
- NewPipeDatabase.checkpoint();
+ executor.submit(NewPipeDatabase::checkpoint).get();
final SharedPreferences preferences = PreferenceManager
.getDefaultSharedPreferences(requireContext());
diff --git a/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java b/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java
index 619579f3a73..21cba3daa16 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/BasePreferenceFragment.java
@@ -48,8 +48,8 @@ public void onResume() {
}
@NonNull
- public final Preference requirePreference(@StringRes final int resId) {
- final Preference preference = findPreference(getString(resId));
+ public final T requirePreference(@StringRes final int resId) {
+ final T preference = findPreference(getString(resId));
Objects.requireNonNull(preference);
return preference;
}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java
index d78ade49df6..82f2f5bb632 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/DebugSettingsFragment.java
@@ -22,27 +22,20 @@ public void onCreatePreferences(final Bundle savedInstanceState, final String ro
addPreferencesFromResourceRegistry();
final Preference allowHeapDumpingPreference =
- findPreference(getString(R.string.allow_heap_dumping_key));
+ requirePreference(R.string.allow_heap_dumping_key);
final Preference showMemoryLeaksPreference =
- findPreference(getString(R.string.show_memory_leaks_key));
+ requirePreference(R.string.show_memory_leaks_key);
final Preference showImageIndicatorsPreference =
- findPreference(getString(R.string.show_image_indicators_key));
+ requirePreference(R.string.show_image_indicators_key);
final Preference checkNewStreamsPreference =
- findPreference(getString(R.string.check_new_streams_key));
+ requirePreference(R.string.check_new_streams_key);
final Preference crashTheAppPreference =
- findPreference(getString(R.string.crash_the_app_key));
+ requirePreference(R.string.crash_the_app_key);
final Preference showErrorSnackbarPreference =
- findPreference(getString(R.string.show_error_snackbar_key));
+ requirePreference(R.string.show_error_snackbar_key);
final Preference createErrorNotificationPreference =
- findPreference(getString(R.string.create_error_notification_key));
-
- assert allowHeapDumpingPreference != null;
- assert showMemoryLeaksPreference != null;
- assert showImageIndicatorsPreference != null;
- assert checkNewStreamsPreference != null;
- assert crashTheAppPreference != null;
- assert showErrorSnackbarPreference != null;
- assert createErrorNotificationPreference != null;
+ requirePreference(R.string.create_error_notification_key);
+
final Optional optBVLeakCanary = getBVDLeakCanary();
diff --git a/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java
index 32e33d55bf6..cb3de39a0f4 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/MainSettingsFragment.java
@@ -25,7 +25,7 @@ public void onCreatePreferences(final Bundle savedInstanceState, final String ro
// Check if the app is updatable
if (!ReleaseVersionUtil.INSTANCE.isReleaseApk()) {
getPreferenceScreen().removePreference(
- findPreference(getString(R.string.update_pref_screen_key)));
+ requirePreference(R.string.update_pref_screen_key));
defaultPreferences.edit().putBoolean(getString(R.string.update_app_key), false).apply();
}
@@ -33,7 +33,7 @@ public void onCreatePreferences(final Bundle savedInstanceState, final String ro
// Hide debug preferences in RELEASE build variant
if (!DEBUG) {
getPreferenceScreen().removePreference(
- findPreference(getString(R.string.debug_pref_screen_key)));
+ requirePreference(R.string.debug_pref_screen_key));
}
}
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 0a5512c699f..7cb1564b386 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/NewPipeSettings.java
@@ -103,12 +103,12 @@ private static String getNewPipeChildFolderPathForDir(final File dir) {
}
public static boolean useStorageAccessFramework(final Context context) {
- // There's a FireOS bug which prevents SAF open/close dialogs from being confirmed with a
- // remote (see #6455).
- if (DeviceUtils.isFireTv()) {
- return false;
- } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
return true;
+ } else if (DeviceUtils.isFireTv()) {
+ // There's a FireOS bug which prevents SAF open/close dialogs from being confirmed with
+ // a remote (see #6455).
+ return false;
}
final String key = context.getString(R.string.storage_use_saf);
diff --git a/app/src/main/java/org/schabi/newpipe/settings/NotificationsSettingsFragment.kt b/app/src/main/java/org/schabi/newpipe/settings/NotificationsSettingsFragment.kt
index 2d3344c09f3..d6b0a84daf1 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/NotificationsSettingsFragment.kt
+++ b/app/src/main/java/org/schabi/newpipe/settings/NotificationsSettingsFragment.kt
@@ -29,8 +29,7 @@ class NotificationsSettingsFragment : BasePreferenceFragment(), OnSharedPreferen
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.notifications_settings)
- streamsNotificationsPreference =
- findPreference(getString(R.string.enable_streams_notifications))
+ streamsNotificationsPreference = requirePreference(R.string.enable_streams_notifications)
// main check is done in onResume, but also do it here to prevent flickering
updateEnabledState(NotificationHelper.areNotificationsEnabledOnDevice(requireContext()))
@@ -125,8 +124,8 @@ class NotificationsSettingsFragment : BasePreferenceFragment(), OnSharedPreferen
private fun updateSubscriptions(subscriptions: List) {
val notified = subscriptions.count { it.notificationMode != NotificationMode.DISABLED }
- val preference = findPreference(getString(R.string.streams_notifications_channels_key))
- preference?.apply { summary = "$notified/${subscriptions.size}" }
+ val preference = requirePreference(R.string.streams_notifications_channels_key)
+ preference.summary = "$notified/${subscriptions.size}"
}
private fun onError(e: Throwable) {
diff --git a/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java b/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java
index 1158b3d8307..81fddbcfbc8 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/PeertubeInstanceListFragment.java
@@ -396,7 +396,8 @@ void bind(final int position) {
}
}
- private static class PeertubeInstanceCallback extends DiffUtil.ItemCallback {
+ private static final class PeertubeInstanceCallback
+ extends DiffUtil.ItemCallback {
@Override
public boolean areItemsTheSame(@NonNull final PeertubeInstance oldItem,
@NonNull final PeertubeInstance newItem) {
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 37335421d16..18e0816bbde 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SelectChannelFragment.java
@@ -174,7 +174,7 @@ public interface OnCancelListener {
void onCancel();
}
- private class SelectChannelAdapter
+ private final class SelectChannelAdapter
extends RecyclerView.Adapter {
@NonNull
@Override
diff --git a/app/src/main/java/org/schabi/newpipe/settings/SelectFeedGroupFragment.java b/app/src/main/java/org/schabi/newpipe/settings/SelectFeedGroupFragment.java
index 662379369bf..c106f599809 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SelectFeedGroupFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SelectFeedGroupFragment.java
@@ -175,7 +175,7 @@ public interface OnCancelListener {
void onCancel();
}
- private class SelectFeedGroupAdapter
+ private final class SelectFeedGroupAdapter
extends RecyclerView.Adapter {
@NonNull
@Override
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 36abef9e5ca..880cbb282ea 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/SelectPlaylistFragment.java
@@ -118,12 +118,12 @@ private void clickedItem(final int position) {
if (selectedItem instanceof PlaylistMetadataEntry) {
final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem);
- onSelectedListener.onLocalPlaylistSelected(entry.getUid(), entry.name);
+ onSelectedListener.onLocalPlaylistSelected(entry.getUid(), entry.getOrderingName());
} else if (selectedItem instanceof PlaylistRemoteEntity) {
final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem);
onSelectedListener.onRemotePlaylistSelected(
- entry.getServiceId(), entry.getUrl(), entry.getName());
+ entry.getServiceId(), entry.getUrl(), entry.getOrderingName());
}
}
dismiss();
@@ -138,7 +138,7 @@ public interface OnSelectedListener {
void onRemotePlaylistSelected(int serviceId, String url, String name);
}
- private class SelectPlaylistAdapter
+ private final class SelectPlaylistAdapter
extends RecyclerView.Adapter {
@NonNull
@Override
@@ -157,14 +157,15 @@ public void onBindViewHolder(@NonNull final SelectPlaylistItemHolder holder,
if (selectedItem instanceof PlaylistMetadataEntry) {
final PlaylistMetadataEntry entry = ((PlaylistMetadataEntry) selectedItem);
- holder.titleView.setText(entry.name);
+ holder.titleView.setText(entry.getOrderingName());
holder.view.setOnClickListener(view -> clickedItem(position));
- PicassoHelper.loadPlaylistThumbnail(entry.thumbnailUrl).into(holder.thumbnailView);
+ PicassoHelper.loadPlaylistThumbnail(entry.getThumbnailUrl())
+ .into(holder.thumbnailView);
} else if (selectedItem instanceof PlaylistRemoteEntity) {
final PlaylistRemoteEntity entry = ((PlaylistRemoteEntity) selectedItem);
- holder.titleView.setText(entry.getName());
+ holder.titleView.setText(entry.getOrderingName());
holder.view.setOnClickListener(view -> clickedItem(position));
PicassoHelper.loadPlaylistThumbnail(entry.getThumbnailUrl())
.into(holder.thumbnailView);
diff --git a/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java
index b8d0aa556d3..8923972b004 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/UpdateSettingsFragment.java
@@ -34,9 +34,9 @@ public class UpdateSettingsFragment extends BasePreferenceFragment {
public void onCreatePreferences(final Bundle savedInstanceState, final String rootKey) {
addPreferencesFromResourceRegistry();
- findPreference(getString(R.string.update_app_key))
+ requirePreference(R.string.update_app_key)
.setOnPreferenceChangeListener(updatePreferenceChange);
- findPreference(getString(R.string.manual_update_key))
+ requirePreference(R.string.manual_update_key)
.setOnPreferenceClickListener(manualUpdateClick);
}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java
index a1f563724ee..c5c4c480c2d 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/VideoAudioSettingsFragment.java
@@ -90,12 +90,12 @@ private void updateResolutionOptions() {
showHigherResolutions);
// get resolution preferences
- final ListPreference defaultResolution = findPreference(
- getString(R.string.default_resolution_key));
- final ListPreference defaultPopupResolution = findPreference(
- getString(R.string.default_popup_resolution_key));
- final ListPreference mobileDataResolution = findPreference(
- getString(R.string.limit_mobile_data_usage_key));
+ final ListPreference defaultResolution = requirePreference(
+ R.string.default_resolution_key);
+ final ListPreference defaultPopupResolution = requirePreference(
+ R.string.default_popup_resolution_key);
+ final ListPreference mobileDataResolution = requirePreference(
+ R.string.limit_mobile_data_usage_key);
// update resolution preferences with new resolutions, entries & values for each
defaultResolution.setEntries(resolutionListDescriptions.toArray(new String[0]));
@@ -161,8 +161,7 @@ private void updateSeekOptions() {
}
}
- final ListPreference durations = findPreference(
- getString(R.string.seek_duration_key));
+ final ListPreference durations = requirePreference(R.string.seek_duration_key);
durations.setEntryValues(displayedDurationValues.toArray(new CharSequence[0]));
durations.setEntries(displayedDescriptionValues.toArray(new CharSequence[0]));
final int selectedDuration = Integer.parseInt(durations.getValue());
diff --git a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigAdapter.kt b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigAdapter.kt
index f61aa72ab18..fd8abfa1651 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigAdapter.kt
+++ b/app/src/main/java/org/schabi/newpipe/settings/notifications/NotificationModeConfigAdapter.kt
@@ -31,7 +31,7 @@ class NotificationModeConfigAdapter(
fun update(newData: List) {
val items = newData.map {
- SubscriptionItem(it.uid, it.name, it.notificationMode, it.serviceId, it.url)
+ SubscriptionItem(it.uid, it.name!!, it.notificationMode, it.serviceId, it.url!!)
}
submitList(items)
}
diff --git a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.java b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.java
index d6e2021a15f..dd59ba86e5b 100644
--- a/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.java
+++ b/app/src/main/java/org/schabi/newpipe/settings/preferencesearch/PreferenceSearchAdapter.java
@@ -69,7 +69,8 @@ static class PreferenceViewHolder extends RecyclerView.ViewHolder {
}
}
- private static class PreferenceCallback extends DiffUtil.ItemCallback {
+ private static final class PreferenceCallback
+ extends DiffUtil.ItemCallback {
@Override
public boolean areItemsTheSame(@NonNull final PreferenceSearchItem oldItem,
@NonNull final PreferenceSearchItem newItem) {
diff --git a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java
index 266cec24a4d..7cdc84e2227 100644
--- a/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java
+++ b/app/src/main/java/org/schabi/newpipe/streams/OggFromWebMWriter.java
@@ -1,8 +1,14 @@
package org.schabi.newpipe.streams;
+import static org.schabi.newpipe.MainActivity.DEBUG;
+
+import android.util.Log;
+import android.util.Pair;
+
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
+import org.schabi.newpipe.extractor.stream.StreamInfo;
import org.schabi.newpipe.streams.WebMReader.Cluster;
import org.schabi.newpipe.streams.WebMReader.Segment;
import org.schabi.newpipe.streams.WebMReader.SimpleBlock;
@@ -13,6 +19,10 @@
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
/**
* @author kapodamy
@@ -52,8 +62,10 @@ public class OggFromWebMWriter implements Closeable {
private long segmentTableNextTimestamp = TIME_SCALE_NS;
private final int[] crc32Table = new int[256];
+ private final StreamInfo streamInfo;
- public OggFromWebMWriter(@NonNull final SharpStream source, @NonNull final SharpStream target) {
+ public OggFromWebMWriter(@NonNull final SharpStream source, @NonNull final SharpStream target,
+ @Nullable final StreamInfo streamInfo) {
if (!source.canRead() || !source.canRewind()) {
throw new IllegalArgumentException("source stream must be readable and allows seeking");
}
@@ -63,6 +75,7 @@ public OggFromWebMWriter(@NonNull final SharpStream source, @NonNull final Sharp
this.source = source;
this.output = target;
+ this.streamInfo = streamInfo;
this.streamId = (int) System.currentTimeMillis();
@@ -271,12 +284,31 @@ private int makePacketheader(final long granPos, @NonNull final ByteBuffer buffe
@Nullable
private byte[] makeMetadata() {
+ if (DEBUG) {
+ Log.d("OggFromWebMWriter", "Downloading media with codec ID " + webmTrack.codecId);
+ }
+
if ("A_OPUS".equals(webmTrack.codecId)) {
- return new byte[]{
- 0x4F, 0x70, 0x75, 0x73, 0x54, 0x61, 0x67, 0x73, // "OpusTags" binary string
- 0x00, 0x00, 0x00, 0x00, // writing application string size (not present)
- 0x00, 0x00, 0x00, 0x00 // additional tags count (zero means no tags)
- };
+ final var metadata = new ArrayList>();
+ if (streamInfo != null) {
+ metadata.add(Pair.create("COMMENT", streamInfo.getUrl()));
+ metadata.add(Pair.create("GENRE", streamInfo.getCategory()));
+ metadata.add(Pair.create("ARTIST", streamInfo.getUploaderName()));
+ metadata.add(Pair.create("TITLE", streamInfo.getName()));
+ metadata.add(Pair.create("DATE", streamInfo
+ .getUploadDate()
+ .getLocalDateTime()
+ .format(DateTimeFormatter.ISO_DATE)));
+ }
+
+ if (DEBUG) {
+ Log.d("OggFromWebMWriter", "Creating metadata header with this data:");
+ metadata.forEach(p -> {
+ Log.d("OggFromWebMWriter", p.first + "=" + p.second);
+ });
+ }
+
+ return makeOpusTagsHeader(metadata);
} else if ("A_VORBIS".equals(webmTrack.codecId)) {
return new byte[]{
0x03, // ¿¿¿???
@@ -290,6 +322,59 @@ private byte[] makeMetadata() {
return null;
}
+ /**
+ * This creates a single metadata tag for use in opus metadata headers. It contains the four
+ * byte string length field and includes the string as-is. This cannot be used independently,
+ * but must follow a proper "OpusTags" header.
+ *
+ * @param pair A key-value pair in the format "KEY=some value"
+ * @return The binary data of the encoded metadata tag
+ */
+ private static byte[] makeOpusMetadataTag(final Pair pair) {
+ final var keyValue = pair.first.toUpperCase() + "=" + pair.second.trim();
+
+ final var bytes = keyValue.getBytes();
+ final var buf = ByteBuffer.allocate(4 + bytes.length);
+ buf.order(ByteOrder.LITTLE_ENDIAN);
+ buf.putInt(bytes.length);
+ buf.put(bytes);
+ return buf.array();
+ }
+
+ /**
+ * This returns a complete "OpusTags" header, created from the provided metadata tags.
+ *